From 7c6cb9eeb41979c3cf2db77c173745adb4eae6bc Mon Sep 17 00:00:00 2001 From: Ali Kazemi Date: Mon, 28 Sep 2026 21:08:01 -0600 Subject: [PATCH 01/37] Update TCubeLaserControl.jl documentation for closed-loop calibration details --- .../tcube_laser/TCubeLaserControl.jl | 67 ++++++++++++------- 1 file changed, 41 insertions(+), 26 deletions(-) diff --git a/src/hardware_implementations/tcube_laser/TCubeLaserControl.jl b/src/hardware_implementations/tcube_laser/TCubeLaserControl.jl index 2bb7baa..31a6350 100644 --- a/src/hardware_implementations/tcube_laser/TCubeLaserControl.jl +++ b/src/hardware_implementations/tcube_laser/TCubeLaserControl.jl @@ -19,37 +19,52 @@ import ...MicroscopeControl.HardwareInterfaces.LightSourceInterface: gui as red_ const Thorlabs_Tcube_laser = "C:\\Program Files\\Thorlabs\\Kinesis\\Thorlabs.MotionControl.TCube.LaserDiode.dll" -# Bench data measured on this controller in open-loop mode, preserved from the -# deleted `helpers.jl` (which drove 80 mA into a specific lab laser at include -# time and so could not be kept). Expected power as set on the Kinesis display -# versus the power actually measured before the fiber, in mW; single readings, -# no repeats or statistics: +# Closed-loop (constant power) calibration of the 642 nm rig: TLD001 serial +# 64849775, monitor photodiode on the 1 mA TIA range. Measured by Ali Kazemi Nasaban Shotorban +# with a power meter before the fiber (see the module docstring), and recorded +# in the deleted `helpers.jl` (Oct 2024, which drove the laser at include time +# and so could not be kept): # -# expected measured -# 0.0 < 0.001 -# 10.0 9.31 -# 20.0 19.11 -# 30.0 29.11 -# 40.0 39.06 -# 50.0 48.97 -# 60.0 59.22 -# 70.0 69.65 -# 80.0 79.50 +# W/A calibration factor 224.2 (LD_SetWACalibFactor) +# TIA range 1.0 mA (rear-panel DIP switch; read-only in software) # -# `helpers.jl` also held the only worked example of the closed-loop bindings -# (`LD_SetClosedLoopMode`, `LD_SetWACalibFactor`, `LD_GetPhotoCurrentReading`). -# A conditional optical scaling is derivable from it: +# Closed-loop entry and command sequence from `helpers.jl`: # +# LD_SetClosedLoopMode(serialNo) +# LD_SetWACalibFactor(serialNo, 224.2) +# LD_SetLaserSetPoint(serialNo, UInt16(round(power_mW * 32767 / 224.2 / 1.0))) +# +# In closed loop the setpoint word is photocurrent, 0..32767 = 0..TIA full +# scale, so the conversions both ways are +# +# word = power_mW / calibration_W_per_A / TIA_range_mA * 32767 # power_mW = raw / 32767 * TIA_range_mA * calibration_W_per_A # -# where `raw` is the word `LD_GetPhotoCurrentReading` returns. The two factors -# are assumptions from one bench setup, not device constants, and the driver -# cannot read either of them back: `helpers.jl` took the TIA range to be 1.0 mA -# and *set* the calibration factor to 224.2 W/A for one photodiode on one rig. -# On another diode, another TIA gain setting or another fibre, both are wrong. -# That is why this is a formula in a comment and not a getter, and why -# `tcube_get_power` was deleted rather than repaired. No closed-loop method is -# implemented here; the bindings themselves remain in `functions_Tlaser.jl`. +# where `raw` is the word `LD_GetPhotoCurrentReading` returns. Verification, in +# closed loop with those two factors: power commanded through the formula +# versus power measured before the fiber, in mW; single readings, no repeats +# or statistics: +# +# commanded measured +# 0.0 < 0.001 +# 10.0 9.31 +# 20.0 19.11 +# 30.0 29.11 +# 40.0 39.06 +# 50.0 48.97 +# 60.0 59.22 +# 70.0 69.65 +# 80.0 79.50 +# +# Measured power sits 0.35–1.03 mW below the command at every point: within +# 1.5 % from 60 mW up, 2–4.5 % at 20–50 mW, and 7 % at 10 mW. +# +# These numbers belong to this photodiode, this TIA range and this measurement +# plane: moving the DIP switch or replacing the diode invalidates them, and the +# driver cannot read the W/A factor's provenance back. Closed loop regulates the +# monitor photocurrent, so without a TEC-stabilised mount the delivered power +# can still drift with diode temperature. No closed-loop method is implemented +# here yet; the bindings remain in `functions_Tlaser.jl`. include("constants_Tlaser.jl") include("functions_Tlaser.jl") From 3c540516e797e3a4b5d341fd6349589d1c8059da Mon Sep 17 00:00:00 2001 From: Ali Kazemi Date: Mon, 28 Sep 2026 21:20:15 -0600 Subject: [PATCH 02/37] Update DAQmx source to track the main branch --- Project.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Project.toml b/Project.toml index 87019fa..4ccf325 100644 --- a/Project.toml +++ b/Project.toml @@ -23,7 +23,7 @@ Statistics = "10745b16-79ce-11e8-11f9-7d13ad32a3b2" TOML = "fa267f1f-6049-4f14-aa54-33bafae1ed76" [sources] -DAQmx = {url = "https://github.com/LidkeLab/DAQmx.jl.git"} +DAQmx = {rev = "main", url = "https://github.com/LidkeLab/DAQmx.jl.git"} [compat] CEnum = "0.5.0" From 258c42e679bbe652b62673ca0e2fce4770892775 Mon Sep 17 00:00:00 2001 From: Ali Kazemi Date: Mon, 28 Sep 2026 23:26:39 -0600 Subject: [PATCH 03/37] TCube laser: closed-loop (power) mode, DiodeLaser interface, hardware-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 --- CHANGELOG.md | 154 +++ Project.toml | 2 +- README.md | 7 +- skills/mc-api-map/SKILL.md | 78 +- skills/mc-api-map/references/gui-fields.md | 43 + skills/mc-extend/SKILL.md | 57 +- .../mc-extend/references/driver-scaffold.md | 24 +- .../references/interface-scaffold.md | 2 +- skills/mc-extend/references/rig-causes.md | 13 +- skills/mc-system-design/SKILL.md | 62 +- .../references/driver-caveats.md | 40 +- skills/mc-testing/SKILL.md | 55 +- .../HardwareImplementations.jl | 3 + .../SimulatedDiodeLaser.jl | 357 +++++++ .../tcube_laser/CALIBRATION.md | 312 ++++++ .../tcube_laser/TCubeLaserControl.jl | 12 +- .../tcube_laser/constants_Tlaser.jl | 123 ++- .../tcube_laser/functions_Tlaser.jl | 34 +- .../tcube_laser/interface_methods.jl | 868 ++++++++++++--- .../tcube_laser/types.jl | 215 ++-- .../LightSourceInterface.jl | 11 +- .../lightsource_interface/diode_laser.jl | 159 +++ .../lightsource_interface/diode_laser_gui.jl | 209 ++++ .../interface_functions.jl | 159 ++- .../lightsource_interface/interface_types.jl | 117 ++- src/skills.jl | 33 +- test/contract.jl | 97 +- test/gui.jl | 29 + test/runtests.jl | 992 +++++++++++++----- test/tcube_fake_sdk.jl | 169 ++- 30 files changed, 3772 insertions(+), 664 deletions(-) create mode 100644 src/hardware_implementations/simulated_diode_laser/SimulatedDiodeLaser.jl create mode 100644 src/hardware_implementations/tcube_laser/CALIBRATION.md create mode 100644 src/hardware_interfaces/lightsource_interface/diode_laser.jl create mode 100644 src/hardware_interfaces/lightsource_interface/diode_laser_gui.jl diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d4a789..570ab89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,160 @@ and `y` is the non-breaking one (every merge to `main` is tagged). ## [Unreleased] +## [0.3.0] - 2026-09-28 + +Laser diodes in two regulation modes, and closed loop (photodiode feedback, +"constant power") for the TCube. Implements the design plan +`dev/output/plan-laser-modes.md` (revision 3, on branch `fix/revert-cppbool`). + +**Hardware verification: PARTIAL**, on the 642 nm rig's TLD001 (64849775), +2026-09-28, with a power meter before the fibre; the tables are in +`src/hardware_implementations/tcube_laser/CALIBRATION.md`. + +- **Open loop: verified.** 70 / 90 / 110 mA gave 70.0 / 90.0 / 110.0 mA on the + front display and 1.69 / 20.67 / 39.42 mW. +- **Closed loop: verified from 1 to 5 mW only.** Measured = requested - 0.46 mW, + slope 0.99, so the 224.2 W/A calibration holds. **Above about 5 mW it failed on + this rig**: the photodiode reading clips at about 3213 counts (~21 mW) and + reads `0x8000` above it, and the loop held ~21 mW for a 10 mW request. That is + the controller's photodiode channel (range DIP switch or TIA gain), not this + driver or the calibration; `CALIBRATION.md` says what to check. +- **Found on the rig and fixed before release**, none of which the fake SDK + could have shown: the TLD001 **ignores setpoints sent while its output is + off** (so the driver sends them right after enabling, and zeroes the setpoint + before disabling); the setpoint read-back only refreshes while polling runs + (so polling starts first); the photocurrent reading is signed, with `0x8000` + meaning over range; a plain potentiometer write is ignored (adjust mode and a + pause are needed); and the header's potentiometer-to-mA scale is wrong for this + unit (so the clamp is the controller's own reported limit). + +Per the plan's contingency (§8.1), `mode` is a **required keyword with no +default**: closed loop is not yet verified across the rig's working range. +Not verified: the meaning of `LD_EnableMaxCurrentAdjust`'s second flag (always +passed `false`). + +**Breaking.** Every downstream rig using a `TCubeLaser` must change its +construction line and its `setpower` calls; nothing it can write silently +changes meaning, because the old calls now throw. + +### Migration + +```julia +# before +laser = TCubeLaser("64849775"; max_current = 160.0) +setpower(laser, 80.0) # this was mA, despite the name + +# after, open loop: the same behaviour, with the unit in the name +laser = TCubeLaser("64849775"; mode = ConstantCurrent(), max_current = 160.0) +setcurrent!(laser, 80.0) # mA + +# after, closed loop (calibration: src/hardware_implementations/tcube_laser/CALIBRATION.md) +laser = TCubeLaser("64849775"; mode = ConstantPhotocurrent(), + wa_calibration = 224.2, tia_range = 1e-3, tec_stabilised = missing, + threshold_current = 65.0, max_current = 160.0, + properties = LightSourceProperties("mW", 0.0, false, 1.0, 70.0)) +setoutputpower!(laser, 20.0) # mW at the laser output + +# either mode, when the unit does not matter: a fraction of the declared range +setlevel!(laser, 0.4) +``` + +### Added +- **`DiodeLaser <: LightSource`**, for lasers on a controller that regulates + something, and the mode types **`ConstantCurrent`** (the loop holds drive + current) and **`ConstantPhotocurrent`** (the loop holds the monitor photodiode + current). There is deliberately no `ConstantPower`: the loop does not hold + optical power, and without a temperature-stabilised mount the delivered power + drifts while the photocurrent is held. `regulation_mode(laser)` and + `supported_modes(T)` report the mode. The voltage-modulated lights + (`CrystaLaser`, `VortranLaser`, `DaqTrLight`) and `SimLight` are unchanged. +- **`TCubeLaser{M}`**: the driver is parametric in its mode, fixed at + construction. **Closed loop** (`ConstantPhotocurrent`) takes the photodiode + calibration as required keywords -- `wa_calibration` (W/A at the laser + output), `tia_range` (the rear-panel DIP switch, A), `tec_stabilised` (`true`, + `false` or `missing`) -- held in a new **`PhotodiodeLoop`**, and + `properties.min_power`/`max_power` become the enforced mW bounds. A range the + amplifier cannot reach, a TIA range the TLD001 does not have, or a ceiling + below the potentiometer's lowest clamp is refused at construction. +- **Closed-loop `initialize`**, a protected sequence: require the key switch and + interlock; require the controller's TIA range to match `tia_range` (a moved + DIP switch is a silent factor-of-ten error otherwise); disable the output (the + setpoint cannot be zeroed with the output off, so `light_on` sends the real one + right after enabling); leave the max-current potentiometer at the highest + position whose controller-reported limit is `<= max_current` -- the only real + protection in closed loop, since a blocked photodiode drives the current + straight to it; enter closed loop and verify the status bit; write the W/A + factor to the controller's display and read it back. It never enables the + output. `light_on` and `setoutputpower!` refuse until the clamp is verified. +- **`setcurrent!(laser, mA)`** (open loop) and **`setoutputpower!(laser, mW)`** + (closed loop, mW at the laser output -- not at the sample; the driver holds no + field describing the optics downstream). Both read their setpoint back and + record the request only after the controller confirmed it. + `pd.photocurrent_requested` holds the decoded setpoint the loop was actually + given. `setoutputpower!` refuses when the amplifier is over range, or under + range with the output on, or when the controller has left closed loop. +- **`setlevel!(laser, frac)`**: unit-free, `frac` in 0..1 of the declared range, + linear in the regulated quantity; the same call works in both modes, so code + written against it survives a mode switch. `frac = 0` is the bottom of the + range, not off. +- **Readbacks**: `measured_current` (mA), `measured_photocurrent` (A), + `indicated_output_power` (mW, closed loop; a conversion, not a measurement) + and `loop_status`, one snapshot of the status word, both readings and the + decoded flags, including `saturated` (at the current clamp) and + `below_threshold` (`missing` when the threshold is unknown, never a silent + pass). `initialize` starts Kinesis polling at 50 ms so these are cache reads. + New field `threshold_current` (mA, declared by the rig). +- **Panels**: `gui(::DiodeLaser)` opens a current panel (slider in mA over the + enforced range) or a power panel (slider in mW at the laser output, the range + and the calibration basis shown). Opening issues no command and no read; the + readout fills on Read or a Poll toggle that starts off. The textbox accepts + only in-range values and turns red otherwise; an entry issues one command, + not two; the display changes only after a command succeeded, and a refusal + is shown in the panel instead of being thrown inside the event handler. +- **`SimDiodeLaser{M}`**, a simulated twin on the same abstract type, with a + diode, photodiode and loop model, faults (blocked photodiode, responsivity + drift, TIA flags, key/interlock) and a log of every command and read. +- **`CALIBRATION.md`** in the TCube driver folder: how to choose the TIA range, + measure the threshold and the W/A factor, build the laser in closed loop and + verify it, and the 642 nm rig's current calibration (224.2 W/A on the 1 mA + range, measured before the fibre, with its closed-loop verification table). + +### Changed +- `setpower` is not defined for any `DiodeLaser`; it throws, naming the + replacements. There is no forwarder, on purpose: with a power mode available + a forwarded `setpower(laser, 80.0)` could have turned 80 mA into 80 mW. +- `TCubeLaser`'s `properties.power_unit` defaults to `"mA"` in open loop, and + `properties.power` is no longer written. +- `export_state(::TCubeLaser)` attributes: `regulation_mode`, `setpoint_unit`, + `min_current_mA`, `max_current_mA`, `threshold_current_mA` (were + `min_current`, `max_current`); closed loop adds `power_reference`, + `min_output_power_mW`, `max_output_power_mW`, `wa_calibration_W_per_A`, + `tia_range_A`, `tec_stabilised`, `max_current_clamp_mA`, + `output_power_requested_mW`, `photocurrent_requested_A`. `power` and + `power_unit` are gone. +- `LD_GetLaserDiodeCurrentReading` is bound as signed (`Cshort`): a negative + reading used to decode to about 440 mA. Kinesis booleans are split by role: + arguments as zero-extended `Cuint` (`KBOOL_ARG`), returns as one byte + (`KBOOL_RET`), from `fix/revert-cppbool`. +- The contract test and the API-map generator walk the type hierarchy to its + leaves: `subtypes` is one level deep and would have dropped `TCubeLaser` the + moment it moved under `DiodeLaser`. **Downstream code calling + `subtypes(MicroscopeControl.LightSource)` now sees `DiodeLaser` in place of + `TCubeLaser` and `SimDiodeLaser`.** + +### Removed +- The deprecated `legacy_power` figure (`properties.power` on `TCubeLaser`). +- The deprecated 2-argument `export_state(::TCubeLaser, x)`. +- The 2-argument interface stub `light_on(::LightSource, ipower::Float64)`, + which no driver implemented; the stub is now `light_on(::LightSource)`. + +### Deferred +- Renaming `properties.is_on` to `is_on_requested` across all lights, queued in + the plan for this release: it touches every light driver, which the same plan + otherwise leaves untouched, and is independent of the laser modes. +- Defaulting `mode` to `ConstantPhotocurrent()`: after closed loop has run on the + rig. + ### Fixed (documentation) - **Depending on this package needs more than pinning the tag, and the docs did not say so.** MicroscopeControl depends on the unregistered `DAQmx.jl` and diff --git a/Project.toml b/Project.toml index 4ccf325..2f82d2c 100644 --- a/Project.toml +++ b/Project.toml @@ -1,6 +1,6 @@ name = "MicroscopeControl" uuid = "aa70d9ae-4a1e-49fd-870a-8ccfd99f4c3e" -version = "0.2.3" +version = "0.3.0" authors = ["klidke@unm.edu"] [deps] diff --git a/README.md b/README.md index a8376d5..ef68c71 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,8 @@ MicroscopeControl.jl is organized to ensure scalability and easy integration of - Simulated stage for testing ### Light Sources -- Thorlabs TCube laser diode controller +- Thorlabs TCube laser diode controller (TLD001), open loop (constant current) or closed loop on the monitor photodiode (see `src/hardware_implementations/tcube_laser/CALIBRATION.md`) +- Simulated laser diode (`SimDiodeLaser`) for testing either mode - CrystaLaser 561nm - Vortran 488nm laser - Simulated light source for testing @@ -73,7 +74,7 @@ Since this package is under active development and not yet registered, install i ```julia using Pkg -Pkg.add(url="https://github.com/LidkeLab/MicroscopeControl.jl.git", rev="v0.2.3") +Pkg.add(url="https://github.com/LidkeLab/MicroscopeControl.jl.git", rev="v0.3.0") ``` **You must also declare this package's unregistered dependency in your own @@ -88,7 +89,7 @@ writes both entries for you: ```julia using Pkg Pkg.add(url="https://github.com/LidkeLab/DAQmx.jl.git") -Pkg.add(url="https://github.com/LidkeLab/MicroscopeControl.jl.git", rev="v0.2.3") +Pkg.add(url="https://github.com/LidkeLab/MicroscopeControl.jl.git", rev="v0.3.0") ``` If you write the TOML by hand, `[sources]` alone is **not** enough — Julia diff --git a/skills/mc-api-map/SKILL.md b/skills/mc-api-map/SKILL.md index ea8d9c2..9670725 100644 --- a/skills/mc-api-map/SKILL.md +++ b/skills/mc-api-map/SKILL.md @@ -96,14 +96,29 @@ not necessarily the one you are about to call. The case that exposed this was sth)`, the map listed that and nothing else, and the 1-arg `export_state(laser)` every lifecycle loop calls fell through to the throwing `AbstractInstrument` stub. **[fixed in v0.2.3]** — `export_state(::TCubeLaser)` exists and -`which(export_state, Tuple{TCubeLaser})` lands on it. The 2-arg method is still -there as a deprecated forwarder that warns and delegates (removal scheduled for -a future 0.3.0), so the map may still list *that* signature: adding the 1-arg -method was the fix, not deleting the other one. The blind spot -is a property of the generator rather than of that driver, and an installed map -generated against an older pinned tag still shows the old signature. `light_on` is a -live example (below). When the listed signature is not the one you are calling, check -the exact tuple with `hasmethod`/`which`. +`which(export_state, Tuple{TCubeLaser})` lands on it. v0.2.3 kept the 2-arg method +as a deprecated forwarder; **[guarantee]** 0.3.0 removed it, so +`hasmethod(export_state, Tuple{TCubeLaser,Any})` is false and an installed map at +0.3.0 lists only the 1-arg signature. The blind spot is a property of the generator +rather than of that driver, and an installed map generated against an older pinned +tag still shows the old signature. A current example: the generator's inherited +check asks about the 1-arg tuple only, so a 2-arg generic whose only method is on an +abstract intermediate (`setpower(::DiodeLaser, ::Float64)`, the throwing refusal, and +`setlevel!(::DiodeLaser, ::Float64)`, the shared implementation) does not appear in a +`TCubeLaser` or `SimDiodeLaser` section at all. When the listed signature is not the +one you are calling, check the exact tuple with `hasmethod`/`which`. + +**[guarantee]** Parametric devices are listed once, under the bare name +(`### TCubeLaser`), and a method written against one instantiation is printed with +it: `setcurrent!(TCubeLaser{ConstantCurrent}, Float64)`, +`setoutputpower!(TCubeLaser{ConstantPhotocurrent}, Float64)`. A method written +against the bare type (`light_on(TCubeLaser)`) serves both modes. The generator +walks the type tree to its non-abstract leaves, so drivers beneath the abstract +intermediate `DiodeLaser` are listed under `## LightSource`. +**[limitation]** `InteractiveUtils.subtypes` is one level deep: downstream code that +enumerates lights with `subtypes(LightSource)` gets `DiodeLaser` in place of +`TCubeLaser` and `SimDiodeLaser`, silently. Walk to the leaves +(`isabstracttype(S) ? recurse : keep`), as the map generator and `test/contract.jl` do. **[limitation]** `MLSLM` and `Triggerscope4` sit outside the `AbstractInstrument` hierarchy (`SLM` and `TRIG` are `abstract type ... end` with no supertype). A lifecycle name missing @@ -129,20 +144,27 @@ where dispatch lands and whether that is the concrete type: ```julia using MicroscopeControl -# true for every LightSource, because the interface stub exists +# false from 0.3.0: the 2-arg light_on stub was removed (up to v0.2.x it was true +# for every LightSource and landed on a throwing stub) hasmethod(light_on, Tuple{TCubeLaser,Float64}) -# -> true +# -> false -# where the call actually goes -which(light_on, Tuple{TCubeLaser,Float64}).sig -# -> Tuple{typeof(light_on), LightSource, Float64} (the throwing stub: no -# driver implements 2-arg light_on) +# resolves, but lands on the DiodeLaser-level refusal, which throws and names +# setcurrent! / setoutputpower! / setlevel! +which(setpower, Tuple{TCubeLaser{ConstantCurrent},Float64}).sig +# -> Tuple{typeof(setpower), DiodeLaser, Float64} + +# mode-specific: exists only on the instantiation for that mode +hasmethod(setcurrent!, Tuple{TCubeLaser{ConstantPhotocurrent},Float64}) +# -> true, but it is the throwing DiodeLaser stub; check which(...).sig +which(setcurrent!, Tuple{TCubeLaser{ConstantCurrent},Float64}).sig +# -> Tuple{typeof(setcurrent!), TCubeLaser{ConstantCurrent}, Float64} which(export_state, Tuple{TCubeLaser}).sig # -> Tuple{typeof(export_state), TCubeLaser} (real driver code from v0.2.3; # up to v0.2.2, the throwing stub. -# Tuple{TCubeLaser,Any} still resolves, -# to the deprecated forwarder) +# Tuple{TCubeLaser,Any} no longer +# resolves: forwarder removed in 0.3.0) which(getdata, Tuple{SimCamera}).sig # -> Tuple{typeof(getdata), SimCamera} (real driver code) @@ -156,13 +178,22 @@ in the upstream repo uses: ```julia has_specific(f, T, args...) = - hasmethod(f, Tuple{T, args...}) && which(f, Tuple{T, args...}).sig.parameters[2] === T + hasmethod(f, Tuple{T, args...}) && + Base.unwrap_unionall(which(f, Tuple{T, args...}).sig).parameters[2] === T has_specific(initialize, SimCamera) # true has_specific(initialize, ThorCamCSCCamera) # false has_specific(setpower, SimLight, Float64) # true +has_specific(setpower, TCubeLaser, Float64) # false: lands on the DiodeLaser refusal +has_specific(light_on, TCubeLaser) # true: mode-shared, written on the bare type +has_specific(setcurrent!, TCubeLaser{ConstantCurrent}, Float64) # true: mode-specific ``` +The `unwrap_unionall` matters from 0.3.0: a `where`-method has a `UnionAll` +signature whose `.parameters` throws. Pass the bare `TCubeLaser` for a mode-shared +method and the instantiation for a mode-specific one; `has_specific(setcurrent!, +TCubeLaser, Float64)` is false because no method is written on the bare type. + For `gui` the right question is "does dispatch avoid the `AbstractInstrument` stub", since the interface-level `gui` is the intended shared implementation: `which(gui, Tuple{T}).sig.parameters[2] !== AbstractInstrument`. @@ -178,10 +209,15 @@ per device. ## Arity traps the map makes visible -- **[limitation]** `light_on` is declared `light_on(::LightSource, ipower::Float64)` at the interface - and implemented as `light_on(::T)` by all five drivers. The 2-arg call throws for - every light source. The map lists `light_on(TCubeLaser)` etc. as device-specific; - a 2-arg form never appears. +- **[guarantee]** `light_on` takes the light only. Up to v0.2.x the interface + declared `light_on(::LightSource, ipower::Float64)`, which no driver implemented + and which threw for every light; 0.3.0 replaced it with a 1-arg stub, so the 2-arg + call is now a plain `MethodError`. Set the level first, then `light_on`. +- **[guarantee]** `setpower` is not defined for any `DiodeLaser` (`TCubeLaser`, + `SimDiodeLaser`): it resolves to a refusal that throws and names `setcurrent!` + (mA, `ConstantCurrent` only), `setoutputpower!` (mW at the laser output, + `ConstantPhotocurrent` only) and `setlevel!` (unit-free `0..1`). It is unchanged on + the other lights. None of this shows in the map (the 2-arg blind spot above). - `move` takes `Float64` positions. `move(stage, 1, 2, 3)` with integers is a `MethodError`, not a stub error. - The SmarAct `MCS2Stage` has `move(MCS2Stage, Float64, Float64[, Float64])` in diff --git a/skills/mc-api-map/references/gui-fields.md b/skills/mc-api-map/references/gui-fields.md index f89eb5d..65a3e43 100644 --- a/skills/mc-api-map/references/gui-fields.md +++ b/skills/mc-api-map/references/gui-fields.md @@ -75,4 +75,47 @@ panel is observably read-only. The fix is scoped to the shared light panel; it is not a guarantee about the others, and `gui(::DAQ)` still calls `showdevices`/`showchannels` at construction. +From 0.3.0 this panel serves only the lights that are not a `DiodeLaser` +(`CrystaLaser`, `VortranLaser`, `DaqTrLight`, `SimLight`); a `DiodeLaser` has its +own, below. + +## `gui(::DiodeLaser)` (0.3.0) + +Dispatches on `regulation_mode(laser)` to `current_panel` (`ConstantCurrent`: +slider in mA over `min_current .. effective_max_current(laser)`, calling +`setcurrent!`) or `power_panel` (`ConstantPhotocurrent`: slider in mW **at the +laser output** over `properties.min_power .. max_power`, calling +`setoutputpower!`, with a basis line naming `wa_calibration`, the TIA range and +the TEC state; the range is printed as `[lo mW - hi mW]`). Enumerated from +`lightsource_interface/diode_laser_gui.jl`; `TCubeLaser` and `SimDiodeLaser` +carry every field. + +| Field | Read by | Notes | +|---|---|---| +| `unique_id::String` | header label and window title | both panels | +| `properties.is_on` | initial state of the on/off toggle | both panels; the toggle, not the slider's bottom, is "off" | +| `properties.min_power`, `max_power` | slider range and textbox bounds | `power_panel` only; mW at the laser output | +| `min_current` | slider floor | `current_panel` only | +| `effective_max_current(laser)` (a method) | slider ceiling | `current_panel` only; falls back to `max_current`, `TCubeLaser` takes the smallest of `max_current`, `controller_max_current`, `max_setcurrent` | +| `threshold_current` | header text (`"unknown"` when `NaN`) | `current_panel`; also `loop_status`'s `below_threshold` | +| `drive_current` | slider start and "commanded" line | `current_panel` only | +| `pd.wa_calibration`, `pd.tia_range`, `pd.tec_stabilised` | basis line; `wa_calibration` also for the readout's indicated power | `power_panel` only | +| `pd.output_power_requested`, `pd.photocurrent_requested` | slider start and "commanded" line | `power_panel` only | + +`properties.power` is **not** read by either panel. + +Methods called, each only on a user action: `setcurrent!`/`setoutputpower!` (slider +or textbox), `light_on`/`light_off` (toggle), `loop_status` (the Read button, or +the Poll toggle at 2 Hz, which stops when turned off or the window closes). + +`[guarantee]` Opening either panel issues **no command and no read**: the readout +shows "not read yet" until Read is pressed or Poll (off at construction) is +turned on. On a `SimDiodeLaser` this is checkable: `laser.log` gains nothing when +the panel opens. `[guarantee]` The textbox accepts only a number within the +slider's range, the same bounds the driver enforces; anything else is not +applied and the border turns red. An entry moves the slider, so it issues exactly +one command. The "commanded" line is refreshed from the device's fields only after +the command returns, and a refusal is shown in the panel as `refused: `, +not thrown out of the callback. + `gui(::TRIG)` exists for `Triggerscope4`; `MLSLM` has no `gui` at all. diff --git a/skills/mc-extend/SKILL.md b/skills/mc-extend/SKILL.md index 98a22d1..e4b251b 100644 --- a/skills/mc-extend/SKILL.md +++ b/skills/mc-extend/SKILL.md @@ -33,7 +33,10 @@ your own type, extend freely (steps 3 and 4); an MC type, report and work around from outside (step 2). **[limitation]** The collision is worse than "diverge silently", and this has -happened. A rig repo defined `export_state(::TCubeLaser)` itself, because +happened (historical: the case below is v0.2.1 to v0.2.3; at 0.3.0 the 1-arg +method is upstream and the 2-arg form it replaced is gone, so a rig still carrying +a shim or a 2-arg call from that period should delete it; the pattern, and the +guard, apply to any future gap). A rig repo defined `export_state(::TCubeLaser)` itself, because v0.2.1 shipped only a 2-argument form and the 1-argument call fell through to the throwing stub. When v0.2.3 added that method upstream, Julia failed **precompilation** on the overwrite; in the environment that reported it the @@ -87,7 +90,7 @@ defects; a report needs the rig, in a fresh session with nothing else attached. ## 2. Report against the pinned tag, and work around from outside -The rig pins a tag (`Pkg.add(url=..., rev="v0.2.0")`) and the fix ships as a new +The rig pins a tag (`Pkg.add(url=..., rev="v0.3.0")`) and the fix ships as a new tag, so the report must say which tag, from the environment: `pkgversion(MicroscopeControl)`, `VERSION`, `Sys.KERNEL`. Template: @@ -147,9 +150,10 @@ closed link** (no silent no-op); a **pure constructor**; `initialize` that opens the link only if it is not already open; `shutdown` that closes it only if `owns_port`; interface operations that range-check and cache the *requested* value; a 1-arg `export_state` whose attribute names say which kind of state -they hold (`power_requested`). **[limitation]** the interface also declares -`light_on(::LightSource, ipower::Float64)`, which no driver implements and -upstream tracks as `@test_broken`; implement the 1-arg form. +they hold (`power_requested`). **[guarantee]** the interface declares +`light_on(::LightSource)` only; implement that. (Up to v0.2.x it declared a 2-arg +`light_on(::LightSource, ipower::Float64)` that no driver implemented; 0.3.0 +removed it, so a 2-arg call is a `MethodError`.) ### Binding identity: import to extend, never rely on `using` @@ -219,6 +223,33 @@ carry: The slider and toggle are now wired with `on`, which fires only on a later change, and initialised from the device's current `properties.power`/ `properties.is_on`. +- **DiodeLaser** (0.3.0; a laser on a controller that regulates drive current or + monitor photocurrent; a light that is only voltage-modulated stays a plain + `LightSource`): **[guarantee]** the shared `current_panel`/`power_panel` and + `setlevel!` read `unique_id`, `properties` (`is_on`; `min_power`/`max_power` as + the enforced mW bounds at the laser output in `ConstantPhotocurrent` mode), + `min_current`, `max_current`, `threshold_current` (mA, `NaN` unknown), + `drive_current` (mA requested, `NaN` before the first and always in + `ConstantPhotocurrent`) and `pd::Union{Nothing,PhotodiodeLoop}` (a + `PhotodiodeLoop` exactly in `ConstantPhotocurrent`). Beyond fields, the driver + is parametric, `MyLaser{M<:RegulationMode} <: DiodeLaser`, and defines + `supported_modes(::Type{<:MyLaser})` (a tuple of mode types; the contract test + expands the device through it) and `regulation_mode(::Type{MyLaser{M}}) where + {M} = M()`; its inner constructor rejects a mode not in `supported_modes` and + calls `LightSourceInterface.check_diode_config`. Do not define `setpower`: the + `DiodeLaser` refusal must answer. `TCubeLaser` and `SimDiodeLaser` are the two + templates. + +**[guarantee]** Mode-shared methods (`initialize`, `shutdown`, `export_state`, +`light_on`, `light_off`, `measured_current`, `measured_photocurrent`, +`loop_status`) are written against the bare type (`TCubeLaser`); mode-specific +ones against the instantiation (`setcurrent!(::TCubeLaser{ConstantCurrent}, +::Float64)`, `setoutputpower!`/`indicated_output_power` on +`TCubeLaser{ConstantPhotocurrent}`). `test/contract.jl` checks exactly those +signatures, so **[policy]** never write a `where M` method on a generic the +contract test checks: its signature matches neither the bare type nor an +instantiation, and the gate fails. Dispatch on the mode inside a mode-shared method +with a helper (`enter_mode!(regulation_mode(l), l)`), as `TCubeLaser` does. **[policy]** carry the fields rather than writing your own panel: the fields are what make the Sim-for-hardware swap in `mc-testing` work. @@ -240,7 +271,7 @@ shows the failure mode as a **[limitation]** (traced unless marked). | Calibration | table in the device (`AttenuatorProperties.cal_*`); values from the rig | `LCC1620.settransmission` `@error`s and returns when uncalibrated | | Cached versus measured | name it (`power_requested`); update `real_*` only from a readback | Sim stages copy `targ_*` into `real_*`; `getposition(::SimStage3d)` returns a scalar, not the documented tuple (executed) | | Image axis convention | `(H, W)` per frame, `(H, W, N)` per stack; at a row-major SDK boundary `permutedims(reshape(buf, (W, H)), (2, 1))` | DCAM4 does this in `dcambuf.jl`; `SimCamera` returns `(roi.height, roi.width[, N])` (executed); `save_h5` stamps `dimension_order` assuming it | -| `export_state` | 1-arg, `(Dict{String,Any}, data_or_nothing, Dict{String,Any})`; HDF5-safe values (`collect` tuples, `string` enums, `copy` vectors); children are named tuples | `Triggerscope4` has none (`MethodError`). `TCubeLaser` had only a 2-arg method until **v0.2.3**, so the contract call threw; the 1-arg method was added there and the 2-arg one kept as a deprecated forwarder | +| `export_state` | 1-arg, `(Dict{String,Any}, data_or_nothing, Dict{String,Any})`; HDF5-safe values (`collect` tuples, `string` enums, `copy` vectors); children are named tuples | `Triggerscope4` has none (`MethodError`). `TCubeLaser` had only a 2-arg method until **v0.2.3**, so the contract call threw; the 1-arg method was added there, the 2-arg one kept as a deprecated forwarder, and **0.3.0 removed the forwarder** (contract test asserts `!hasmethod(export_state, Tuple{TCubeLaser,Any})`) | | Unsupported operations | do not define the method; let the throwing stub answer | `stopmotion(::MCLStage)` is a concrete method whose body is `@error "STOP MOTION NOT IMPLEMENTED"` (executed), so `hasmethod` and the API map count it as implemented | ## 4. Define a new interface (rare, consequential) @@ -272,7 +303,9 @@ completion, required versus optional) are in `references/interface-scaffold.md`. **[policy]** the simulated implementation is mandatory and ships with the interface: the stubs all throw (write them so), so nothing exercises the contract without it; -upstream's contract test iterates `subtypes`; downstream tests put it in the +upstream's contract test iterates the interface's non-abstract leaves (from 0.3.0 +`device_types` walks through abstract intermediates such as `DiodeLaser`; +**[limitation]** a plain `subtypes(iface)` is one level deep and misses them); downstream tests put it in the interface-typed field; a shared panel is developed against it. Make it honest about what it does not model. **[limitation]** upstream CLAUDE.md calls interface GUIs optional while `test/contract.jl` requires every non-exempt device to @@ -324,8 +357,14 @@ An interface missing from either tuple loads fine and is invisible to both gates What upstream's `test/contract.jl` runs for **every** subtype: items 1 to 3 (method specificity for the lifecycle and the interface operations, function identity for every generic a submodule defines, and `gui` dispatch off the -`AbstractInstrument` stub), plus, per `LightSource` subtype, a `@test_broken` on -the 2-arg `light_on`. Item 4 is **not** run per subtype: the only stub-throws +`AbstractInstrument` stub), plus a "LightSource contract" testset: every light +has `light_on`/`light_off` on its own type and **no** 2-arg `light_on` (the +`@test_broken` of v0.2.x is gone with the stub); a `DiodeLaser` must have a +non-empty `supported_modes`, per mode a `regulation_mode` of that mode, the +mode-specific setter on the instantiation and not the other one, `setlevel!` and +`setpower` landing on the `DiodeLaser` methods, and `measured_current`, +`measured_photocurrent`, `loop_status` on its own type; any other light must have +its own `setpower`. Item 4 is **not** run per subtype: the only stub-throws checks are three assertions on two test fixture types (`_ContractDummyStage`, `_ContractDummyInstrument`), so write your own `@test_throws` for the operations you leave unimplemented. Items 5 and 6 are diff --git a/skills/mc-extend/references/driver-scaffold.md b/skills/mc-extend/references/driver-scaffold.md index 6e9275e..6958afc 100644 --- a/skills/mc-extend/references/driver-scaffold.md +++ b/skills/mc-extend/references/driver-scaffold.md @@ -93,14 +93,17 @@ What the run showed: | `gui(led)` after `shutdown(led)` (recorded against MC 0.2.0, before the panel's `lift`->`on` fix) | **threw from inside `gui`**: the shared light panel called `setpower(light, 0.5)` when it opened (see the "Fixed in 0.2.1" note in the GUI section of the parent `SKILL.md`). On 0.2.1+, constructing the panel no longer calls `setpower`/`light_on`/`light_off` at all, so `gui(led)` here no longer throws for this reason. | The interface stub signature is the contract you are satisfying **[guarantee]**: -`setpower(::LightSource, ::Float64)` and `light_off(::LightSource)`. -**[limitation]** for `light_on` the interface declares **only** the 2-arg -`light_on(::LightSource, ipower::Float64)`, which no driver implements and -upstream tracks as `@test_broken`; every driver and both shared panels use the -1-arg `light_on(light)`, for which there is no stub at all, so a bare -`LightSource` subtype gets a `MethodError` for it, not the "not implemented" -error. Implement the 1-arg form; the contract testset's `has_specific(MC.light_on, T)` -checks it. +`setpower(::LightSource, ::Float64)`, `light_on(::LightSource)` and +`light_off(::LightSource)`. From 0.3.0 the `light_on` stub is 1-arg, matching every +driver and the shared panels. (Up to v0.2.x the interface declared only a 2-arg +`light_on(::LightSource, ipower::Float64)` that no driver implemented, tracked +upstream as `@test_broken`, and a bare `LightSource` subtype got a `MethodError` +for the 1-arg call.) Implement the 1-arg form; the contract testset's +`has_specific(MC.light_on, T)` checks it, and upstream now also asserts that no +2-arg `light_on` exists. This scaffold is a plain `LightSource`; a laser on a +controller that regulates current or photocurrent is a `DiodeLaser`, which has no +`setpower` and a further contract (parent `SKILL.md`, "The implicit structural +contract"). ## Tests a new driver must satisfy @@ -128,8 +131,9 @@ has_specific(f, T, args...) = hasmethod(f, Tuple{T,args...}) && which(f, Tuple{T end # 3. inherited gui is the interface panel, not the AbstractInstrument stub @test which(MC.gui, Tuple{T}).sig.parameters[2] === LightSource - # 4. unsupported operations fail loudly (2-arg light_on is not implemented, on purpose) - @test_throws ErrorException MC.light_on(SerialLED(), 1.0) + # 4. unsupported operations fail loudly (from 0.3.0 no 2-arg light_on exists, so + # this is a MethodError; on v0.2.x it hit the throwing stub, an ErrorException) + @test_throws MethodError MC.light_on(SerialLED(), 1.0) # 5. lifecycle and behaviour through the fake transport led = SerialLED() @test !led.port.isopen diff --git a/skills/mc-extend/references/interface-scaffold.md b/skills/mc-extend/references/interface-scaffold.md index 32e2bea..888bcf5 100644 --- a/skills/mc-extend/references/interface-scaffold.md +++ b/skills/mc-extend/references/interface-scaffold.md @@ -115,7 +115,7 @@ What the run showed: | `initialize(SimShutter()); open_shutter!(sh); is_open(sh)` | `true`; `export_state(sh)[1]["is_open"] == true` | | `has_specific` for `initialize, shutdown, export_state, open_shutter!, close_shutter!, is_open` on `SimShutter` | all `true` | | `SimulatedShutter.open_shutter! === ShutterInterface.open_shutter!`, `SimulatedShutter.export_state === MicroscopeControl.export_state` | `true`, `true`: no shadow bindings | -| `subtypes(Shutter)` | `[NoMethodsShutter, SimShutter]`: this is how the contract test and the API map will find your devices | +| `subtypes(Shutter)` | `[NoMethodsShutter, SimShutter]`: the contract test and the API map find your devices from this, walking on through any abstract intermediate to the non-abstract leaves (from 0.3.0; `subtypes` alone is one level deep) | ## Writing the contract, not just the stubs diff --git a/skills/mc-extend/references/rig-causes.md b/skills/mc-extend/references/rig-causes.md index aeb0552..f6d3356 100644 --- a/skills/mc-extend/references/rig-causes.md +++ b/skills/mc-extend/references/rig-causes.md @@ -14,9 +14,16 @@ and each looks like a broken `ccall`. | Serial device times out, returns garbage, or "port busy" | Wrong COM assignment (Windows renumbers COM ports when USB topology changes), or the port is open elsewhere. Applies only to the **Triggerscope** (`Triggerscope4`, default `portname="COM3"`) and, through it, `LCC1620`. | Device Manager: match the port to the device. From Julia, `MicroscopeControl.HardwareImplementations.Triggerscope.LibSerialPort.list_ports()`. One `Triggerscope4` per physical port, shared by its dependents (`mc-system-design`). | | A DAQ-backed light (`CrystaLaser`, `VortranLaser`, `DaqTrLight`) `@warn`s at construction ("No NI-DAQ devices found ..." or "Failed to initialize NI-DAQ ..."), then `initialize` returns normally (Vortran's may `@warn` "insufficient DO channels for initialization") and `setpower`/`light_on` `@warn` about missing channels and do nothing, or only some calls work | These devices are **not** serial. They drive NI-DAQ AO/DO channels through an `NIdaq` built in their constructor, which picks `devs[1]` (Crysta, Vortran) or `devs[device_index]` (DaqTrLight, default 2). Discovery runs AO then DO inside one `try`, so it can **partially** succeed: AO channels kept, DO empty. Causes: wrong device index, missing NI-DAQmx runtime, card not enumerated, or a DO-less card. | Inspect `light.channelsAO` and `light.channelsDO` right after construction; `NIDAQcard.showdevices(NIdaq())` from Julia; compare with NI MAX. Fix the index or the runtime, not the driver. | | `TCubeLaser` does not respond | Kinesis serial number wrong or the device is open in the Kinesis GUI. It is addressed by `serialNo` (Thorlabs Kinesis), plus an `NIdaq` AO channel for modulation. Before **v0.2.3** every Kinesis status code was assigned to an unread `err`, so a failed `LD_Open` looked exactly like a successful one; from v0.2.3 each one throws naming the call and the code. | Match `serialNo` to the Kinesis GUI's device list; close that GUI. | -| `setpower(::TCubeLaser, current)` drives more current than asked for, or `TCubeLaser` rejects a small current | **[fixed in v0.2.3]** the range check only logged `@error` and then sent the setpoint anyway, so an out-of-range request reached the diode; and `min_current` defaulted to **60.0 mA**, a lower bound that rejected safe small currents while protecting nothing. From v0.2.3 the check throws an `ArgumentError` before any setpoint is computed, and `min_current` defaults to `0.0`. | On v0.2.3+, `setpower(laser, 500.0)` throws. On an older pinned tag, do the range check in your own system code before calling. | -| A `TCubeLaser` rig ceiling is ignored after `initialize` | **[fixed in v0.2.3]** `initialize` overwrote `light.max_current` with the controller's own limit (160-220 mA), so a caller's `max_current=80.0` was gone by the time `setpower` validated against it. From v0.2.3 the controller's value goes to the new `controller_max_current` field and `setpower` enforces the smallest of `max_current`, `controller_max_current` and `max_setcurrent`. | Print `laser.max_current` after `initialize`: on an older tag it equals the controller's limit, not yours. | -| `laser.properties.power` on a `TCubeLaser` does not match a power meter | Expected, on every version. `setpower` takes a drive **current in mA**; `properties.power` is `current * max_power / ` under a `"mW"` label, an uncalibrated linear model the driver cannot measure and that the bench table in `TCubeLaserControl.jl` contradicts. The controller reports no optical power in the open-loop mode this driver uses. **[limitation]** the pair is **deprecated from v0.2.3** and scheduled for removal in a future 0.3.0. | From v0.2.3 read `laser.drive_current` (mA, `NaN` before the first accepted `setpower`), which is what the driver acted on, and also written to `export_state`'s attributes. Convert to optical units in your own system code; the bench table and the conditional scaling formula beside it are the only optical data the package has, and both are one rig's assumptions. | +| `setpower(::TCubeLaser, x)` throws "setpower is not defined for TCubeLaser{...}" after moving the pin to 0.3.0 | **[guarantee]** Not a fault: from 0.3.0 `setpower` is a deliberate refusal on every `DiodeLaser`. It took mA while its name said mW, and with a power mode available the same call could mean either, so there is no forwarder. `TCubeLaser(serialNo; ...)` also now **requires** `mode=` (no default; `ArgumentError` without it). | Construct with `mode = ConstantCurrent()` and replace `setpower(laser, mA)` with `setcurrent!(laser, mA)`; or `mode = ConstantPhotocurrent()` and `setoutputpower!(laser, mW)`; or `setlevel!(laser, frac)` (unit-free `0..1`) for code that must work in either mode. | +| `setcurrent!` (formerly `setpower`) on a `TCubeLaser` drives more current than asked for, or rejects a small current | **[fixed in v0.2.3]** the range check only logged `@error` and then sent the setpoint anyway, so an out-of-range request reached the diode; and `min_current` defaulted to **60.0 mA**, a lower bound that rejected safe small currents while protecting nothing. From v0.2.3 the check throws an `ArgumentError` before any setpoint is computed, and `min_current` defaults to `0.0`. | On 0.3.0, `setcurrent!(laser, 500.0)` throws (on v0.2.3-v0.2.x, `setpower`). On an older pinned tag, do the range check in your own system code before calling. | +| A `TCubeLaser` rig ceiling is ignored after `initialize` | **[fixed in v0.2.3]** `initialize` overwrote `light.max_current` with the controller's own limit (160-220 mA), so a caller's `max_current=80.0` was gone by the time `setpower` validated against it. From v0.2.3 the controller's value goes to the new `controller_max_current` field and the current setter (`setcurrent!` from 0.3.0) enforces the smallest of `max_current`, `controller_max_current` and `max_setcurrent`. | Print `laser.max_current` after `initialize`: on an older tag it equals the controller's limit, not yours. | +| The laser's power does not match a power meter | Up to v0.2.x, `properties.power` on a `TCubeLaser` was an uncalibrated linear guess under a `"mW"` label; **[guarantee]** 0.3.0 no longer writes `properties.power`/`power_unit` or exports them. In `ConstantCurrent` mode the driver knows only current (`drive_current`, requested; `measured_current`, read back); no optical number exists. In `ConstantPhotocurrent` mode the power is `setoutputpower!`'s mW **at the laser output**, converted through `wa_calibration`: wrong if the calibration is stale, the TIA DIP switch moved, or you meter at the sample (the fibre and optics are not in it). **[limitation]** without a TEC-stabilised mount the photodiode's responsivity drifts, so the delivered power drifts while the loop holds photocurrent and `indicated_output_power` stays flat. | Meter at the plane the calibration was measured at (before the fibre on the 642 nm rig). Recalibrate per `src/hardware_implementations/tcube_laser/CALIBRATION.md` in the package source. Read `indicated_output_power(laser)` (a conversion, not a measurement) and `measured_current(laser)`: a drive current climbing at a still setpoint is a blocked photodiode or an ageing diode. | +| `initialize(::TCubeLaser{ConstantPhotocurrent})` throws "refusing closed loop: key switch (0x2) and/or interlock (0x8) not set" | Key switch off or interlock open on the TLD001. **[guarantee]** power-mode `initialize` requires both before it touches the loop. | Turn the key, close the interlock, re-run `initialize`. | +| `initialize` in power mode throws "the controller's photodiode range is X A but tia_range states Y A" or "reports no single photodiode range" | The rear-panel TIA DIP switch does not match the `tia_range` keyword. **[guarantee]** refused because the W/A calibration is valid only on the range it was measured on. | Set the switch to the calibrated range (`10e-6`, `100e-6`, `1e-3` or `10e-3` A); do not just edit `tia_range` unless you recalibrate on the new range. | +| `initialize` in power mode throws about the max-current potentiometer or clamp | **[guarantee]** `initialize` leaves the max-current potentiometer at the highest position whose limit, **as the controller reports it**, is `<= max_current`, and records that reported limit in `pd.max_current_clamp`. Hardware-verified on the 642 nm rig (2026-09-28): a plain pot write is ignored, adjust mode is required with a pause before leaving it, and the header's position-to-mA scale is wrong for that unit, so no clamp value is computed from a position. The pot position does not survive a controller power cycle. | Report it with the message, the pinned tag and the controller firmware; until then run `ConstantCurrent`. `light_on`/`setoutputpower!` refuse while `laser.pd.max_current_clamp` is `NaN`. | +| A `TCubeLaser` goes to its current limit whatever setpoint was sent (front display at the limit, power far above the request) | The TLD001 **ignores a setpoint sent while its output is off**, and on enable runs on its stored setpoint (on the 642 nm rig a word above full scale). **[guarantee]** since 0.3.0 the driver never sends a setpoint with the output off: `light_on` sends it right after enabling, and `light_off` zeroes it before disabling. Code or software that sets the current *before* enabling the output (including the pre-0.3.0 driver used in that order) hits this. | Upgrade, or enable the output first and then set the current. | +| Power mode holds about 21 mW whatever is requested above ~5 mW, with `loop_status().photocurrent_A` flat near 98 µA | **[limitation]** observed on the 642 nm rig (2026-09-28): the photodiode reading clips at about 3213 counts (~10 % of the 1 mA range the status bits report) and reads `0x8000` (over range) above it, and the loop then holds the clip level instead of the setpoint. 1-5 mW regulated correctly. This is the controller's photodiode channel set-up (DIP range switch or TIA gain), not the calibration: 224.2 W/A held where the channel worked. | Check the rear-panel photodiode-range DIP switch and the TIA gain calibration per the TLD001 manual (see `tcube_laser/CALIBRATION.md`); until then keep `properties.max_power` at or below ~5 mW, or use `ConstantCurrent`. | +| `loop_status(laser).saturated` is `true` in power mode | The drive current is at the clamp, so the power is not being held: a blocked or misaligned monitor photodiode, or a requested power the diode cannot reach under the clamp. **[policy]** treat a sustained `saturated` as a fault in unattended runs. | Check `measured_current` against `pd.max_current_clamp`; unblock the photodiode, lower the request, or (if the rig allows) raise `max_current`. `below_threshold === true` is a different fault: the diode is not lasing. | | `initialize(::TCubeLaser)` fails once, then every retry fails at `LD_Open` | **[fixed in v0.2.3]** a failure after a successful `LD_Open` — a mode change, a readings request — threw without closing, and a controller left open refuses the next `LD_Open`, so the first failure poisoned the retry. From v0.2.3 the handle is closed on the way out and the original error is the one raised. | On an older pinned tag, call `TCubeLaserControl.LD_Close(laser.serialNo)` before retrying, or power-cycle the cube. | | `setupIO(::TCubeLaser)` throws `BoundsError`, or modulates the wrong analogue output | It hardcoded `devs[2]` and `channelsAO[2]`. **[fixed in v0.2.3]** the defaults are unchanged (a working rig keeps working) but the indices are validated with a message naming what discovery found, and `TCubeLaser(serialNo; daq_device=, ao_channel=)` names them explicitly. | `NIDAQcard.showdevices(NIdaq())` from Julia; pass `daq_device=`/`ao_channel=`. | | A method "does nothing" or throws `not implemented for ` | It is a contract stub, not a defect in a `ccall`. | `which(f, Tuple{typeof(dev)}).sig` names `AbstractInstrument` or the interface type. See `mc-api-map`. Still worth reporting, as a missing method, not a wrong one. | diff --git a/skills/mc-system-design/SKILL.md b/skills/mc-system-design/SKILL.md index 8eb558d..5e6ae75 100644 --- a/skills/mc-system-design/SKILL.md +++ b/skills/mc-system-design/SKILL.md @@ -34,7 +34,7 @@ MicroscopeControl and Pkg writes both entries for you: ```julia Pkg.add(url="https://github.com/LidkeLab/DAQmx.jl.git") -Pkg.add(url="https://github.com/LidkeLab/MicroscopeControl.jl.git", rev="v0.2.3") +Pkg.add(url="https://github.com/LidkeLab/MicroscopeControl.jl.git", rev="v0.3.0") ``` Writing the TOML by hand needs **both** a `[deps]` and a `[sources]` entry — @@ -76,6 +76,7 @@ gives the assembled instrument its meaning. Neither can do the other's job. | **State** | the device's own fields (`exposure_time`, `roi`, `targ_x`, `properties.power`) are the configuration the driver pushes to hardware; **[guarantee]** shared code reads those fields by name (see Principle 2). | the instrument-level configuration (`AbstractSystemState`), when it is captured, and which fields are requested values versus measured ones (see "Three kinds of state"). | | **Failure recovery** | **[guarantee]** since v0.1.0 the stubs of the five `AbstractInstrument` interfaces (`Camera`, `Stage`, `LightSource`, `DAQ`, `Attenuator`) and the `AbstractInstrument` lifecycle stubs throw `ErrorException("... not implemented for T")` instead of returning `nothing`. **[limitation]** `SLM`'s `displayimage(::SLM)` has an empty body and returns `nothing`, and `SLM`/`TRIG` devices get `MethodError`, not the stub, for lifecycle calls (`references/driver-caveats.md`). Drivers mostly `@warn`/`@error` and return on hardware errors. **[limitation]** the `AbstractSystem` fallbacks still `@error` and return `nothing`. | **[policy]** rollback on partial initialization, a shutdown that reports what it could not close, and a saved record that says what is missing. All three are in the worked example below; none is provided by MC. | | **Units, axes, conventions** | its own: PI in millimetres, MCL in micrometres, Sim stages unitless (0..100); cameras return `(H, W)` / `(H, W, N)` arrays **[guarantee]** for Sim and DCAM4 (executed / traced). | **[policy]** one normalisation layer in the system (a function per device, not a conversion at every call site). | +| **Optical power at the sample** | **[guarantee]** (0.3.0, traced) a `DiodeLaser` in `ConstantPhotocurrent` mode commands and reports mW **at the laser output**, the plane its `wa_calibration` was measured at (`setoutputpower!`, `indicated_output_power`); no driver holds a field describing splitters, attenuators, fibres or objectives. In `ConstantCurrent` mode the driver has no optical number at all. | **[policy]** the path transmission from laser output to sample, its measurement and its drift belong to the system; convert in one place, and label saved values with the plane they refer to (the TCube export already writes `power_reference = "laser output"`). | | **Persistence** | a one-argument `export_state` returning `(attributes, data, children)` **[guarantee]** for every device except those in the caveats file **[limitation]**. | assembling the tree, choosing the child names, deciding when to snapshot, and recording incomplete exports (see Principle 5 and the worked example). | ## The boundary test: upstream or downstream? @@ -130,6 +131,19 @@ call, because a shared interface is what gives you the Sim substitution and the GUI for free. Define a new one only when the nearest interface would lie about what the device does. +Within lights **[guarantee]** (0.3.0): a laser on a controller that *regulates* +something (drive current, or monitor photocurrent under photodiode feedback) is a +`DiodeLaser <: LightSource`, parametric in a `RegulationMode` fixed at +construction (`TCubeLaser{ConstantCurrent}`, `TCubeLaser{ConstantPhotocurrent}`, +and the simulated `SimDiodeLaser{M}`); a light that is only on/off or modulated by +a voltage (`CrystaLaser`, `VortranLaser`, `DaqTrLight`, `SimLight`) is a plain +`LightSource`. The two take different setters: `setpower` on a plain light; +`setcurrent!` (mA), `setoutputpower!` (mW at the laser output) or the unit-free +`setlevel!` on a `DiodeLaser`, where `setpower` throws. **[policy]** type a system +field `::DiodeLaser` when the system needs readback (`loop_status`, +`measured_current`) and `::LightSource` when on/off is all it uses; test +`isa DiodeLaser`, not a driver name, to choose the setter. + ## Six design principles the source expresses Each is stated as what the code does today; the label says how far to trust it. @@ -150,7 +164,9 @@ Each is stated as what the code does today; the label says how far to trust it. `targ_x/y/z`, `real_x/y/z`, `range_x/y/z`, `stagelabel`; `gui(::Camera)` (`camera_interface/gui.jl`) reads `unique_id`, `exposure_time`, `roi`, `capture_mode`, `trigger_mode`, `sequence_length`, `is_running`; the light - and attenuator panels read `unique_id` and `properties`. A device that + and attenuator panels read `unique_id` and `properties`; from 0.3.0 the + `DiodeLaser` panels and `setlevel!` also read `min_current`, `max_current`, + `threshold_current`, `drive_current` and `pd`. A device that satisfies the method contract but lacks a field cannot use the inherited panel. **[limitation]** the Sim stages have `label` where the GUI wants `stagelabel`, so `gui(SimStage3d())` throws a `FieldError` (executed at @@ -205,8 +221,8 @@ Each is stated as what the code does today; the label says how far to trust it. | Kind | Lives in | Example | Trust | |---|---|---|---| -| **Requested configuration** | device fields the driver pushes to hardware, and your `AbstractSystemState` | `cam.exposure_time = 0.02`; `stage.targ_z`; `light.properties.power` | what you asked for. **[limitation]** `properties.power` is only updated by `setpower` on `SimLight` and `TCubeLaser` -- and on `TCubeLaser` it holds a linear current-to-power guess in a field labelled `"mW"`, contradicted by bench measurement and **deprecated from v0.2.3** (removal queued for a future 0.3.0). From v0.2.3 read `laser.drive_current` instead: the drive current in mA that `setpower` last accepted, `NaN` before the first one; `CrystaLaser`, `VortranLaser` and `DaqTrLight` write the voltage to the DAQ and leave the field at its constructor value (traced). Where the driver does not track it, the system must record the requested power itself: `Bench` below retains the last `BenchState` in `sys.requested`, `get_state` returns that, and `measured_power` reads the field (executed against a DAQ-like fake: requested `3.5`, measured `0.0`). | -| **Measured hardware state** | whatever the driver reads back | `stage.real_x` after `getposition`; a frame; `connectionstatus` | what the hardware said, at the moment you asked. **[limitation]** the Sim stages copy `targ_*` into `real_*`, so measured equals requested there by construction. | +| **Requested configuration** | device fields the driver pushes to hardware, and your `AbstractSystemState` | `cam.exposure_time = 0.02`; `stage.targ_z`; `light.properties.power` | what you asked for. **[limitation]** `properties.power` is only updated by `setpower` on `SimLight`; `CrystaLaser`, `VortranLaser` and `DaqTrLight` write the voltage to the DAQ and leave the field at its constructor value (traced). **[guarantee]** (0.3.0) a `DiodeLaser` never writes `properties.power`/`power_unit` (up to v0.2.x `TCubeLaser` wrote an uncalibrated linear guess there under a `"mW"` label; removed). Its requested state splits by mode: in `ConstantCurrent`, `laser.drive_current` is the mA `setcurrent!` last accepted (`NaN` before the first); in `ConstantPhotocurrent`, `drive_current` stays `NaN` for the life of the laser (the loop owns the current) and the request lives in `laser.pd.output_power_requested` (mW at the laser output) and `laser.pd.photocurrent_requested` (A, the decoded setpoint the loop was given). What the controller reports is measured state, next row. Where the driver does not track it, the system must record the requested power itself: `Bench` below retains the last `BenchState` in `sys.requested`, `get_state` returns that, and `measured_power` reads the field (executed against a DAQ-like fake: requested `3.5`, measured `0.0`). | +| **Measured hardware state** | whatever the driver reads back | `stage.real_x` after `getposition`; a frame; `connectionstatus` | what the hardware said, at the moment you asked. **[limitation]** the Sim stages copy `targ_*` into `real_*`, so measured equals requested there by construction. On a `DiodeLaser`: `measured_current` (mA), `measured_photocurrent` (A) and `loop_status` are readings; `indicated_output_power` (mW, `ConstantPhotocurrent` only) is a conversion of the photocurrent reading through a bench calibration, not a measurement of light. **[limitation]** on `TCubeLaser` these read Kinesis caches that `initialize` sets polling (50 ms); that polling refreshes them is not hardware-verified. | | **Saved metadata** | the `export_state` tree in the HDF5 file | `attrs["Main/camera"]["exposure_time"]` | a record of the above at snapshot time, plus whatever the system adds (which is missing, which is requested versus measured). | **[policy]** name attributes so the reader can tell the kinds apart @@ -405,15 +421,23 @@ end # get_state returns what was REQUESTED; measured values are read from device fields separately. MC.get_state(sys::Bench) = sys.requested === nothing ? error("no configuration has been requested yet") : sys.requested -measured_power(sys::Bench) = sys.laser.properties.power # cached by SimLight/TCube only; stale on the DAQ lights - # and on TCube it is a deprecated uncalibrated guess: - # read laser.drive_current (mA) from v0.2.3 instead +measured_power(sys::Bench) = laser_reading(sys.laser) +laser_reading(l::LightSource) = l.properties.power # cached by SimLight only; stale on the DAQ lights +laser_reading(l::DiodeLaser) = # 0.3.0: a DiodeLaser never writes properties.power + regulation_mode(l) isa ConstantPhotocurrent ? + indicated_output_power(l) : # mW at the laser OUTPUT: a conversion, not a meter + NaN # ConstantCurrent has no optical number; log measured_current(l) (mA) + +# The light's setter depends on its kind. On a DiodeLaser `setpower` throws; setlevel! is the +# mode-agnostic call, taking a fraction 0..1 of the device's declared range (0 is not off). +set_laser!(l::LightSource, p::Float64) = setpower(l, p) # plain light: the driver's own unit +set_laser!(l::DiodeLaser, frac::Float64) = setlevel!(l, frac) # so BenchState.laser_power is a fraction here function MC.set_state(sys::Bench, st::BenchState) sys.in_flight && error("refusing to change configuration while an acquisition is in flight") sys.cam.exposure_time = st.exposure_time # requested; the driver pushes it on the next acquisition call move(sys.stage, sys.stage.targ_x, sys.stage.targ_y, st.z) - setpower(sys.laser, st.laser_power) + set_laser!(sys.laser, st.laser_power) sys.requested = st # retained here because the light driver may not keep it return sys end @@ -469,7 +493,9 @@ end ``` What the run showed (`SimCamera(roi=CameraROI(1,1,64,32), exposure_time=0.01)`, -`SimStage3d()`, `SimLight()` unless stated): +`SimStage3d()`, `SimLight()` unless stated; the `DiodeLaser` methods of +`laser_reading` and `set_laser!` were added for 0.3.0 and are traced from the +source, not part of this run, which exercised the `LightSource` paths): | Path | Result | |---|---| @@ -545,17 +571,17 @@ stop; each is false at v0.2.0. the device's current `properties.power`/`properties.is_on`, so constructing the panel is observably read-only. **Caveat:** what `properties.power` holds after a `setpower` differs by driver (traced). `SimLight` stores the - argument it was given. `TCubeLaser`'s `setpower` takes current in - **milliamps** but stores a *calculated* power - (`current * max_power / `) in a field labelled `"mW"`, so - the units on display and on the wire differ. That pair is **deprecated from - v0.2.3** and queued for removal in a future 0.3.0; v0.2.3 adds - `laser.drive_current`, the accepted current in mA, which is what the panel - would have to read to show the wire. The slider still reads the deprecated - field. `CrystaLaser`, + argument it was given. `CrystaLaser`, `VortranLaser` and `DaqTrLight` never write the field at all, so the slider can display a stale cached value on those three. This is not a new opening-time write -- it is - about what the widget shows. If you're on an installed copy older + about what the widget shows. (Up to v0.2.x `TCubeLaser` also went through this + panel, with a `setpower` that took **milliamps** and stored a calculated power + under a `"mW"` label. **[guarantee]** from 0.3.0 a `DiodeLaser` has its own + panel, `current_panel` in mA or `power_panel` in mW at the laser output, chosen + by mode; opening it issues no command and no read, the readout is filled only by + its Read button or the Poll toggle (off by default), and a refused command is + shown in the panel rather than thrown; `mc-api-map`'s `references/gui-fields.md`.) + If you're on an installed copy older than 0.2.1, this was a real hazard on a laser, and a reason a system may want its own panel, or to open the shared one only with the shutter closed or the laser off. diff --git a/skills/mc-system-design/references/driver-caveats.md b/skills/mc-system-design/references/driver-caveats.md index 1560c3a..dcaf162 100644 --- a/skills/mc-system-design/references/driver-caveats.md +++ b/skills/mc-system-design/references/driver-caveats.md @@ -20,7 +20,8 @@ the connection; the table shows which drivers currently follow it. | `SimCamera()`, `SimStage*()`, `SimLight()` | pure | none | executed | | `PIStage()`, `N472()`, `MCLStage()`, `MCS2Stage()` | pure (`connectionstatus=false`, handle 0) | none until `initialize` | executed (constructors), traced (initialize) | | `ThorcamDCXCamera()` | pure | none until `initialize` | traced | -| `TCubeLaser(serialNo; daq=NIdaq(), daq_device=nothing, ao_channel=nothing)` | pure; stores the Kinesis serial number, a DAQ handle and (from v0.2.3) the DAQ device/AO channel names `setupIO` should use | none until `initialize` | traced | +| `TCubeLaser(serialNo; mode, daq=NIdaq(), daq_device=nothing, ao_channel=nothing, ...)` | pure; stores the Kinesis serial number, a DAQ handle and (from v0.2.3) the DAQ device/AO channel names `setupIO` should use. **From 0.3.0 `mode` is required** (`ConstantCurrent()` or `ConstantPhotocurrent()`; `ArgumentError` without it, so no construction line changes meaning silently) and the result is a `TCubeLaser{M} <: DiodeLaser`. Power mode also requires `wa_calibration` (W/A at the laser output), `tia_range` (A, the TLD001 DIP switch: `10e-6`, `100e-6`, `1e-3`, `10e-3`), `tec_stabilised` (`Bool` or `missing`) and `properties`, whose `min_power`/`max_power` become enforced mW bounds; the constructor validates them (`max_power` must fit `tia_range * wa_calibration`). `threshold_current` (mA, `NaN` unknown) is optional in both modes. The pre-0.3.0 positional arities still construct, as `ConstantCurrent`. | none until `initialize` | traced | +| `SimDiodeLaser(; mode, ...)` | pure; the simulated `DiodeLaser`, same required keywords as `TCubeLaser`; every command and read throws before `initialize` and after `shutdown` | none | traced (0.3.0) | | `Triggerscope4(; portname="COM3")` | creates a `LibSerialPort.SerialPort` object for the port; does not open it | `shutdown`, if `initialize` opened it | traced | | `LCC1620(; scope=Triggerscope4(), dac_channel=1)` | constructs its own `Triggerscope4` unless you pass `scope`; validates `dac_channel` against `scope.dacoutputs` with `@error` (does not throw) | as for the scope it holds | traced | | **`DCAM4Camera(dev_id=0)`** | **calls `dcamapi_init` and `dcamdev_open`, reads sensor size and exposure.** The camera is claimed before `initialize`; `initialize(::DCAM4Camera)` is then a no-op. **Fixed in 0.2.1:** both failure paths now throw an `ErrorException` naming the device id and the `DCAMERR` code, instead of returning a non-camera value; on each of those two checked paths the constructor calls `dcamapi_uninit()` before it throws. That is not a general guarantee: an exception raised after `dcamdev_open` succeeds has no cleanup guard, and on the two paths that do clean up the cleanup is only *attempted*: `dcamapi_uninit()` checks its own SDK result and logs an `@error` when it fails, but the constructor ignores that return and throws either way, so a failed uninitialize is visible in the log and nowhere else. (Before 0.2.1, neither failure path returned a camera: if `dcamapi_init` failed the constructor called `dcamapi_uninit` itself and returned the `DCAMERR` code; if init succeeded but `dcamdev_open` failed it `@error`d "Could not open camera" and returned `nothing`, **leaving the DCAM API initialized**.) | On success: `shutdown(cam)` (`dcamdev_close` + `dcamapi_uninit`) even if you never called `initialize`. On an installed copy older than 0.2.1, a `nothing` return needs a manual `MicroscopeControl.HardwareImplementations.DCAM4.dcamapi_uninit()` call, or the next `DCAM4Camera()` in the session starts from a half-initialized SDK; check `cam isa DCAM4Camera` before storing it. | traced (hardware verification: NOT DONE for the 0.2.1 fix — no Hamamatsu camera available) | @@ -44,7 +45,7 @@ The runtime constraints come from object ownership: |---|---|---|---| | `LCC1620` | `scope::Triggerscope4` | keyword `scope=`; defaults to a fresh `Triggerscope4()` on `COM3` | construct the scope first if you want to share it. `initialize(att)` calls `initialize(att.scope)` itself when the scope's port is not open, then sets the DAC range and drives to `min_voltage`. `shutdown(att)` drives the channel to `min_voltage` (0 V, which is **full transmission** on the LCC1620) and leaves the port open. | | `XEM` (module `OK_XEM`) | `daq::NIdaq` | keyword `daq=`; defaults to `NIdaq()` | none: `NIdaq` holds no connection (tasks are created and deleted per operation, `initialize`/`shutdown` are no-ops) | -| `TCubeLaser` | `daq::NIdaq` for the modulation channel | keyword `daq=` | none, as above. `TCubeLaserControl.setupIO(laser)` creates the AO task and picks the **second** discovered device and its **second** AO channel unless you pass `daq_device=`/`ao_channel=` (those keywords, and a validating error message in place of a bare `BoundsError`, are v0.2.3) | +| `TCubeLaser` | `daq::NIdaq` for the modulation channel | keyword `daq=` | none, as above. `TCubeLaserControl.setupIO(laser)` creates the AO task and picks the **second** discovered device and its **second** AO channel unless you pass `daq_device=`/`ao_channel=` (those keywords, and a validating error message in place of a bare `BoundsError`, are v0.2.3). **[guarantee]** (0.3.0, traced) `initialize` in `ConstantPhotocurrent` mode is a protected sequence and never enables the output: it requires key switch and interlock; requires the controller's TIA range bits to match `tia_range`; disables the output (it cannot zero the setpoint: the TLD001 ignores setpoints while the output is off, so `light_on` sends the setpoint right after enabling and `light_off` zeroes it before disabling); leaves the max-current potentiometer at the highest position whose controller-reported limit is `<= max_current` and records that limit in `pd.max_current_clamp`; enters closed loop and verifies the status bit; writes and reads back the W/A factor. Any failed step throws and closes the handle. `light_on` and `setoutputpower!` refuse until the clamp is verified. Both modes start Kinesis polling (50 ms), so the readbacks are cache reads. Hardware-verified on the 642 nm rig (2026-09-28, output off and on): polling refreshes the setpoint cache, the pot needs adjust mode, setpoints only take with the output on. **[limitation]** on that rig the photodiode reading clipped at ~3213 counts and power mode held ~21 mW for requests above ~5 mW; see `mc-extend/references/rig-causes.md` | | `CrystaLaser`, `VortranLaser`, `DaqTrLight` | `daq::NIdaq` | **built internally**; no keyword, cannot be shared | none; each constructor does its own discovery (see side effects) | Because `NIdaq` is a stateless handle, several devices each holding their own @@ -68,9 +69,18 @@ Each is confirmed by the exception sets in upstream `test/contract.jl` `export_state(::TCubeLaser, sth)`, with an unused second positional argument, so `export_state(laser)` fell through to the throwing stub. **v0.2.3** adds the 1-argument method — which is the whole fix — and removes the type from -upstream's `no_export_state` exception set. The 2-argument form stays as a -deprecated forwarder that warns once and delegates, so nothing calling it has -to change; removal is queued for a future 0.3.0. +upstream's `no_export_state` exception set. v0.2.3 kept the 2-argument form as a +deprecated forwarder; **[guarantee]** 0.3.0 removed it, so a 2-argument call is +now a `MethodError`. The 0.3.0 export also changed its attribute names: every +mode writes `regulation_mode`, `setpoint_unit` (`"mA"`/`"mW"`), `min_current_mA`, +`max_current_mA`, `controller_max_current`, `threshold_current_mA`, +`drive_current`, `is_on` and the identifiers and DAQ names; `ConstantPhotocurrent` +adds `power_reference` (`"laser output"`), `min_output_power_mW`, +`max_output_power_mW`, `wa_calibration_W_per_A`, `tia_range_A`, `tec_stabilised` +(`"true"`/`"false"`/`"unknown"`), `max_current_clamp_mA`, +`output_power_requested_mW` and `photocurrent_requested_A`. `power` and +`power_unit` are no longer written; a reader of older files must expect either +set. `XEM` (Opal Kelly FPGA, module `OK_XEM`) is not an `AbstractInstrument` and so is absent from the API map, but it **does** extend the shared `initialize` @@ -99,10 +109,18 @@ Resolving to a device-specific method is not the same as the operation working: `mc-acquire` owns this rule and the per-driver table; use its `snap` adapter (`snap(::SimCamera)` versus `snap(::Camera)`) rather than a blanket call order. -- `light_on(light, power::Float64)` exists only as a throwing interface stub; - every driver implements the 1-arg `light_on(light)`. Upstream tracks this as - `@test_broken` (executed: the `light_on` method table has only 1-arg driver - methods). +- `light_on(light, power::Float64)` existed up to v0.2.x only as a throwing + interface stub, tracked upstream as `@test_broken`. **[guarantee]** 0.3.0 + removed it: the stub is now the 1-arg `light_on(light)` every driver + implements, and the 2-arg call is a `MethodError`. +- `setpower(::DiodeLaser, ::Float64)` (so on `TCubeLaser` and `SimDiodeLaser`) + resolves but always throws, naming `setcurrent!`, `setoutputpower!` and + `setlevel!` (traced, 0.3.0). It is deliberate: there is no forwarder, so no + existing call silently changes unit. `setlevel!` has methods only for + `DiodeLaser` **[limitation]**; on the plain lights it is the throwing stub. +- `setlevel!(laser, 0.0)` sets the bottom of the declared range, which is not + off (in `ConstantCurrent` it is `min_current`, which may be above threshold). + Use `light_off`. ## Shared GUI facts @@ -115,7 +133,9 @@ misbehaves on `SimCamera`. `MLSLM` has no `gui`; `gui(::TRIG)` exists for `setpower(light, 0.5)` on open (executed against a fake-transport light); opening **the shared light panel** is now observably read-only. That is the whole scope of the fix -- `gui(::DAQ)` still queries `showdevices` and -`showchannels` at construction. +`showchannels` at construction. From 0.3.0 a `DiodeLaser` opens its own panel +(`current_panel` or `power_panel`, by mode), which issues no command and no read +on open **[guarantee]**. ## `save_h5` facts diff --git a/skills/mc-testing/SKILL.md b/skills/mc-testing/SKILL.md index cc66d41..46335c1 100644 --- a/skills/mc-testing/SKILL.md +++ b/skills/mc-testing/SKILL.md @@ -47,6 +47,7 @@ machine with no hardware or DLLs loads the package fine. | `SimCamera` | keyword, all defaulted | `exposure_time=0.1` (s), `roi=CameraROI(1, 1, 1024, 1024)`, `sequence_length=10`, `capture_mode=LIVE`, `is_running=false` | | `SimStage3d` / `SimStage2d` / `SimStage1d` | `Base.@kwdef` | `dimensions=3/2/1`, `range_*=(0, 100)`, `real_*=0.0`, `targ_*=0.0`, `connectionstatus=false`, `label` | | `SimLight` | keyword | `properties=LightSourceProperties("mW", 0.0, false, 0.0, 100.0)` | +| `SimDiodeLaser{M}` (0.3.0) | keyword; **`mode` required**, and in `ConstantPhotocurrent` also `wa_calibration`, `tia_range`, `tec_stabilised`, `properties`, exactly as `TCubeLaser` | `threshold_current=65.0`, `max_current=160.0` (mA), `min_current=0.0`, `efficiency=1.2` (mW/mA above threshold), `responsivity=1/wa_calibration` (calibration starts true); fault fields `pd_blocked`, `responsivity_drift`, `tia_range_A` (the simulated DIP switch), `tia_over_fault`, `tia_under_fault`, `key`, `interlock`; `log` | All implement `initialize`, `shutdown`, `export_state` **[guarantee]**. There is no simulated DAQ, attenuator, Triggerscope or SLM; for those, write a fake behind @@ -72,8 +73,32 @@ Honestly, per device (executed): call `println`s a status line. `move` is `Float64`-only. - **SimLight.** `setpower` writes `properties.power` with no clamp; `light_on`/`light_off` toggle `is_on`; `initialize` turns it **on**, - `shutdown` off. 2-arg `light_on(light, power)` throws (interface arity - mismatch shared by every light). + `shutdown` off. 2-arg `light_on(light, power)` is a `MethodError` from 0.3.0 + (up to v0.2.x it hit a throwing interface stub). `SimLight` is a plain + `LightSource`, so it is **not** the simulator for a `TCubeLaser`. +- **SimDiodeLaser** (0.3.0; traced from the source and its upstream testset, not + part of the v0.2.0 run). The simulator for any `DiodeLaser` system: same + abstract type, same constructor keywords and validation, same `loop_status` + layout and `export_state` attribute names as `TCubeLaser`, so a field typed + `::DiodeLaser` takes either. **[guarantee]** every command and read throws + before `initialize` and after `shutdown`; power-mode `initialize` refuses on a + missing key or interlock or a `tia_range_A` that differs from `tia_range`, and + sets the clamp to `max_current`. The model is three lines: `P = efficiency * + max(0, I - threshold_current)`; photocurrent `= responsivity * (1 + + responsivity_drift) * P / 1000`; in power mode the loop settles instantly at + the setpoint or at the clamp (`saturated`). `pd_blocked = true` drives the + current to the clamp; a nonzero `responsivity_drift` moves the true power while + `indicated_output_power` stays put. The setpoint uses the TLD001's round-down + encoding, so `pd.photocurrent_requested` differs from the request as on hardware. + `sim.log` records every call as a `(verb, value)` tuple, **reads as well as + commands** (`(:read, :loop_status)`), so a test can assert that something issued + neither: e.g. `empty!(sim.log); gui(sim); @test isempty(sim.log)`. + `MicroscopeControl.HardwareImplementations.SimulatedDiodeLaser.true_output_power(sim)` + is the model's true mW at the laser output: a **test oracle only**, not + exported and not an interface generic; compare it with + `indicated_output_power` to test what your system does when the calibration + is wrong. Not modelled: settling dynamics (only an optional real `sleep` via + `settle_s`), Kinesis polling, the DAQ modulation input, thermal behaviour. Good for: dispatch, composition, lifecycle order, state round trips, `export_state`/`save_h5` tree shape, array shapes, refusal logic. Not good for: @@ -152,9 +177,20 @@ types; today the same loop fails for `ThorCamCSCCamera` (`initialize`, `export_state`), naming the device that would throw during `shutdown` before it does. `TCubeLaser` failed the `export_state` assertion too until **v0.2.3**, whose `export_state(::TCubeLaser)` is the 1-argument method that was missing; the old -`export_state(::TCubeLaser, sth)` remains as a deprecated forwarder rather than -being deleted. An installed copy of this skill older than v0.2.3 still lists -`TCubeLaser` as failing. +`export_state(::TCubeLaser, sth)` stayed as a deprecated forwarder through +v0.2.x and **0.3.0 removed it** (a 2-arg call is now a `MethodError`). An +installed copy of this skill older than v0.2.3 still lists `TCubeLaser` as +failing. + +For a parametric `DiodeLaser` (`TCubeLaser`, `SimDiodeLaser`), pass the **bare** +type for the lifecycle and other mode-shared methods, and the instantiation for +a mode-specific one: `has_specific(setcurrent!, TCubeLaser{ConstantCurrent}, +Float64)`. **[guarantee]** from 0.3.0 upstream's own check unwraps `UnionAll` +signatures (`Base.unwrap_unionall(m.sig).parameters[2]`); the plain +`.sig.parameters[2]` above throws on a `where`-method, so use the unwrapped form +if your loop can reach one. **[limitation]** if you build the type list with +`subtypes(LightSource)`, it contains `DiodeLaser`, not `TCubeLaser` or +`SimDiodeLaser`; walk to the non-abstract leaves instead. Behavioural tests on top, executed: @@ -217,7 +253,12 @@ test: it snapshots `export_state(dev)` before and after `gui(dev)` for every Sim device, and additionally opens the light panel on a recording light whose `setpower`/`light_on`/`light_off` log every call, asserting the log is empty. The state snapshot alone cannot catch the defect, because the widgets now -initialise from the device's own values.) One remaining fact for a GUI test: the camera panel +initialise from the device's own values.) From 0.3.0 `test/gui.jl` does the same +for the `DiodeLaser` panels with `SimDiodeLaser` in both modes: `empty!(sim.log)`, +open the panel, assert the log is still empty. **[guarantee]** opening +`current_panel`/`power_panel` issues no command and **no read** (the readout +waits for Read or Poll); a system test can use the same `log` check on its own +panels and menus. One remaining fact for a GUI test: the camera panel uses `capture`'s return value as the frame, so "Start Capture" misbehaves on `SimCamera`. Test the panels you can; list the rest under hardware acceptance. @@ -245,7 +286,7 @@ Upstream's `.github/workflows/CI.yml` (Ubuntu): Both halves matter: the background `Xvfb :99` plus `DISPLAY` covers precompilation in `julia-buildpkg`, the `xvfb-run -a` prefix covers the test process. Add `RIG_SIMULATE: "true"` to the job `env`. Pin MicroscopeControl.jl -to a tag (`Pkg.add(url=..., rev="v0.2.0")`) and rerun `install_skills()` after +to a tag (`Pkg.add(url=..., rev="v0.3.0")`) and rerun `install_skills()` after moving it so the API map matches what CI tests against. ## What hardware acceptance must still establish diff --git a/src/hardware_implementations/HardwareImplementations.jl b/src/hardware_implementations/HardwareImplementations.jl index b9d7dbf..40c5ad8 100644 --- a/src/hardware_implementations/HardwareImplementations.jl +++ b/src/hardware_implementations/HardwareImplementations.jl @@ -47,6 +47,9 @@ include("simulated_light/SimulatedLight.jl") include("tcube_laser/TCubeLaserControl.jl") @reexport using .TCubeLaserControl +include("simulated_diode_laser/SimulatedDiodeLaser.jl") +@reexport using .SimulatedDiodeLaser + include("daq_transmission_light/TransmissionDaqControl.jl") @reexport using .TransmissionDaqControl diff --git a/src/hardware_implementations/simulated_diode_laser/SimulatedDiodeLaser.jl b/src/hardware_implementations/simulated_diode_laser/SimulatedDiodeLaser.jl new file mode 100644 index 0000000..8a921c1 --- /dev/null +++ b/src/hardware_implementations/simulated_diode_laser/SimulatedDiodeLaser.jl @@ -0,0 +1,357 @@ +""" + SimulatedDiodeLaser + +A simulated twin of a regulated laser diode, [`SimDiodeLaser`](@ref), on the same +abstract type as the hardware driver, so a system field typed `::DiodeLaser` +accepts either. +""" +module SimulatedDiodeLaser + +using ...MicroscopeControl.HardwareInterfaces.LightSourceInterface +import ...MicroscopeControl.HardwareInterfaces.LightSourceInterface: effective_max_current, STATUS_BITS + +import ...MicroscopeControl: export_state, initialize, shutdown + +export SimDiodeLaser + +""" + SimDiodeLaser{M<:RegulationMode} <: DiodeLaser + +A simulated laser diode on a TLD001-like controller, in either mode. It carries +the field names the shared panels and `setlevel!` read (`unique_id`, +`properties`, `min_current`, `max_current`, `threshold_current`, +`drive_current`, `pd`), reports the same [`loop_status`](@ref) layout as +`TCubeLaser`, throws on every command and read before `initialize` and after +`shutdown` (leaving every `*_requested` field unchanged), and records each call +in `log` as a `(verb, value)` tuple -- reads as well as commands, so a test can +assert that something issued neither. + +The diode model is three lines of physics, and is the point of the type: + + P_true = efficiency * max(0, I - threshold_current) # mW at the laser output + photocurrent = responsivity * (1 + responsivity_drift) * P_true / 1000 # A + loop : I rises until photocurrent reaches the setpoint, or I reaches the clamp + +In `ConstantCurrent` mode `I` is the commanded current, limited by the clamp. In +`ConstantPhotocurrent` mode the loop settles instantly. [`true_output_power`](@ref) +returns `P_true`: the test oracle, on the simulation only, never an interface +generic. + +# Model and fault fields +- `efficiency` (mW/mA above threshold), `responsivity` (A/W at the monitor + photodiode; defaults to `1 / wa_calibration`, so the calibration starts true), + `responsivity_drift` (fractional; nonzero models a warming photodiode whose + photocurrent the loop still holds while the light moves). +- `tia_range_A`: the simulated rear-panel DIP switch, in A. Defaults to the + stated `pd.tia_range` (or 1 mA); set it differently to model a moved switch. +- `key`, `interlock`: the two interlocks `initialize` checks in power mode. +- `pd_blocked`: the photodiode sees nothing, so the loop drives the current + straight to the clamp. +- `tia_over_fault`, `tia_under_fault`: force the amplifier flags. +- `settle_s`: real `sleep` after each command. +""" +mutable struct SimDiodeLaser{M<:RegulationMode} <: DiodeLaser + unique_id::String + properties::LightSourceProperties + min_current::Float64 + max_current::Float64 + threshold_current::Float64 + drive_current::Float64 + pd::Union{Nothing,PhotodiodeLoop} + efficiency::Float64 + responsivity::Float64 + responsivity_drift::Float64 + tia_range_A::Float64 + key::Bool + interlock::Bool + pd_blocked::Bool + tia_over_fault::Bool + tia_under_fault::Bool + settle_s::Float64 + is_open::Bool + output_enabled::Bool + setpoint::Float64 + clamp_mA::Float64 + log::Vector{Tuple{Symbol,Any}} + + function SimDiodeLaser{M}(unique_id, properties, min_current, max_current, threshold_current, + drive_current, pd, efficiency, responsivity, responsivity_drift, tia_range_A, key, interlock, + pd_blocked, tia_over_fault, tia_under_fault, settle_s) where {M<:RegulationMode} + M in supported_modes(SimDiodeLaser) || throw(ArgumentError("SimDiodeLaser: mode $(M) is not supported")) + LightSourceInterface.check_diode_config(M, pd, properties, Float64(max_current), "SimDiodeLaser $unique_id") + new{M}(unique_id, properties, min_current, max_current, threshold_current, drive_current, pd, + efficiency, responsivity, responsivity_drift, tia_range_A, key, interlock, + pd_blocked, tia_over_fault, tia_under_fault, settle_s, + false, false, 0.0, max_current, Tuple{Symbol,Any}[]) + end +end + +LightSourceInterface.supported_modes(::Type{<:SimDiodeLaser}) = (ConstantCurrent, ConstantPhotocurrent) +LightSourceInterface.regulation_mode(::Type{SimDiodeLaser{M}}) where {M} = M() + +""" + SimDiodeLaser(; mode, kwargs...) + +`mode` is required, as on `TCubeLaser`. In `ConstantPhotocurrent` mode so are +`wa_calibration`, `tia_range`, `tec_stabilised` and `properties`, exactly as on +the hardware driver, so that a system built against the simulation constructs +the same way. `threshold_current` defaults to 65 mA and `max_current` to 160 mA +(the 642 nm diode's numbers); the model fields are documented on the type. +""" +function SimDiodeLaser(; + mode::Union{Nothing,RegulationMode}=nothing, + unique_id::String="SimDiodeLaser", + properties::Union{Nothing,LightSourceProperties}=nothing, + min_current::Float64=0.0, + max_current::Float64=160.0, + threshold_current::Float64=65.0, + wa_calibration::Union{Nothing,Real}=nothing, + tia_range::Union{Nothing,Real}=nothing, + tec_stabilised::Union{Nothing,Bool,Missing}=nothing, + efficiency::Float64=1.2, + responsivity::Union{Nothing,Float64}=nothing, + responsivity_drift::Float64=0.0, + tia_range_A::Union{Nothing,Float64}=nothing, + key::Bool=true, + interlock::Bool=true, + pd_blocked::Bool=false, + settle_s::Float64=0.0, +) + mode === nothing && throw(ArgumentError( + "SimDiodeLaser: the `mode` keyword is required: ConstantCurrent() or ConstantPhotocurrent()")) + pd = if mode isa ConstantPhotocurrent + absent = [kw for (kw, v) in (:wa_calibration => wa_calibration, :tia_range => tia_range, + :tec_stabilised => tec_stabilised, :properties => properties) if v === nothing] + isempty(absent) || throw(ArgumentError( + "SimDiodeLaser: a ConstantPhotocurrent laser needs these keywords, none of which has a default: $(join(absent, ", "))")) + PhotodiodeLoop(; wa_calibration=wa_calibration, tia_range=tia_range, tec_stabilised=tec_stabilised) + else + any(!isnothing, (wa_calibration, tia_range, tec_stabilised)) && throw(ArgumentError( + "SimDiodeLaser: wa_calibration, tia_range and tec_stabilised describe a photodiode loop, which only a ConstantPhotocurrent laser has")) + nothing + end + props = something(properties, LightSourceProperties("mA", 0.0, false, min_current, max_current)) + resp = something(responsivity, pd === nothing ? 1 / 224.2 : 1 / pd.wa_calibration) + range = something(tia_range_A, pd === nothing ? 1e-3 : pd.tia_range) + SimDiodeLaser{typeof(mode)}(unique_id, props, min_current, max_current, threshold_current, + NaN, pd, efficiency, resp, responsivity_drift, range, key, interlock, + pd_blocked, false, false, settle_s) +end + +# --- the model ------------------------------------------------------------ + +_responsivity(sim::SimDiodeLaser) = sim.pd_blocked ? 0.0 : sim.responsivity * (1 + sim.responsivity_drift) + +"Drive current in mA the simulated controller is passing." +function _current(sim::SimDiodeLaser{ConstantCurrent}) + sim.output_enabled || return 0.0 + return min(sim.setpoint, sim.clamp_mA) +end + +function _current(sim::SimDiodeLaser{ConstantPhotocurrent}) + (sim.output_enabled && sim.setpoint > 0) || return 0.0 + r = _responsivity(sim) + r > 0 || return sim.clamp_mA # nothing to regulate on: straight to the clamp + needed = sim.threshold_current + sim.setpoint / r * 1000 / sim.efficiency + return min(needed, sim.clamp_mA) +end + +_true_power(sim::SimDiodeLaser) = sim.efficiency * max(0.0, _current(sim) - sim.threshold_current) +_photocurrent(sim::SimDiodeLaser) = _responsivity(sim) * _true_power(sim) / 1000 + +function _saturated(sim::SimDiodeLaser{ConstantCurrent}) + sim.output_enabled && sim.setpoint >= sim.clamp_mA +end +function _saturated(sim::SimDiodeLaser{ConstantPhotocurrent}) + sim.output_enabled && sim.setpoint > 0 && _current(sim) >= sim.clamp_mA && + _photocurrent(sim) < sim.setpoint +end + +function _status_word(sim::SimDiodeLaser) + w = UInt32(0) + set(bit, on) = on ? (w |= bit) : w + set(STATUS_BITS.output_enabled, sim.output_enabled) + set(STATUS_BITS.key, sim.key) + set(STATUS_BITS.interlock, sim.interlock) + set(STATUS_BITS.closed_loop, regulation_mode(sim) isa ConstantPhotocurrent && sim.is_open) + set(STATUS_BITS.psu_ok, true) + for (bit, range) in LightSourceInterface.TIA_RANGE_BITS + set(bit, isapprox(range, sim.tia_range_A; rtol=1e-9)) + end + set(STATUS_BITS.saturated, _saturated(sim)) + set(STATUS_BITS.tia_over, sim.tia_over_fault || _photocurrent(sim) > sim.tia_range_A) + set(STATUS_BITS.tia_under, sim.tia_under_fault) + return w +end + +""" + true_output_power(sim::SimDiodeLaser) + +The simulated optical power at the laser output, in mW: the model's ground +truth, which no driver can report. A test oracle only, not an interface +generic -- compare it with `indicated_output_power` to see what the +calibration does and does not guarantee. +""" +true_output_power(sim::SimDiodeLaser) = _true_power(sim) + +# --- lifecycle and commands ------------------------------------------------ + +function _require_open(sim::SimDiodeLaser, op) + sim.is_open || error("SimDiodeLaser $(sim.unique_id): $op refused: not initialized (or already shut down)") + return nothing +end + +function _require_clamp(sim::SimDiodeLaser, op) + regulation_mode(sim) isa ConstantPhotocurrent && isnan(sim.pd.max_current_clamp) && error( + "SimDiodeLaser $(sim.unique_id): $op refused: the max-current clamp has not been programmed") + return nothing +end + +_settle(sim::SimDiodeLaser) = sim.settle_s > 0 && sleep(sim.settle_s) + +function initialize(sim::SimDiodeLaser) + push!(sim.log, (:initialize, nothing)) + if regulation_mode(sim) isa ConstantPhotocurrent + missing_bits = [label for (label, ok) in (("key switch", sim.key), ("interlock", sim.interlock)) if !ok] + isempty(missing_bits) || error("SimDiodeLaser $(sim.unique_id): refusing closed loop: $(join(missing_bits, " and ")) not set") + isapprox(sim.tia_range_A, sim.pd.tia_range; rtol=1e-9) || error( + "SimDiodeLaser $(sim.unique_id): refusing closed loop: the controller's photodiode range is $(sim.tia_range_A) A but tia_range states $(sim.pd.tia_range) A") + sim.clamp_mA = sim.max_current + sim.pd.max_current_clamp = sim.clamp_mA + end + sim.output_enabled = false + sim.properties.is_on = false + sim.setpoint = 0.0 + sim.is_open = true + return nothing +end + +function shutdown(sim::SimDiodeLaser) + push!(sim.log, (:shutdown, nothing)) + sim.output_enabled = false + sim.properties.is_on = false + sim.is_open = false + return nothing +end + +function LightSourceInterface.light_on(sim::SimDiodeLaser) + push!(sim.log, (:light_on, nothing)) + _require_open(sim, "light_on") + _require_clamp(sim, "light_on") + sim.output_enabled = true + sim.properties.is_on = true + _settle(sim) + return nothing +end + +function LightSourceInterface.light_off(sim::SimDiodeLaser) + push!(sim.log, (:light_off, nothing)) + _require_open(sim, "light_off") + sim.output_enabled = false + sim.properties.is_on = false + return nothing +end + +function LightSourceInterface.setcurrent!(sim::SimDiodeLaser{ConstantCurrent}, current::Float64) + push!(sim.log, (:setcurrent!, current)) + _require_open(sim, "setcurrent!") + hi = effective_max_current(sim) + (isfinite(current) && sim.min_current <= current <= hi) || throw(ArgumentError( + "SimDiodeLaser $(sim.unique_id): requested current $(current) mA is outside the allowed range [$(sim.min_current), $(hi)] mA")) + sim.setpoint = current + sim.drive_current = current + _settle(sim) + return nothing +end + +function LightSourceInterface.setoutputpower!(sim::SimDiodeLaser{ConstantPhotocurrent}, power_mW::Float64) + push!(sim.log, (:setoutputpower!, power_mW)) + _require_open(sim, "setoutputpower!") + _require_clamp(sim, "setoutputpower!") + lo, hi = sim.properties.min_power, sim.properties.max_power + (isfinite(power_mW) && lo <= power_mW <= hi) || throw(ArgumentError( + "SimDiodeLaser $(sim.unique_id): requested output power $(power_mW) mW is outside the allowed range [$(lo), $(hi)] mW")) + word = _status_word(sim) + word & STATUS_BITS.tia_over != 0 && error( + "SimDiodeLaser $(sim.unique_id): setoutputpower! refused: the photodiode amplifier reports OVER range") + (word & STATUS_BITS.tia_under != 0 && sim.output_enabled) && error( + "SimDiodeLaser $(sim.unique_id): setoutputpower! refused: the photodiode amplifier reports UNDER range with the output on") + pd = sim.pd + i_pd = power_mW / 1000 / pd.wa_calibration + i_pd <= pd.tia_range || throw(ArgumentError( + "SimDiodeLaser $(sim.unique_id): $(i_pd) A of photocurrent is above the amplifier's full scale of $(pd.tia_range) A")) + # The same 15-bit, round-down encoding as the TLD001, so the decoded + # setpoint differs from the request exactly as it does on hardware. + code = floor(i_pd / pd.tia_range * 32767) + while code > 0 && code / 32767 * pd.tia_range > i_pd + code -= 1 + end + sim.setpoint = code / 32767 * pd.tia_range + pd.output_power_requested = power_mW + pd.photocurrent_requested = sim.setpoint + _settle(sim) + return nothing +end + +# --- readbacks --------------------------------------------------------------- + +function LightSourceInterface.measured_current(sim::SimDiodeLaser) + push!(sim.log, (:read, :measured_current)) + _require_open(sim, "measured_current") + return _current(sim) +end + +function LightSourceInterface.measured_photocurrent(sim::SimDiodeLaser) + push!(sim.log, (:read, :measured_photocurrent)) + _require_open(sim, "measured_photocurrent") + return min(_photocurrent(sim), sim.tia_range_A) +end + +function LightSourceInterface.indicated_output_power(sim::SimDiodeLaser{ConstantPhotocurrent}) + return measured_photocurrent(sim) * sim.pd.wa_calibration * 1000 +end + +function LightSourceInterface.loop_status(sim::SimDiodeLaser) + push!(sim.log, (:read, :loop_status)) + _require_open(sim, "loop_status") + commanded = regulation_mode(sim) isa ConstantPhotocurrent ? !isnan(sim.pd.output_power_requested) : + !isnan(sim.drive_current) + return LightSourceInterface.status_snapshot(_status_word(sim), _current(sim), + min(_photocurrent(sim), sim.tia_range_A); + threshold_current=sim.threshold_current, commanded=commanded) +end + +""" + export_state(sim::SimDiodeLaser) + +The same attribute names as `export_state(::TCubeLaser)`, from field reads only. +""" +function export_state(sim::SimDiodeLaser) + mode = regulation_mode(sim) + attributes = Dict{String,Any}( + "unique_id" => sim.unique_id, + "regulation_mode" => string(nameof(typeof(mode))), + "setpoint_unit" => mode isa ConstantPhotocurrent ? "mW" : "mA", + "min_current_mA" => sim.min_current, "max_current_mA" => sim.max_current, + "threshold_current_mA" => sim.threshold_current, + "drive_current" => sim.drive_current, + "is_on" => sim.properties.is_on, + ) + if mode isa ConstantPhotocurrent + pd = sim.pd + merge!(attributes, Dict{String,Any}( + "power_reference" => "laser output", + "min_output_power_mW" => sim.properties.min_power, + "max_output_power_mW" => sim.properties.max_power, + "wa_calibration_W_per_A" => pd.wa_calibration, + "tia_range_A" => pd.tia_range, + "tec_stabilised" => pd.tec_stabilised === missing ? "unknown" : string(pd.tec_stabilised), + "max_current_clamp_mA" => pd.max_current_clamp, + "output_power_requested_mW" => pd.output_power_requested, + "photocurrent_requested_A" => pd.photocurrent_requested, + )) + end + return attributes, nothing, Dict{String,Any}() +end + +end diff --git a/src/hardware_implementations/tcube_laser/CALIBRATION.md b/src/hardware_implementations/tcube_laser/CALIBRATION.md new file mode 100644 index 0000000..5117bd1 --- /dev/null +++ b/src/hardware_implementations/tcube_laser/CALIBRATION.md @@ -0,0 +1,312 @@ +# Calibrating a TCube laser for closed loop (power mode) + +A `TCubeLaser{ConstantPhotocurrent}` is commanded in **mW at the laser output** +with `setoutputpower!`. The controller cannot measure optical power: in closed +loop it holds the **monitor photodiode current** constant, and the driver turns +your mW into a photocurrent setpoint through two numbers you must supply: + +| Number | Keyword | What it is | Where it comes from | +|---|---|---|---| +| W/A factor | `wa_calibration` | optical power at the laser output per amp of monitor photocurrent | measured with a power meter (this document) | +| TIA range | `tia_range` | full scale of the photodiode amplifier, in A: `10e-6`, `100e-6`, `1e-3` or `10e-3` | the rear-panel DIP switch on the TLD001; `initialize` refuses to start if the controller reports a different one | + +The conversion both ways is + + photocurrent_A = power_mW / 1000 / wa_calibration + setpoint_word = photocurrent_A / tia_range * 32767 (rounded down) + power_mW = reading_word / 32767 * tia_range * wa_calibration * 1000 + +Two more numbers are declared at construction and come from the same bench +session: `threshold_current` (mA, where the diode starts lasing) and +`properties.min_power` / `max_power` (mW, the range `setoutputpower!` accepts +and the endpoints of `setlevel!` and the panel slider). + +## The 642 nm rig's current calibration + +| | | +|---|---| +| Controller | Thorlabs TLD001, serial `64849775` | +| TIA range | 1 mA (`tia_range = 1e-3`) | +| W/A factor | **224.2** W/A | +| Measurement plane | power meter **before the fibre** (coupling efficiency drifts, so the meter is never read after it) | +| Measured by | Ali Kazemi Nasaban Shotorban, recorded in `helpers.jl`, Oct 2024 | +| Threshold | ~65 mA | + +Verification, in closed loop with those two factors: power commanded through +the formula above versus power measured before the fibre, in mW. Single +readings, no repeats or statistics: + +| commanded | measured | +|---:|---:| +| 0.0 | < 0.001 | +| 10.0 | 9.31 | +| 20.0 | 19.11 | +| 30.0 | 29.11 | +| 40.0 | 39.06 | +| 50.0 | 48.97 | +| 60.0 | 59.22 | +| 70.0 | 69.65 | +| 80.0 | 79.50 | + +Measured power sits 0.35–1.03 mW below the command at every point: within +1.5 % from 60 mW up, 2–4.5 % at 20–50 mW, and 7 % at 10 mW. The offset is not +modelled by the driver. + +Constructing the laser with it: + +```julia +using MicroscopeControl + +laser = TCubeLaser("64849775"; + mode = ConstantPhotocurrent(), + wa_calibration = 224.2, # W/A, this document + tia_range = 1e-3, # A, the rear-panel DIP switch + tec_stabilised = missing, # true / false once known; `missing` is honest until then + threshold_current = 65.0, # mA + max_current = 160.0, # mA: programmed into the controller as the loop's clamp + properties = LightSourceProperties("mW", 0.0, false, 1.0, 5.0)) # [1 mW, 5 mW] +# 5 mW, not 70: until the photodiode channel is fixed (see "Hardware check, +# 2026-09-28"), closed loop on this rig only regulated up to about 5 mW. + +initialize(laser) # checks key, interlock and the DIP switch; programs the clamp; never emits +setoutputpower!(laser, 20.0) # mW at the laser output +light_on(laser) +loop_status(laser) # photocurrent, drive current, saturated / TIA flags +``` + +## Hardware check, 2026-09-28 (642 nm rig, this driver) + +Power meter before the fibre, read by Ali Kazemi Nasaban Shotorban; 30 s (open +loop) or 20 s (closed loop) per point. + +Open loop (`ConstantCurrent`), setpoint sent after the output is enabled: + +| setpoint | front display | measured | photodiode x 224.2 W/A | +|---:|---:|---:|---:| +| 70 mA | 70.0 | 1.687 mW | 2.2 mW | +| 90 mA | 90.0 | 20.67 mW | 21.4 mW | +| 110 mA | 110.0 | 39.42 mW | over range (`0x8000`) | + +Threshold about 68 mA, slope about 0.94 mW/mA. + +Closed loop (`ConstantPhotocurrent`, 224.2 W/A, 1 mA range): + +| requested | measured | photodiode held at | drive current | +|---:|---:|---:|---:| +| 1 mW | 0.561 mW | 4.46 µA (= setpoint) | 68.5 mA | +| 2 mW | 1.554 mW | 8.91 µA (= setpoint) | 69.8 mA | +| 3 mW | 2.543 mW | 13.37 µA (= setpoint) | 70.7 mA | +| 5 mW | 4.517 mW | 22.28 µA (= setpoint) | 72.8 mA | +| 5 mW (first attempt) | 21.43 mW | 98.09 µA (stuck) | 90.8 mA | +| 10 mW | 21.42 mW | 98.09 µA (stuck) | 90.8 mA | + +Where the loop regulated, measured = requested - 0.46 mW with a slope of 0.99: +**the 224.2 W/A calibration still holds.** But the photodiode channel **clips +at about 3213 counts (98 µA, ~21 mW at this W/A) and reads `0x8000` above it**, +and above roughly 5 mW the loop held that clip level (~21 mW) instead of the +setpoint. 98 µA is almost exactly the full scale of the **100 µA** range, while +the status bits report the 1 mA range, and in 2024 the same calibration was +verified in closed loop up to 80 mW. So the photodiode channel's set-up has +changed: see "The photodiode range and the TIA gain" below. **Until it is +fixed, closed loop is only usable up to about 5 mW** (`properties.max_power`). + +Found on the same day, and fixed in the driver: the controller ignores a +setpoint sent while its output is off (it then runs on a stale stored setpoint, +which on this rig drove the diode to its ~160 mA limit, 85-88 mW), so the +driver only sends setpoints with the output on. + +## The photodiode range (DIP switch) and the TIA gain + +The photodiode signal goes through a transimpedance amplifier (TIA) whose range +-- 10 µA, 100 µA, 1 mA or 10 mA full scale -- is selected **only by the +rear-panel DIP switches**; software can read it (status bits `0x10`-`0x80`, +`loop_status(laser).tia_range_A`) but not set it. A separate TIA **gain** +calibration (`LD_FindTIAGain` in the Kinesis API) trims the amplifier on the +selected range. + +The procedure, from the TLD001 Kinesis user manual (section "Photodiode Current +(IPD) Range", the front-panel set-up pages, and the software "Photodiode Tab"; +read on ManualsLib, manual 1891386, pages 18, 27, 28 and 46, 2026-09-28): + +1. **Start on the 10 mA range**: "Initially always set the PD RANGE switches to + the 10 mA range (all switches must be in the upwards, ON position)." +2. **The laser must be on** -- "otherwise the photocurrent will be zero". Run it + at the highest current you intend to use, so the range covers your top power. +3. **Step the switches down, one range at a time**, "from a higher range towards + the lower ranges, i.e. in the 10 mA -> 1 mA -> 100 uA -> 10 uA order", and + watch the indication: + - front panel, Photodiode Range parameter (reached with DISPLAY from the Max + Laser Current parameter): **`Pdr H`** = over range, **`Pdr -`** = range OK, + **`Pdr L`** = under range; + - or in the Kinesis software, Settings > **Photodiode** tab, with "Enable + Photodiode TIA Range Adjustment" ticked: adjust until the green **In Range** + LED is lit. + Stop on the most sensitive range that still shows OK. +4. **Then optimise the photodiode (TIA) gain** -- "an automated process performed + internally by the unit", only after the range is set: front panel, next + parameter after the range (DISPLAY), press **MODE** (the display shows `Pd oP` + with a flashing dot, then the photocurrent); or **Optimize Amplifier Gain** on + the Kinesis Photodiode tab. "Persist Settings to Hardware" saves it. This + driver does not do either (the API call is `LD_FindTIAGain`). +5. **Re-measure the W/A factor on that range** (steps 3 and 5 of the procedure + below). A W/A factor belongs to one range and one gain setting; do not carry + 224.2 over. +6. Construct the laser with `tia_range` set to the range you chose; + `initialize` refuses if the controller reports a different one. + +Why the gain step matters here (Kinesis user guide 17874-D03, §3.3.8): in +closed loop the setpoint comes from a DAC, and "to enable the full range of the +DAC to be used, the photodiode current readings must be 'normalized', so that +the full range (i.e. maximum photocurrent) corresponds to the DAC full range. +The 'optimize current gain' button carries out this normalization." And from +the PC tutorial: "The photodiode range adjustment should be performed at maximum +laser drive current." So the reading's full scale is set by the gain +optimisation, done with the diode at its current limit. A reading that clips at +~10 % of full scale (as on 2026-09-28) points to a gain that no longer matches +the range and diode -- for example one optimised on another range or at another +current limit. **Re-optimising the gain changes the counts per mW, so the W/A +factor must be measured again afterwards**; 224.2 belongs to the gain the +controller had in 2024, which the 1-5 mW closed-loop points of 2026-09-28 still +matched. The manuals are on the lab share: `Z:\Computers and Software\isos and +Install Files\ThorLabs\TLD001 Laser Diode Driver\` (17874-d03.pdf = Kinesis, +17874-d01.pdf = APT). + +What to check first on the 642 nm rig: which range the DIP switch is physically +on, and whether the reading ceiling moves when it is changed. The 98 µA ceiling +with the status bits saying 1 mA suggests the switch position and the reported +range disagree, or that the TIA gain is set for the wrong range. + +## When to recalibrate + +The numbers belong to **this photodiode, this TIA range and this measurement +plane**. Recalibrate when any of these changes: + +- the DIP switch is moved (`initialize` will refuse until `tia_range` matches; + measure the W/A factor again on the new range rather than assuming it carries over); +- the diode, its mount or the photodiode is replaced or realigned; +- the plane you want to quote power at changes; +- the verification (step 5) drifts by more than you can accept. + +It is also worth re-running step 5 every few months: it takes minutes and tells +you whether the old factor still holds. + +## Procedure + +**Safety first.** Every step below emits. Follow the rig's laser rules, keep the +beam off the sample (park the SLM or close the shutters), and put the power +meter head **before the fibre coupler**, at the plane you will quote power at. +Let the laser warm up for about 15 minutes at a mid current before measuring: +the photodiode's responsivity changes with temperature. + +All of steps 1–3 run in **open loop** (`ConstantCurrent`), where you command +the current and read the photocurrent, so nothing depends on a calibration you +do not have yet. + +```julia +using MicroscopeControl +laser = TCubeLaser("64849775"; mode = ConstantCurrent(), min_current = 0.0, max_current = 132.0) +initialize(laser) +``` + +### 1. Choose the TIA range + +The range must put the photocurrent well inside full scale at the highest power +you will use: not over range, and not so low that it is a few counts. + +1. Set the DIP switch to the least sensitive range (10 mA) and power-cycle the + controller if the manual asks for it. +2. Run the laser at the top of its operating range and read + `s = loop_status(laser)`. Look at `s.tia_range_A`, `s.photocurrent_A`, + `s.tia_over` and `s.tia_under`. +3. Step to the next more sensitive range (10 mA → 1 mA → 100 µA → 10 µA) until + the reading is between roughly 30 % and 90 % of full scale with neither flag + set. Use that range. + +```julia +setcurrent!(laser, 130.0); light_on(laser); sleep(1.0) +s = loop_status(laser) +(s.tia_range_A, s.photocurrent_A / s.tia_range_A, s.tia_over, s.tia_under) +light_off(laser) +``` + +### 2. Measure the threshold current + +Sweep the drive current across and above the threshold, read the power meter at +each point, and fit a straight line to the points clearly above threshold. The +threshold is where that line crosses zero power. Choose `min_current` (open +loop) and `min_power` (closed loop) a little **above** threshold: at exactly the +threshold the diode is at the knee and emits almost nothing, and a `min_power` +the diode cannot reach makes the loop drive the current to the clamp. + +### 3. Measure the W/A factor + +At several currents above threshold, record the power meter and the photocurrent +together. W/A is the slope of power (W) against photocurrent (A); fit it through +the origin, since zero light should give zero photocurrent. + +```julia +using Statistics +rows = NamedTuple[] +light_on(laser) +for I in 70.0:10.0:130.0 + setcurrent!(laser, I) + sleep(1.0) # settle; the readings are polled every 50 ms + s = loop_status(laser) + (s.tia_over || s.tia_under) && @warn "TIA flag set at $I mA: change the range (step 1)" + print("I = $I mA, photocurrent = $(round(s.photocurrent_A * 1e6; digits=2)) µA. Power meter (mW): ") + P = parse(Float64, readline()) + push!(rows, (I_mA = I, I_pd_A = s.photocurrent_A, P_mW = P)) +end +light_off(laser) + +x = [r.I_pd_A for r in rows] +y = [r.P_mW / 1000 for r in rows] # W +wa_calibration = sum(x .* y) / sum(x .^ 2) # least squares through the origin, W/A + +# threshold from the same data: power vs drive current, points above threshold +Is = [r.I_mA for r in rows]; Ps = [r.P_mW for r in rows] +slope = sum((Is .- mean(Is)) .* (Ps .- mean(Ps))) / sum((Is .- mean(Is)) .^ 2) +threshold_current = mean(Is) - mean(Ps) / slope + +shutdown(laser) +``` + +Check the residuals: if power against photocurrent is not a straight line, the +range is wrong (step 1) or the photodiode is saturating. + +### 4. Build the laser in closed loop + +Construct `TCubeLaser(...; mode = ConstantPhotocurrent(), ...)` with the three +numbers, as in the example above, choosing `properties.min_power` above the +power at threshold and `max_power` at or below the highest power you verified. +`initialize` checks that the controller reports the `tia_range` you stated. + +### 5. Verify + +In closed loop, command a set of powers across `[min_power, max_power]` with +`setoutputpower!`, and read the power meter at each. Record the table beside +this document's, with the date. Agreement within a few percent means the factor +holds; a constant offset is expected, a slope error means the factor is wrong. + +Watch `loop_status` while you do it: `saturated` means the loop hit the current +clamp (blocked or misaligned photodiode, or a power the diode cannot make), and +a drive current creeping up at a fixed setpoint means the photodiode or the +diode is changing. + +### 6. Optional: how much does the light drift? + +Closed loop holds the **photocurrent**, not the light. Without a +temperature-stabilised mount the photodiode's responsivity drifts with +temperature, so the delivered power can drift while every number the driver +reports stays flat. To measure how much, log the power meter (or a camera signal +from a fixed reflection) for 20 minutes from a cold start in closed loop. The +rig probe in MicroscopeAdapt, `dev/test_tcube_power.jl` (`:drift_log` and +`:closed_loop_trial` steps), automates this and the clamp and scan checks. +Set `tec_stabilised` from what you find. + +## What the controller's own W/A setting does + +`initialize` writes `wa_calibration` to the controller with +`LD_SetWACalibFactor`, so the front-panel display agrees with the driver. That +setting scales the **display** only; the loop regulates photocurrent regardless, +and this driver does its own conversion. diff --git a/src/hardware_implementations/tcube_laser/TCubeLaserControl.jl b/src/hardware_implementations/tcube_laser/TCubeLaserControl.jl index 31a6350..e0558df 100644 --- a/src/hardware_implementations/tcube_laser/TCubeLaserControl.jl +++ b/src/hardware_implementations/tcube_laser/TCubeLaserControl.jl @@ -15,6 +15,9 @@ using ...MicroscopeControl.HardwareImplementations.NIDAQcard import ...MicroscopeControl: export_state, initialize, shutdown import ...MicroscopeControl.HardwareInterfaces.LightSourceInterface: gui as red_laser_gui +# Not exported by the interface: the shared ceiling generic this driver extends, +# and the status-word layout it shares with its simulated twin. +import ...MicroscopeControl.HardwareInterfaces.LightSourceInterface: effective_max_current, STATUS_BITS const Thorlabs_Tcube_laser = "C:\\Program Files\\Thorlabs\\Kinesis\\Thorlabs.MotionControl.TCube.LaserDiode.dll" @@ -63,8 +66,11 @@ const Thorlabs_Tcube_laser = "C:\\Program Files\\Thorlabs\\Kinesis\\Thorlabs.Mot # plane: moving the DIP switch or replacing the diode invalidates them, and the # driver cannot read the W/A factor's provenance back. Closed loop regulates the # monitor photocurrent, so without a TEC-stabilised mount the delivered power -# can still drift with diode temperature. No closed-loop method is implemented -# here yet; the bindings remain in `functions_Tlaser.jl`. +# can still drift with diode temperature. +# +# How to measure these numbers again, and how to use them to build the laser in +# closed loop (`mode = ConstantPhotocurrent()`, `setoutputpower!`), is in +# `CALIBRATION.md` beside this file. include("constants_Tlaser.jl") include("functions_Tlaser.jl") @@ -73,7 +79,7 @@ include("interface_methods.jl") export TCubeLaser export red_laser_gui -export light_on, light_off, setpower, shutdown, tcube_get_current +export light_on, light_off, shutdown, tcube_get_current # `tcube_refresh` is an exported name from before v0.2.3 and stays one: the # method now throws and explains itself rather than vanishing into an # `UndefVarError`. See its docstring. diff --git a/src/hardware_implementations/tcube_laser/constants_Tlaser.jl b/src/hardware_implementations/tcube_laser/constants_Tlaser.jl index 4406eda..9206591 100644 --- a/src/hardware_implementations/tcube_laser/constants_Tlaser.jl +++ b/src/hardware_implementations/tcube_laser/constants_Tlaser.jl @@ -8,23 +8,71 @@ const __int64 = Clonglong const __int32 = Cint +""" + BOOL + +Four bytes. **Retained for `TLI_DeviceInfo`'s fields only**, which nothing in +this package calls — see that struct's docstring. Do not use it for a new +binding: use [`KBOOL_ARG`](@ref) for an argument and [`KBOOL_RET`](@ref) for a +return. + +`[limitation]` The vendor headers do **not** declare `BOOL` for these fields. +Two rigs have now read their own vendor-installed +`Thorlabs.MotionControl.TCube.LaserDiode.h` — Kinesis 1.14.10 on the seq-sr rig +and 1.14.47.22504 on quickbeam — and **both declare lowercase C++ `bool` with +`#pragma pack(1)` active**. This `Cuint` is therefore wrong for +`TLI_DeviceInfo`, and is left in place only because no single layout fits both +versions anyway (they disagree on `serialNo`'s length), so there is nothing to +change it *to*. + +**History, because this was got wrong twice in opposite directions.** v0.2.3 +retyped both boolean roles to a one-byte `Bool`. A copy of the header on the lab +NAS appeared to contradict that, carrying `typedef unsigned int BOOL` and no +lowercase `bool` at all, and on its strength the change was reverted — but that +file is a Clang.jl generation input, hand-edited to parse without the Windows +SDK, and its typedefs are artefacts of the editing rather than the vendor's ABI. +Splitting the two roles is the correct answer, and the vendor-installed headers +since read off both rigs confirm it. +""" const BOOL = Cuint """ - CPPBOOL - -Julia's `Bool`, i.e. one byte, for the Kinesis entry points the header declares -as C++ `bool` rather than as a Windows `BOOL`. The distinction is not cosmetic: -a `bool` return sets only the low byte of the return register, so reading it as -a 4-byte `BOOL` reads three bytes of whatever happened to be there, and -`LD_CheckConnection(...) != 0` can then report a disconnected controller as -connected. Reported by the 642 nm rig from the Kinesis header, 2026-09-23; the -same file's `tcubeapi.jl` already used `::Bool` for `LD_StartPolling` and -`LD_StopPolling`, so the package disagreed with itself. Not hardware-verified. - -`BOOL` above stays `Cuint` for the genuine Windows `BOOL` uses. + KBOOL_ARG + +The type to pass a Kinesis C++ `bool` ARGUMENT: a zero-extended `Cuint` +carrying exactly 0 or 1. + +This is robust whichever width the callee really reads. A `bool` callee takes +the low byte and sees 0 or 1; a `BOOL` callee takes all four and sees 0 or 1. +Passing a 1-byte `Bool` is NOT robust in this direction: the upper three bytes +of the register are undefined, so a `false` can arrive as true. That matters +for `LD_EnableMaxCurrentAdjust(serialNo, enableAdjust, enableDiode)`, whose +second flag enables the laser diode during a max-current adjustment. +""" +const KBOOL_ARG = Cuint + +""" + KBOOL_RET + +The type to read a Kinesis C++ `bool` RETURN: one byte. + +The installed vendor header declares these `bool`, which on MSVC x86-64 +returns in `AL` and leaves the rest of `EAX` **undefined**. Reading four bytes +can therefore turn a `false` into a nonzero value — `LD_CheckConnection` +reporting a disconnected controller as connected. Reading the low byte is +correct under the `bool` ABI and still correct under a `BOOL` ABI returning +0 or 1. + +**History, because this was got wrong twice.** v0.2.3 retyped both roles to a +1-byte `Bool`, which was right for returns and wrong for arguments. A copy of +the header on the lab NAS appeared to contradict it — but that copy is a +Clang.jl generation input, hand-edited to parse without Windows headers: its +`typedef unsigned int BOOL` and its commented-out `#pragma pack` are artefacts +of that editing, not the vendor's ABI. The installed header has 32 lowercase +`bool`, zero `BOOL`, and an active `#pragma pack(1)`. Splitting the two roles +is what is actually correct, and is safe under either reading. """ -const CPPBOOL = Bool +const KBOOL_RET = Bool struct tagSAFEARRAYBOUND cElements::Culong @@ -67,22 +115,39 @@ end """ TLI_DeviceInfo -**[limitation] This layout is suspect and unverified.** The Kinesis header -declares the `is*` fields as C++ `bool` (one byte), and they are typed here as -`BOOL` = `Cuint` (four). If that is right, every field from `isKnownType` -onward is misaligned and `TLI_GetDeviceInfo` returns nonsense. Nothing in this -package calls `TLI_GetDeviceInfo`, so the defect is latent rather than active, -and it is left alone as deferred ABI repair rather than corrected in passing. - -Correcting it is more than retyping the five fields, and does NOT need a -controller -- it needs the vendor header, which this repo does not carry. The -header declares the structure `#pragma pack(1)` at 100 bytes; this declaration -is 120, and the divergence starts at `PID`, *before* the first `bool`. So -retyping the booleans alone would leave it wrong. Repair it against the header, -with the size asserted, or do not call it. The function signatures in -`functions_Tlaser.jl` had a related discrepancy and WERE corrected -- see -`CPPBOOL` -- because there the fix moves no field and the ABI rule is -unambiguous. +**[limitation] This layout does not match either Kinesis version we have +looked at, and it is left alone deliberately, because there is no single +layout that would match both.** + +Two rigs read their installed headers and reported different declarations: + +| | Kinesis 1.14.10 (seq-sr rig) | Kinesis 1.14.47.22504 (quickbeam) | +|---|---|---| +| `serialNo` | `char serialNo[9]` | `char serialNo[16]` | +| flags | 1-byte C++ `bool` | 1-byte C++ `bool` | +| `#pragma pack(1)` | ACTIVE (lines 63-125) | ACTIVE | +| reported size | — | 100 bytes, `PID` at 85 | + +This declaration uses `NTuple{16,Cchar}`, 4-byte `BOOL` flags and default +alignment, measuring **120 bytes with `PID` at offset 88** (verified by +execution). It is wrong for both, and the field that differs between the two +vendor versions is an array LENGTH, so no reinterpretation fixes both at once. + +`[policy]` Do not call `TLI_GetDeviceInfo` through this declaration. Nothing +in this package does — `initialize` uses only `TLI_BuildDeviceList` and +`TLI_GetDeviceListSize`, both of which return counts and touch no struct +(verified). If a caller ever needs device info, declare the struct for the +SDK version in use, assert `sizeof`, and keep it beside the header it was +read from. + +**One copy of this header on the lab NAS contradicts all of the above; do not +trust it.** `Personal Folders/Sheng/code/generate_lib/lib/` holds a Clang.jl +generation input, hand-edited to parse without Windows headers: local +typedefs including `typedef unsigned int BOOL`, `OaIdl.h` and `__declspec` +stripped, both pack pragmas commented out, every lowercase `bool` rewritten. +Those are artefacts of the editing. This docstring briefly asserted, on the +strength of that file, that our layout was correct; it is not, and the round +trip cost two wrong rulings in opposite directions. """ struct TLI_DeviceInfo typeID::DWORD diff --git a/src/hardware_implementations/tcube_laser/functions_Tlaser.jl b/src/hardware_implementations/tcube_laser/functions_Tlaser.jl index 0680825..7d77243 100644 --- a/src/hardware_implementations/tcube_laser/functions_Tlaser.jl +++ b/src/hardware_implementations/tcube_laser/functions_Tlaser.jl @@ -55,7 +55,7 @@ function LD_Close(serialNo) end function LD_CheckConnection(serialNo) - ccall((:LD_CheckConnection, Thorlabs_Tcube_laser), CPPBOOL, (Ptr{Cchar},), serialNo) + ccall((:LD_CheckConnection, Thorlabs_Tcube_laser), KBOOL_RET, (Ptr{Cchar},), serialNo) end function LD_Identify(serialNo) @@ -79,15 +79,15 @@ function LD_GetSoftwareVersion(serialNo) end function LD_LoadSettings(serialNo) - ccall((:LD_LoadSettings, Thorlabs_Tcube_laser), CPPBOOL, (Ptr{Cchar},), serialNo) + ccall((:LD_LoadSettings, Thorlabs_Tcube_laser), KBOOL_RET, (Ptr{Cchar},), serialNo) end function LD_LoadNamedSettings(serialNo, settingsName) - ccall((:LD_LoadNamedSettings, Thorlabs_Tcube_laser), CPPBOOL, (Ptr{Cchar}, Ptr{Cchar}), serialNo, settingsName) + ccall((:LD_LoadNamedSettings, Thorlabs_Tcube_laser), KBOOL_RET, (Ptr{Cchar}, Ptr{Cchar}), serialNo, settingsName) end function LD_PersistSettings(serialNo) - ccall((:LD_PersistSettings, Thorlabs_Tcube_laser), CPPBOOL, (Ptr{Cchar},), serialNo) + ccall((:LD_PersistSettings, Thorlabs_Tcube_laser), KBOOL_RET, (Ptr{Cchar},), serialNo) end function LD_Disable(serialNo) @@ -111,11 +111,11 @@ function LD_MessageQueueSize(serialNo) end function LD_GetNextMessage(serialNo, messageType, messageID, messageData) - ccall((:LD_GetNextMessage, Thorlabs_Tcube_laser), CPPBOOL, (Ptr{Cchar}, Ptr{WORD}, Ptr{WORD}, Ptr{DWORD}), serialNo, messageType, messageID, messageData) + ccall((:LD_GetNextMessage, Thorlabs_Tcube_laser), KBOOL_RET, (Ptr{Cchar}, Ptr{WORD}, Ptr{WORD}, Ptr{DWORD}), serialNo, messageType, messageID, messageData) end function LD_WaitForMessage(serialNo, messageType, messageID, messageData) - ccall((:LD_WaitForMessage, Thorlabs_Tcube_laser), CPPBOOL, (Ptr{Cchar}, Ptr{WORD}, Ptr{WORD}, Ptr{DWORD}), serialNo, messageType, messageID, messageData) + ccall((:LD_WaitForMessage, Thorlabs_Tcube_laser), KBOOL_RET, (Ptr{Cchar}, Ptr{WORD}, Ptr{WORD}, Ptr{DWORD}), serialNo, messageType, messageID, messageData) end function LD_SetOpenLoopMode(serialNo) @@ -127,7 +127,7 @@ function LD_SetClosedLoopMode(serialNo) end function LD_EnableMaxCurrentAdjust(serialNo, enableAdjust, enableDiode) - ccall((:LD_EnableMaxCurrentAdjust, Thorlabs_Tcube_laser), Cshort, (Ptr{Cchar}, CPPBOOL, CPPBOOL), serialNo, enableAdjust, enableDiode) + ccall((:LD_EnableMaxCurrentAdjust, Thorlabs_Tcube_laser), Cshort, (Ptr{Cchar}, KBOOL_ARG, KBOOL_ARG), serialNo, enableAdjust, enableDiode) end function LD_RequestMaxCurrentDigPot(serialNo) @@ -147,7 +147,7 @@ function LD_FindTIAGain(serialNo) end function LD_EnableTIAGainAdjust(serialNo, enable) - ccall((:LD_EnableTIAGainAdjust, Thorlabs_Tcube_laser), Cshort, (Ptr{Cchar}, CPPBOOL), serialNo, enable) + ccall((:LD_EnableTIAGainAdjust, Thorlabs_Tcube_laser), Cshort, (Ptr{Cchar}, KBOOL_ARG), serialNo, enable) end function LD_DisableOutput(serialNo) @@ -218,16 +218,22 @@ function LD_RequestStatusBits(serialNo) ccall((:LD_RequestStatusBits, Thorlabs_Tcube_laser), Cshort, (Ptr{Cchar},), serialNo) end +# Signed in practice, whatever the header says: on the 642 nm rig's TLD001 +# (64849775, 2026-09-28) the reading with the output off was 65533 as a WORD, +# i.e. -3, a small negative offset. Read unsigned it decoded to twice full scale. function LD_GetPhotoCurrentReading(serialNo) - ccall((:LD_GetPhotoCurrentReading, Thorlabs_Tcube_laser), WORD, (Ptr{Cchar},), serialNo) + ccall((:LD_GetPhotoCurrentReading, Thorlabs_Tcube_laser), Cshort, (Ptr{Cchar},), serialNo) end function LD_GetVoltageReading(serialNo) ccall((:LD_GetVoltageReading, Thorlabs_Tcube_laser), WORD, (Ptr{Cchar},), serialNo) end +# Signed, unlike the other readings: the vendor header gives the diode current +# as ±32767 = ±220 mA. Read as an unsigned WORD, a negative reading decoded to +# roughly 440 mA -- twice full scale, reported as a plausible-looking number. function LD_GetLaserDiodeCurrentReading(serialNo) - ccall((:LD_GetLaserDiodeCurrentReading, Thorlabs_Tcube_laser), WORD, (Ptr{Cchar},), serialNo) + ccall((:LD_GetLaserDiodeCurrentReading, Thorlabs_Tcube_laser), Cshort, (Ptr{Cchar},), serialNo) end function LD_RequestLaserDiodeMaxCurrentLimit(serialNo) @@ -267,7 +273,7 @@ function LD_GetStatusBits(serialNo) end function LD_StartPolling(serialNo, milliseconds) - ccall((:LD_StartPolling, Thorlabs_Tcube_laser), CPPBOOL, (Ptr{Cchar}, Cint), serialNo, milliseconds) + ccall((:LD_StartPolling, Thorlabs_Tcube_laser), KBOOL_RET, (Ptr{Cchar}, Cint), serialNo, milliseconds) end function LD_PollingDuration(serialNo) @@ -279,15 +285,15 @@ function LD_StopPolling(serialNo) end function LD_TimeSinceLastMsgReceived(serialNo, arg2) - ccall((:LD_TimeSinceLastMsgReceived, Thorlabs_Tcube_laser), CPPBOOL, (Ptr{Cchar}, __int64), serialNo, arg2) + ccall((:LD_TimeSinceLastMsgReceived, Thorlabs_Tcube_laser), KBOOL_RET, (Ptr{Cchar}, __int64), serialNo, arg2) end function LD_EnableLastMsgTimer(serialNo, enable, lastMsgTimeout) - ccall((:LD_EnableLastMsgTimer, Thorlabs_Tcube_laser), Cvoid, (Ptr{Cchar}, CPPBOOL, __int32), serialNo, enable, lastMsgTimeout) + ccall((:LD_EnableLastMsgTimer, Thorlabs_Tcube_laser), Cvoid, (Ptr{Cchar}, KBOOL_ARG, __int32), serialNo, enable, lastMsgTimeout) end function LD_HasLastMsgTimerOverrun(serialNo) - ccall((:LD_HasLastMsgTimerOverrun, Thorlabs_Tcube_laser), CPPBOOL, (Ptr{Cchar},), serialNo) + ccall((:LD_HasLastMsgTimerOverrun, Thorlabs_Tcube_laser), KBOOL_RET, (Ptr{Cchar},), serialNo) end function LD_RequestSettings(serialNo) diff --git a/src/hardware_implementations/tcube_laser/interface_methods.jl b/src/hardware_implementations/tcube_laser/interface_methods.jl index 465f9ca..1e383ea 100644 --- a/src/hardware_implementations/tcube_laser/interface_methods.jl +++ b/src/hardware_implementations/tcube_laser/interface_methods.jl @@ -7,6 +7,9 @@ Thorlabs error code; every call site below used to assign that status to an `err` local and never read it, so a failed open, a failed mode change and a failed setpoint were all indistinguishable from success. +Never point this at a boolean return ([`KBOOL_RET`](@ref)): those are success +FLAGS where `false` means failure, the opposite sense. See [`check_flag`](@ref). + Not hardware-verified (no TCube on the build machine): the `0 == success` convention is the documented Kinesis one, not something this repo has observed. """ @@ -16,42 +19,45 @@ function check_err(err, operation::AbstractString, serialNo::AbstractString) end """ - legacy_power(light::TCubeLaser, current::Float64) + check_flag(ok, operation::AbstractString, serialNo::AbstractString) -The value `properties.power` has held since before v0.2.3: the requested -current scaled linearly onto `properties.max_power`. **Deprecated, and -scheduled for removal in 0.3.0** -- read `light.drive_current` instead. +Throw unless a Kinesis call that returns a C++ `bool` success flag returned +`true`. The counterpart of [`check_err`](@ref) for the opposite convention. +""" +function check_flag(ok, operation::AbstractString, serialNo::AbstractString) + ok === true || error("TCubeLaser $serialNo: $operation reported failure (returned $(repr(ok)))") + return nothing +end -It is a guess, not a measurement. The controller reports no optical power in -the open-loop mode this driver uses, and the bench table preserved in -`TCubeLaserControl.jl` shows the real curve is not this line. It is reproduced -here only so that a rig reading `properties.power` across an upgrade reads the -same number it read before. +""" + REQUEST_WAIT_S -Reproducing it exactly needs one step the old expression did not: it divided by -`light.max_current`, and `initialize` used to overwrite that field with the -controller's limit. `max_current` is now the caller's and stays so, so the -divisor is `controller_max_current` once `initialize` has read it and -`max_current` before that -- which is the same quantity the old field held at -each of those two moments. +Seconds to wait between an `LD_Request*` call and the `LD_Get*` that reads its +answer. The Kinesis getters return a value cached by the DLL, and the request +refreshes it asynchronously; 0.1 s is what this driver has always waited. +A `Ref` so the test suite can set it to zero. Not hardware-verified. +""" +const REQUEST_WAIT_S = Ref(0.1) -**One case where this deliberately does not reproduce 0.2.2.** If a caller -assigns `max_current` *after* `initialize`, 0.2.2 divided by the newly assigned -value; this divides by the controller's limit still. With `max_current = 80.0` -and a 40 mA request after a controller limit of 23830, 0.2.2 gave `50.0` and -this gives `25.000572235150496`. Reproducing it would mean intercepting writes -to the field, which is not worth doing for a number the driver invents and -which 0.3.0 removes. Read `drive_current` instead; it is exact and has no -lifecycle. """ -function legacy_power(light::TCubeLaser, current::Float64) - divisor = isnan(light.controller_max_current) ? light.max_current : light.controller_max_current - return current * light.properties.max_power / divisor -end +""" + POLL_INTERVAL_MS + +The Kinesis background polling period `initialize` starts, in ms (20 Hz), so +that [`measured_current`](@ref), [`measured_photocurrent`](@ref) and +[`loop_status`](@ref) are single cache reads, at most this old. A documented +constant rather than a keyword: 20 Hz covers the 1-10 Hz a rig logs at. + +`[limitation]` that `LD_StartPolling` refreshes the reading caches at this +period is read from the Kinesis header, not observed on hardware. If it does +not, those getters return stale values; `tcube_get_current` issues its own +request and is the fallback. +""" +const POLL_INTERVAL_MS = 50 """ effective_max_current(light::TCubeLaser) -The ceiling `setpower` enforces, in mA: the smallest of the caller's +The ceiling `setcurrent!` enforces, in mA: the smallest of the caller's `max_current`, the controller's `controller_max_current` (skipped while it is `NaN`, i.e. before `initialize`) and `max_setcurrent`, the full-scale current of the setpoint DAC. @@ -59,10 +65,10 @@ of the setpoint DAC. `max_setcurrent` is in the list because a current above full scale has no legal setpoint: see [`SETPOINT_PROTOCOL_MAX`](@ref) for why the bound is the controller's 0-32767 protocol range and not `UInt16` storage. Without it, -`setpower` would carry a request the range check had already passed into a +`setcurrent!` would carry a request the range check had already passed into a conversion that cannot express it. """ -function effective_max_current(light::TCubeLaser) +function LightSourceInterface.effective_max_current(light::TCubeLaser) limits = filter(!isnan, (light.max_current, light.controller_max_current, light.max_setcurrent)) isempty(limits) && error("TCubeLaser $(light.serialNo): no usable current ceiling; max_current, controller_max_current and max_setcurrent are all NaN") return minimum(limits) @@ -91,10 +97,10 @@ Decode a controller setpoint back to a current in mA, the inverse of One definition, used by every place in this driver that turns a raw controller word into mA -- the setpoint check in `setpoint_code`, the limit read in -[`record_controller_limit!`](@ref) and the reading in -[`tcube_get_current`](@ref). `setpoint_code`'s guarantee is stated *in terms of -this function*, so it has to be the same arithmetic in every one of them and -not three copies of the same expression. +[`record_controller_limit!`](@ref) and the readings in +[`measured_current`](@ref) and [`tcube_get_current`](@ref). `setpoint_code`'s +guarantee is stated *in terms of this function*, so it has to be the same +arithmetic in every one of them and not several copies of the same expression. """ setpoint_current(light::TCubeLaser, code) = Float64(code) / light.max_setpoint * light.max_setcurrent @@ -140,8 +146,9 @@ non-negative. In practice it runs at most once. The cost of the whole rule is an undershoot of approximately one code, about 0.0067 mA at the default scale -- "approximately" because the downward -correction can take a boundary predecessor a hair past one code width. At the bottom of the range that undershoot is the entire -request: see [`setpower`](@ref). +correction can take a boundary predecessor a hair past one code width. At the +bottom of the range that undershoot is the entire request: see +[`setcurrent!`](@ref). """ function setpoint_code(light::TCubeLaser, current::Float64) (isfinite(light.max_setcurrent) && light.max_setcurrent > 0) || throw(ArgumentError( @@ -168,15 +175,15 @@ end Validate a requested drive current in mA, throwing an `ArgumentError` naming the request and both bounds if it is out of range. Returns `current`. -This is the whole of `setpower`'s safety check, kept as its own function so it -can be exercised without a controller attached. It runs *before* any setpoint -is computed or sent: the previous code logged `@error` and then carried on to -call `LD_SetLaserSetPoint` anyway. What that cost depended on the request. 500 -mA on a 160 mA diode never reached the controller -- it logged, continued, and -then died in the conversion, `UInt16(round(...))` with an `InexactError`. 200 -mA did: over the same 160 mA ceiling, but inside the setpoint DAC's range, so -it converted cleanly and was sent. A log line was the only thing separating the -two. +This is the whole of `setcurrent!`'s safety check, kept as its own function so +it can be exercised without a controller attached. It runs *before* any +setpoint is computed or sent: the code before v0.2.3 logged `@error` and then +carried on to call `LD_SetLaserSetPoint` anyway. What that cost depended on the +request. 500 mA on a 160 mA diode never reached the controller -- it logged, +continued, and then died in the conversion, `UInt16(round(...))` with an +`InexactError`. 200 mA did: over the same 160 mA ceiling, but inside the +setpoint DAC's range, so it converted cleanly and was sent. A log line was the +only thing separating the two. """ function check_current(light::TCubeLaser, current::Float64) lo = light.min_current @@ -205,20 +212,351 @@ function record_controller_limit!(light::TCubeLaser, raw) return light.controller_max_current end +# --------------------------------------------------------------------------- +# Closed-loop (ConstantPhotocurrent) arithmetic +# --------------------------------------------------------------------------- + +""" + TLD001_TIA_RANGES + +The TLD001's photodiode amplifier ranges, full scale in A: 10 µA, 100 µA, 1 mA +and 10 mA. Selected only by the rear-panel DIP switches; software can read the +range (status bits `0x10`-`0x80`) but not set it. From the TLD001 manual and +the Kinesis header; not hardware-verified. +""" +const TLD001_TIA_RANGES = (10e-6, 100e-6, 1e-3, 10e-3) + +""" + DIGPOT_MIN_POS, DIGPOT_MAX_POS, DIGPOT_STEP_ESTIMATE_mA + +The TLD001's max-current potentiometer: positions 20..255. The Kinesis header +gives its scale as `position * 220 / 255` mA, and **the controller does not +follow it**: on the 642 nm rig's TLD001 (64849775, 2026-09-28) position 204 gave +a limit of 160.74 mA and 194 gave 152.43 mA -- about 0.83 mA per step, where the +header's scale says 176 and 167. So no clamp value is ever computed from a +position. [`program_clamp!`](@ref) reads the controller's own limit after each +setting. The header's step, 220/255 ≈ 0.863 mA, is kept only as an estimate to +choose the next position from: it is larger than the observed step, so +estimated moves fall short and the search approaches the ceiling from one side. +""" +const DIGPOT_MIN_POS = 20 +const DIGPOT_MAX_POS = 255 +const DIGPOT_STEP_ESTIMATE_mA = 220.0 / 255 + +""" + DIGPOT_MIN_mA + +The lowest clamp the header's scale allows, `20 * 220 / 255` = 17.25 mA. A +diode whose ceiling is below it cannot be clamped, so it cannot be built in +power mode. (In adjust mode the rig's controller reported 16.74 mA at position +20, so this is a conservative floor.) +""" +const DIGPOT_MIN_mA = DIGPOT_MIN_POS * 220.0 / 255 + +""" + CLAMP_WAIT_S + +Seconds to wait around each step of a potentiometer change. A `Ref` so the test +suite can set it to zero. On the rig's TLD001, leaving adjust mode immediately +after `LD_SetMaxCurrentDigPot` left the limit unchanged; with 0.5 s between the +steps it latched. +""" +const CLAMP_WAIT_S = Ref(0.5) + +"Fresh read of the controller's diode current limit, in mA." +function read_limit_mA(light::TCubeLaser) + serialNo = light.serialNo + check_err(LD_RequestLaserDiodeMaxCurrentLimit(serialNo), "LD_RequestLaserDiodeMaxCurrentLimit", serialNo) + sleep(REQUEST_WAIT_S[]) + return setpoint_current(light, LD_GetLaserDiodeMaxCurrentLimit(serialNo)) +end + +"Fresh read of the potentiometer position." +function read_digpot(serialNo::AbstractString) + check_err(LD_RequestMaxCurrentDigPot(serialNo), "LD_RequestMaxCurrentDigPot", serialNo) + sleep(REQUEST_WAIT_S[]) + return Int(LD_GetMaxCurrentDigPot(serialNo)) +end + +""" + set_digpot!(light::TCubeLaser, position) + +Set the potentiometer and return the controller's resulting limit in mA: +`LD_EnableMaxCurrentAdjust(true, false)`, wait, `LD_SetMaxCurrentDigPot`, wait, +confirm the position reads back, `LD_EnableMaxCurrentAdjust(false, false)`, +wait, read the limit. Adjust mode is always left, even on failure. The diode +flag is always `false`. The output must already be off: entering adjust mode +drops the limit to its minimum until the position is set. +""" +function set_digpot!(light::TCubeLaser, position::Int) + serialNo = light.serialNo + check_err(LD_EnableMaxCurrentAdjust(serialNo, true, false), "LD_EnableMaxCurrentAdjust", serialNo) + try + sleep(CLAMP_WAIT_S[]) + check_err(LD_SetMaxCurrentDigPot(serialNo, UInt16(position)), "LD_SetMaxCurrentDigPot", serialNo) + sleep(CLAMP_WAIT_S[]) + readback = read_digpot(serialNo) + readback == position || error( + "TCubeLaser $serialNo: set the max-current potentiometer to $(position) but it reads back as $(readback)") + finally + check_err(LD_EnableMaxCurrentAdjust(serialNo, false, false), "LD_EnableMaxCurrentAdjust", serialNo) + end + sleep(CLAMP_WAIT_S[]) + return read_limit_mA(light) +end + +""" + program_clamp!(light::TCubeLaser{ConstantPhotocurrent}) + +Leave the controller's diode current limit at the highest potentiometer position +whose limit, **as the controller reports it**, does not exceed +`light.max_current`, and return that limit in mA. + +The search starts from the current position. It moves by the header's step +estimate ([`DIGPOT_STEP_ESTIMATE_mA`](@ref)), which is larger than the real step +on the rig's controller, so moves fall short and approach the ceiling from one +side. It never needs more than a few settings, and none if the present position +already qualifies. Throws if even the lowest position is above the ceiling, or if +it cannot settle. Output must be off (it is, in `initialize`). +""" +function program_clamp!(light::TCubeLaser{ConstantPhotocurrent}) + ceiling = light.max_current + pos = read_digpot(light.serialNo) + limit = read_limit_mA(light) + below = nothing # (position, limit) of the best setting found at or under the ceiling + above = nothing # lowest position found over the ceiling + # Settle on the best position found under the ceiling, re-setting it if the + # search last left the potentiometer somewhere else. + function settle() + bpos, blimit = below + if bpos != pos + blimit = set_digpot!(light, bpos) + blimit <= ceiling || error( + "TCubeLaser $(light.serialNo): the max-current limit read $(blimit) mA on re-setting position $(bpos), above max_current = $(ceiling) mA") + end + return blimit + end + for _ in 1:16 + if limit <= ceiling + below = (pos, limit) + # Up only by whole estimated steps; none fits -> this is it. + steps = floor(Int, (ceiling - limit) / DIGPOT_STEP_ESTIMATE_mA) + next = min(pos + steps, DIGPOT_MAX_POS) + above !== nothing && (next = min(next, above - 1)) + next <= pos && return settle() + else + above = above === nothing ? pos : min(above, pos) + pos == DIGPOT_MIN_POS && error( + "TCubeLaser $(light.serialNo): even the lowest potentiometer position gives a limit of $(limit) mA, above max_current = $(ceiling) mA") + next = max(pos - max(1, ceil(Int, (limit - ceiling) / DIGPOT_STEP_ESTIMATE_mA)), DIGPOT_MIN_POS) + below !== nothing && next <= below[1] && return settle() + end + pos = next + limit = set_digpot!(light, pos) + end + error("TCubeLaser $(light.serialNo): the max-current clamp did not settle under max_current = $(ceiling) mA") +end + +""" + photocurrent_from_code(light::TCubeLaser{ConstantPhotocurrent}, code) + +Decode a closed-loop setpoint or photocurrent reading to amps: `0..max_setpoint` +spans `0..pd.tia_range`. The one definition [`setoutputpower!`](@ref), +[`measured_photocurrent`](@ref) and [`photocurrent_code`](@ref) share. +""" +photocurrent_from_code(light::TCubeLaser, code, tia_range::Float64) = + Float64(code) / light.max_setpoint * tia_range + +""" + photocurrent_code(light::TCubeLaser{ConstantPhotocurrent}, photocurrent_A) + +Encode a photocurrent in A as the closed-loop setpoint, with the same guarantee +as [`setpoint_code`](@ref): the decoded photocurrent never exceeds the request. +Throws, naming the DIP switch, if the request is above the amplifier's full +scale: no software setting can reach it. +""" +function photocurrent_code(light::TCubeLaser{ConstantPhotocurrent}, photocurrent_A::Float64) + range = light.pd.tia_range + (isfinite(photocurrent_A) && photocurrent_A >= 0) || throw(ArgumentError( + "TCubeLaser $(light.serialNo): photocurrent must be finite and non-negative, got $(photocurrent_A) A")) + code = floor(photocurrent_A / range * light.max_setpoint) + code <= light.max_setpoint || throw(ArgumentError( + "TCubeLaser $(light.serialNo): $(photocurrent_A) A of photocurrent is above the amplifier's full scale of $(range) A. " * + "No software setting can reach it: select a less sensitive range on the rear-panel DIP switch and state it in tia_range.")) + while code > 0 && photocurrent_from_code(light, code, range) > photocurrent_A + code -= 1.0 + end + return UInt16(code) +end + +""" + check_power(light::TCubeLaser{ConstantPhotocurrent}, power_mW::Float64) + +Validate a requested output power against `properties.min_power..max_power`, +throwing an `ArgumentError` naming the request and both bounds. Returns +`power_mW`. The power-mode counterpart of [`check_current`](@ref). +""" +function check_power(light::TCubeLaser{ConstantPhotocurrent}, power_mW::Float64) + lo, hi = light.properties.min_power, light.properties.max_power + (isfinite(power_mW) && lo <= power_mW <= hi) || throw(ArgumentError( + "TCubeLaser $(light.serialNo): requested output power $(power_mW) mW is outside the allowed range " * + "[$(lo), $(hi)] mW at the laser output (properties.min_power, properties.max_power)")) + return power_mW +end + +# --------------------------------------------------------------------------- +# Verified reads, for the one-off checks in `initialize` and the setters +# --------------------------------------------------------------------------- + +"Request, wait, then read the status word: a fresh read, not the polled cache." +function read_status_fresh(serialNo::AbstractString) + check_err(LD_RequestStatusBits(serialNo), "LD_RequestStatusBits", serialNo) + sleep(REQUEST_WAIT_S[]) + return UInt32(LD_GetStatusBits(serialNo)) +end + +""" +# The setpoint only takes while the output is ON + +Hardware-verified on the 642 nm rig's TLD001 (64849775, 2026-09-28), and the +single most important fact about this controller: + +- `LD_SetLaserSetPoint` sent while the output is **disabled** is **ignored**. + On the next `LD_EnableOutput` the controller runs on whatever setpoint it had + stored -- on the rig, a word above full scale, so the diode went straight to + its current limit (~160 mA, 85-88 mW measured) whatever had been "set". +- Sent while the output is **enabled**, it takes within about one poll: 70 mA + commanded, 70.0 mA on the front display, no limit flag. +- `LD_GetLaserSetPoint` reports the controller's setpoint only while the output + is enabled (it read back 10424 for 10425 sent, one code low). With the output + off it returns a stale word unrelated to anything sent. + +So this driver never sends a setpoint with the output off. `setcurrent!` and +`setoutputpower!` record the request and, if the output is on, send and confirm +it; if it is off, the request is applied by `light_on` immediately after it +enables the output. `light_off` zeroes the setpoint BEFORE disabling, so the +controller's stored setpoint is 0 and the next enable starts dark rather than +at a stale value. + +`[limitation]` between `LD_EnableOutput` and the setpoint that follows it +(a few ms, one USB round trip) the controller runs on its stored setpoint. After +a `light_off` from this driver that is 0; on a controller last left by other +software it may be anything up to full scale, bounded only by the current limit +(the max-current clamp in power mode). +""" +const SETPOINT_NEEDS_OUTPUT = true + +""" + SETPOINT_CONFIRM_TIMEOUT_S, SETPOINT_READBACK_TOLERANCE + +How long [`send_setpoint`](@ref) waits for the polled setpoint to show the code +it sent, in seconds (a `Ref` so the test suite can shorten it), and how many +codes the read-back may differ by: the rig's TLD001 reported 10424 for 10425. +""" +const SETPOINT_CONFIRM_TIMEOUT_S = Ref(1.0) +const SETPOINT_READBACK_TOLERANCE = 2 + +"Whether the controller reports its output enabled (polled status word)." +output_enabled(serialNo::AbstractString) = UInt32(LD_GetStatusBits(serialNo)) & STATUS_BITS.output_enabled != 0 + +""" + send_setpoint(light::TCubeLaser, code::UInt16) + +Send a setpoint **with the output on** and wait for the controller to report it +back within [`SETPOINT_READBACK_TOLERANCE`](@ref) codes, throwing if it does not. +Only call it with the output enabled: see [`SETPOINT_NEEDS_OUTPUT`](@ref). +Nothing is recorded by this function. +""" +function send_setpoint(light::TCubeLaser, code::UInt16) + serialNo = light.serialNo + check_err(LD_SetLaserSetPoint(serialNo, code), "LD_SetLaserSetPoint", serialNo) + close_enough(r) = abs(Int(r) - Int(code)) <= SETPOINT_READBACK_TOLERANCE + deadline = time() + SETPOINT_CONFIRM_TIMEOUT_S[] + readback = LD_GetLaserSetPoint(serialNo) + while !close_enough(readback) && time() < deadline + sleep(0.02) + readback = LD_GetLaserSetPoint(serialNo) + end + close_enough(readback) || error( + "TCubeLaser $serialNo: sent setpoint $(code) but the controller reports $(readback) after $(SETPOINT_CONFIRM_TIMEOUT_S[]) s") + return nothing +end + +""" + intended_code(light::TCubeLaser) + +The setpoint the driver's recorded request asks for: `setpoint_code(drive_current)` +in `ConstantCurrent` mode, `photocurrent_code(output_power_requested)` in +`ConstantPhotocurrent` mode, and 0 when nothing has been requested. What +`light_on` sends right after enabling the output. +""" +intended_code(light::TCubeLaser{ConstantCurrent}) = + isnan(light.drive_current) ? UInt16(0) : setpoint_code(light, light.drive_current) +intended_code(light::TCubeLaser{ConstantPhotocurrent}) = + isnan(light.pd.output_power_requested) ? UInt16(0) : + photocurrent_code(light, light.pd.output_power_requested / 1000 / light.pd.wa_calibration) + +# --------------------------------------------------------------------------- +# Lifecycle +# --------------------------------------------------------------------------- + """ initialize(light::TCubeLaser) -Open the controller, put it in open-loop mode and record the controller's own -diode current limit in `light.controller_max_current`. +Open the controller, start background polling ([`POLL_INTERVAL_MS`](@ref)), +put it in the laser's [`RegulationMode`](@ref), and record the controller's own +diode current limit in `light.controller_max_current`. It never enables the +output. Polling starts first because the setpoint read-back that every verified +write depends on only refreshes through it (see +[`SETPOINT_CONFIRM_TIMEOUT_S`](@ref)). + +`ConstantCurrent`: `LD_SetOpenLoopMode`, then the limit read. + +`ConstantPhotocurrent` -- the closed-loop entry sequence. Every step is a +refusal that cannot be retrofitted after a diode is damaged, so none may be +simplified away: + +1. Read the status bits. Require the key switch (`0x2`) and the interlock + (`0x8`); throw naming which is missing. +2. Decode the photodiode amplifier range from `0x10`-`0x80`: exactly one bit, or + throw; and throw if it disagrees with `pd.tia_range`, naming both and the + rear-panel switch. A range moved between sessions is a silent factor-of-ten + error in every commanded power. +3. Disable the output. (The setpoint is not zeroed here: the controller + ignores setpoints while the output is off, see + [`SETPOINT_NEEDS_OUTPUT`](@ref). `light_on` sends the intended photocurrent + setpoint immediately after enabling, and the clamp bounds the moment in + between.) +4. Program the clamp, **the only real protection in power mode**, because the + loop raises current by itself to hold its setpoint (and a blocked photodiode + drives it straight to the clamp): [`program_clamp!`](@ref) leaves the + max-current potentiometer at the highest position whose limit, as the + controller itself reports it after leaving adjust mode, is `<= max_current`. + `pd.max_current_clamp` records that reported limit, never a request or a + value computed from a position (the header's position scale is wrong on the + rig's controller; see [`DIGPOT_STEP_ESTIMATE_mA`](@ref)). +5. `LD_SetClosedLoopMode`, then re-read the bits and require `0x4`. +6. `LD_SetWACalibFactor(pd.wa_calibration)` and read it back within `Cfloat` + tolerance, so the front panel and this driver display the same number. The + factor scales the controller's display only; the driver does its own + conversion. If any step after `LD_Open` fails, the handle is closed before the error propagates -- a half-open controller refuses the next `LD_Open` and so blocks -the retry -- and the original error is the one raised. +the retry -- and the original error is the one raised. A power-mode failure +after step 3 leaves the output off. It deliberately does **not** touch `light.max_current`: that field is the caller's ceiling for this diode, and overwriting it with the controller's -(typically 160-220 mA) limit silently widened the range `setpower` validates +(typically 160-220 mA) limit silently widened the range `setcurrent!` validates against, so a rig that asked for an 80 mA ceiling got the controller's instead. + +Hardware-verified on the 642 nm rig's TLD001 (64849775, 2026-09-28), output +off: polling refreshes the setpoint cache; a plain potentiometer set is ignored +and adjust mode is required, with a pause before leaving it; the limit the +controller reports is the truth, not the header's scale; entering closed loop +sets status bit `0x4`; the W/A factor reads back. `[limitation]` the second flag +of `LD_EnableMaxCurrentAdjust` (always passed `false`) is not verified. """ function initialize(light::TCubeLaser) serialNo = light.serialNo @@ -227,18 +565,21 @@ function initialize(light::TCubeLaser) check_err(LD_Open(serialNo), "LD_Open", serialNo) try - check_err(LD_SetOpenLoopMode(serialNo), "LD_SetOpenLoopMode", serialNo) + # A success FLAG, not a status code: `false` is the failure. + check_flag(LD_StartPolling(serialNo, POLL_INTERVAL_MS), "LD_StartPolling", serialNo) + sleep(REQUEST_WAIT_S[]) + enter_mode!(regulation_mode(light), light) check_err(LD_RequestReadings(serialNo), "LD_RequestReadings", serialNo) # The diode current limit has its OWN request in the Kinesis API, and # `LD_RequestReadings` does not stand in for it. Reading the limit # after only the generic request can hand back a stale or never- # populated cache -- and this value feeds `effective_max_current`, so a - # stale one widens or narrows the ceiling `setpower` enforces. Not + # stale one widens or narrows the ceiling `setcurrent!` enforces. Not # hardware-verified: reported by the 642 nm rig from the Kinesis header # while building its probe, which will measure whether the two differ. check_err(LD_RequestLaserDiodeMaxCurrentLimit(serialNo), "LD_RequestLaserDiodeMaxCurrentLimit", serialNo) - sleep(0.1) + sleep(REQUEST_WAIT_S[]) out = LD_GetLaserDiodeMaxCurrentLimit(serialNo) record_controller_limit!(light, out) catch @@ -247,6 +588,7 @@ function initialize(light::TCubeLaser) # close is reported but never rethrown: the failure that stopped # initialization is the one the caller needs. try + LD_StopPolling(serialNo) LD_Close(serialNo) catch closeerr @error "TCubeLaser $serialNo: LD_Close failed while cleaning up a failed initialize" exception = closeerr @@ -254,85 +596,206 @@ function initialize(light::TCubeLaser) rethrow() end - @info "Laser initialized" serialNo devices = numdev controller_max_current = "$(light.controller_max_current) mA" enforced_max_current = "$(effective_max_current(light)) mA" + @info "Laser initialized" serialNo devices = numdev mode = nameof(typeof(regulation_mode(light))) controller_max_current = "$(light.controller_max_current) mA" enforced_max_current = "$(effective_max_current(light)) mA" + return nothing +end + +enter_mode!(::ConstantCurrent, light::TCubeLaser) = + check_err(LD_SetOpenLoopMode(light.serialNo), "LD_SetOpenLoopMode", light.serialNo) + +function enter_mode!(::ConstantPhotocurrent, light::TCubeLaser) + serialNo, pd = light.serialNo, light.pd + name = "TCubeLaser $serialNo" + + # 1. key switch and interlock + bits = read_status_fresh(serialNo) + missing_bits = [label for (label, bit) in (("key switch (0x2)", STATUS_BITS.key), ("interlock (0x8)", STATUS_BITS.interlock)) + if bits & bit == 0] + isempty(missing_bits) || error("$name: refusing closed loop: $(join(missing_bits, " and ")) not set (status 0x$(string(bits; base=16)))") + + # 2. the amplifier range the controller reports must be the one stated + reported = LightSourceInterface.tia_range_from_word(bits) + isnan(reported) && error("$name: refusing closed loop: the status word reports no single photodiode range (status 0x$(string(bits; base=16)))") + isapprox(reported, pd.tia_range; rtol=1e-9) || error( + "$name: refusing closed loop: the controller's photodiode range is $(reported) A but tia_range states $(pd.tia_range) A. " * + "Check the rear-panel DIP switch; the calibration is only valid on the range it was measured on.") + + # 3. output off. The setpoint cannot be zeroed here: the controller ignores + # setpoints with the output off (SETPOINT_NEEDS_OUTPUT); light_on sends the + # real one right after enabling. + check_err(LD_DisableOutput(serialNo), "LD_DisableOutput", serialNo) + light.properties.is_on = false + + # 4. the clamp, as the controller itself reports it + pd.max_current_clamp = program_clamp!(light) + + # 5. closed loop, verified + check_err(LD_SetClosedLoopMode(serialNo), "LD_SetClosedLoopMode", serialNo) + read_status_fresh(serialNo) & STATUS_BITS.closed_loop != 0 || + error("$name: LD_SetClosedLoopMode returned success but the status word does not report closed loop (0x4)") + + # 6. the display calibration, verified + check_err(LD_SetWACalibFactor(serialNo, Cfloat(pd.wa_calibration)), "LD_SetWACalibFactor", serialNo) + check_err(LD_RequestWACalibFactor(serialNo), "LD_RequestWACalibFactor", serialNo) + sleep(REQUEST_WAIT_S[]) + wa = Float64(LD_GetWACalibFactor(serialNo)) + isapprox(wa, pd.wa_calibration; rtol=1e-6) || error( + "$name: set the W/A calibration factor to $(pd.wa_calibration) but the controller reports $(wa)") return nothing end """ light_on(light::TCubeLaser) -Enable the controller's output, then record it. `properties.is_on` is a -*requested* state across this package, but it is at least written after the -call that was supposed to make it true rather than before it, so a failed -enable no longer leaves the field claiming the laser is on. +Enable the controller's output, then immediately send the setpoint the driver's +recorded request asks for ([`intended_code`](@ref)) and confirm it, then record +`properties.is_on`. The setpoint has to follow the enable: the controller +ignores setpoints while its output is off ([`SETPOINT_NEEDS_OUTPUT`](@ref)). +If the setpoint cannot be confirmed, the output is disabled again before the +error propagates, so a failed `light_on` never leaves the diode emitting at an +unknown setpoint. + +A `ConstantPhotocurrent` laser refuses until `initialize` has programmed and +verified its clamp (`pd.max_current_clamp` is not `NaN`). """ function LightSourceInterface.light_on(light::TCubeLaser) - check_err(LD_EnableOutput(light.serialNo), "LD_EnableOutput", light.serialNo) + require_clamp(regulation_mode(light), light, "light_on") + serialNo = light.serialNo + code = intended_code(light) + check_err(LD_EnableOutput(serialNo), "LD_EnableOutput", serialNo) + try + send_setpoint(light, code) + catch + try + LD_DisableOutput(serialNo) + catch offerr + @error "TCubeLaser $serialNo: LD_DisableOutput failed after a setpoint could not be confirmed" exception = offerr + end + rethrow() + end light.properties.is_on = true println("$(light.laser_color)" * "_laser is on") return nothing end +require_clamp(::ConstantCurrent, light::TCubeLaser, op) = nothing +function require_clamp(::ConstantPhotocurrent, light::TCubeLaser, op) + isnan(light.pd.max_current_clamp) && error( + "TCubeLaser $(light.serialNo): $op refused: the max-current clamp has not been programmed and verified. " * + "Call initialize first; it is the only real protection in closed loop.") + return nothing +end + """ - setpower(light::TCubeLaser, current::Float64) + setcurrent!(light::TCubeLaser{ConstantCurrent}, current::Float64) -Set the diode drive current, in **mA** -- despite the interface name, this -function has always taken a current. +Set the diode drive current, in **mA**. -Validates through [`check_current`](@ref), so an out-of-range request -throws before any setpoint reaches the controller, and encodes the setpoint -through [`setpoint_code`](@ref), whose guarantee is that the *decoded* current --- `setpoint_current(light, code)`, the driver's own arithmetic -- never exceeds -the requested one. +Validates through [`check_current`](@ref), so an out-of-range request throws +before anything reaches the controller, and encodes through +[`setpoint_code`](@ref), whose guarantee is that the *decoded* current never +exceeds the requested one. With the output **on**, the setpoint is sent and +confirmed from the controller's read-back. With the output **off**, it is +recorded and `light_on` applies it right after enabling -- the controller would +ignore it now ([`SETPOINT_NEEDS_OUTPUT`](@ref)). # The bottom of the range commands nothing Because the encoding only ever rounds down, a positive request smaller than one code encodes to code `0`: at the default scale that is any request below -`220 / 32767 ≈ 0.006714` mA, so a **zero current setpoint** is sent. That is -not the same as turning the laser off: this path does not disable the output -and does not touch `properties.is_on`. Use [`light_off`](@ref) to stop -emission. This is -deliberate -- rounding such a request up to one code would command more current -than was asked for, which is the rule this driver will not break -- but it is -worth saying out loud, because `drive_current` records the **requested** -current, not the zero that was commanded. A caller reading it back after a -sub-code request sees its own number, and `export_state` writes that number to -the HDF5 attributes. There is no field here that reports what actually went to -the wire. - -# What is recorded - -On success, `light.drive_current` holds the current that was accepted, in mA. -That is the field to read. - -`properties.power` is also updated, to [`legacy_power`](@ref)'s linear -`current * max_power / ` figure under the historical `"mW"` -label. It is an **uncalibrated guess**, contradicted by the bench measurements -preserved as a comment in `TCubeLaserControl.jl` (they came from the deleted -`helpers.jl`), and the controller reports no power in open-loop mode. It is -kept only so an existing rig reads the same number across this upgrade, is -**deprecated**, and is scheduled for removal in 0.3.0. -""" -function LightSourceInterface.setpower(light::TCubeLaser, current::Float64) +`220 / 32767 ≈ 0.006714` mA, so a **zero current setpoint** is sent. That is not +the same as turning the laser off: this path does not disable the output and +does not touch `properties.is_on`. Use [`light_off`](@ref) to stop emission. +Rounding such a request up would command more current than was asked for, which +is the rule this driver will not break -- but `drive_current` records the +**requested** current, not the zero that was commanded. + +On success, `light.drive_current` holds the accepted current, in mA. +""" +function LightSourceInterface.setcurrent!(light::TCubeLaser{ConstantCurrent}, current::Float64) check_current(light, current) - current_setpoint = setpoint_code(light, current) - check_err(LD_SetLaserSetPoint(light.serialNo, current_setpoint), "LD_SetLaserSetPoint", light.serialNo) + code = setpoint_code(light, current) + on = output_enabled(light.serialNo) + on && send_setpoint(light, code) light.drive_current = current - light.properties.power = legacy_power(light, current) # deprecated; see legacy_power - println("Laser current set to $current mA") + println("Laser current set to $current mA", on ? "" : " (output off: applied at light_on)") + return nothing +end + +""" + setoutputpower!(light::TCubeLaser{ConstantPhotocurrent}, power_mW::Float64) + +Command the optical power at the laser output, in mW -- the plane where +`pd.wa_calibration` was measured. Not the power at the sample. + +1. Refuse unless `initialize` programmed the clamp. +2. [`check_power`](@ref) against `properties.min_power..max_power`. +3. Refuse from the (polled) status word: unless it reports closed loop; if the + photodiode amplifier is over range; or if it is under range while the output + is on. An under-range flag with the output off is expected and ignored. +4. Convert: photocurrent `= power_mW / 1000 / wa_calibration` A, encoded by + [`photocurrent_code`](@ref), rounding DOWN; above full scale it throws + naming the DIP switch. +5. With the output on, send and confirm the setpoint; with it off, leave it for + `light_on` ([`SETPOINT_NEEDS_OUTPUT`](@ref)). +6. Record `pd.output_power_requested` and the DECODED `pd.photocurrent_requested`. + +It never touches `drive_current`: the loop owns the current. Whether the light +at the output matches is a question for a power meter; see +[`indicated_output_power`](@ref) and [`loop_status`](@ref). +""" +function LightSourceInterface.setoutputpower!(light::TCubeLaser{ConstantPhotocurrent}, power_mW::Float64) + require_clamp(ConstantPhotocurrent(), light, "setoutputpower!") + check_power(light, power_mW) + pd, serialNo = light.pd, light.serialNo + bits = UInt32(LD_GetStatusBits(serialNo)) + on = bits & STATUS_BITS.output_enabled != 0 + bits & STATUS_BITS.closed_loop != 0 || error( + "TCubeLaser $serialNo: setoutputpower! refused: the controller is not in closed loop (status 0x$(string(bits; base=16))); " * + "was the mode changed on the front panel? Call initialize again.") + (bits & STATUS_BITS.tia_over != 0 || (on && Int(LD_GetPhotoCurrentReading(serialNo)) == PHOTOCURRENT_OVER_RANGE)) && error( + "TCubeLaser $serialNo: setoutputpower! refused: the photodiode amplifier reports OVER range, so the loop's feedback is invalid") + (bits & STATUS_BITS.tia_under != 0 && on) && error( + "TCubeLaser $serialNo: setoutputpower! refused: the photodiode amplifier reports UNDER range with the output on, so the loop's feedback is invalid") + code = photocurrent_code(light, power_mW / 1000 / pd.wa_calibration) + on && send_setpoint(light, code) + pd.output_power_requested = power_mW + pd.photocurrent_requested = photocurrent_from_code(light, code, pd.tia_range) + println("Laser output power set to $power_mW mW (photocurrent setpoint $(pd.photocurrent_requested) A)", + on ? "" : " (output off: applied at light_on)") + return nothing +end + +""" + zero_then_disable(light::TCubeLaser) + +If the output is on, send setpoint 0 first (best effort: a failure is logged, +not raised, so it can never prevent the disable), then disable the output. The +controller's stored setpoint is then 0, so the next enable starts dark rather +than on a stale value; the driver's recorded request is kept, and `light_on` +re-applies it. +""" +function zero_then_disable(light::TCubeLaser) + serialNo = light.serialNo + try + output_enabled(serialNo) && send_setpoint(light, UInt16(0)) + catch zeroerr + @error "TCubeLaser $serialNo: could not zero the setpoint before disabling; disabling anyway" exception = zeroerr + end + check_err(LD_DisableOutput(serialNo), "LD_DisableOutput", serialNo) + light.properties.is_on = false return nothing end """ light_off(light::TCubeLaser) -Disable the controller's output, then record it. See [`light_on`](@ref) on the -ordering. +Zero the setpoint while the output is still on, then disable the output, then +record it ([`zero_then_disable`](@ref)). The recorded request (`drive_current`, +or `pd.output_power_requested`) is kept, so `light_on` returns to it. """ function LightSourceInterface.light_off(light::TCubeLaser) - check_err(LD_DisableOutput(light.serialNo), "LD_DisableOutput", light.serialNo) - light.properties.is_on = false + zero_then_disable(light) println("$(light.laser_color)" * "_laser is off") return nothing end @@ -340,31 +803,125 @@ end """ shutdown(light::TCubeLaser) -Disable the output and close the connection. A failed disable throws, but the -connection is closed either way: leaving the Kinesis handle open would also -block the reconnection a caller needs in order to retry the disable. +Zero the setpoint and disable the output ([`zero_then_disable`](@ref)), stop +polling and close the connection. A failed disable throws, but the connection +is closed either way: leaving the Kinesis handle open would also block the +reconnection a caller needs in order to retry the disable. """ function shutdown(light::TCubeLaser) serialNo = light.serialNo try - check_err(LD_DisableOutput(serialNo), "LD_DisableOutput", serialNo) - light.properties.is_on = false + zero_then_disable(light) finally + try + LD_StopPolling(serialNo) # returns Cvoid: no status to check + catch stoperr + @error "TCubeLaser $serialNo: LD_StopPolling failed during shutdown" exception = stoperr + end LD_Close(serialNo) # returns Cvoid: no status to check end println("$(light.laser_color)" * "_laser is shutdown") return nothing end +# --------------------------------------------------------------------------- +# Readbacks +# --------------------------------------------------------------------------- + +""" + measured_current(light::TCubeLaser) + +The diode drive current the controller reports, in mA: a polled cache read (see +[`POLL_INTERVAL_MS`](@ref)), decoded by [`setpoint_current`](@ref). The reading +is signed; a raw value outside ±32767 is a protocol error and throws. +""" +function LightSourceInterface.measured_current(light::TCubeLaser) + raw = Int(LD_GetLaserDiodeCurrentReading(light.serialNo)) + abs(raw) <= SETPOINT_PROTOCOL_MAX || error( + "TCubeLaser $(light.serialNo): diode current reading $(raw) is outside the protocol's ±$(SETPOINT_PROTOCOL_MAX)") + return setpoint_current(light, raw) +end + +""" + measured_photocurrent(light::TCubeLaser) + +The monitor photodiode current the controller reports, in A: a polled cache +read, scaled over the amplifier range -- `pd.tia_range` in `ConstantPhotocurrent` +mode, and in `ConstantCurrent` mode the range the status word reports (`NaN` +if it reports none). The reading is signed (a small negative offset at idle); a +raw value outside ±32767 is a protocol error and throws. + +Available in both modes. In open loop it is what a W/A calibration is measured +against; see `CALIBRATION.md`. +""" +function LightSourceInterface.measured_photocurrent(light::TCubeLaser) + raw = Int(LD_GetPhotoCurrentReading(light.serialNo)) + return photocurrent_from_raw(light, raw, photocurrent_range(light)) +end + +""" + PHOTOCURRENT_OVER_RANGE + +The raw photocurrent word the controller reports when the photodiode channel is +over range: `0x8000`, i.e. -32768 read signed. Observed on the 642 nm rig's +TLD001 (2026-09-28) at 110 mA and above, where the reading jumped from about +3200 counts straight to this value. Decoded as `Inf`, and reported by +`loop_status` as `tia_over`. +""" +const PHOTOCURRENT_OVER_RANGE = -32768 + +# Signed: the idle reading is a small negative offset (-3 on the 642 nm rig), so +# a negative value is a reading, not a protocol error. +function photocurrent_from_raw(light::TCubeLaser, raw::Int, range::Float64) + raw == PHOTOCURRENT_OVER_RANGE && return Inf + abs(raw) <= SETPOINT_PROTOCOL_MAX || error( + "TCubeLaser $(light.serialNo): photocurrent reading $(raw) is outside the protocol's ±$(SETPOINT_PROTOCOL_MAX)") + return photocurrent_from_code(light, raw, range) +end + +photocurrent_range(light::TCubeLaser{ConstantPhotocurrent}) = light.pd.tia_range +photocurrent_range(light::TCubeLaser{ConstantCurrent}) = + LightSourceInterface.tia_range_from_word(LD_GetStatusBits(light.serialNo)) + +""" + indicated_output_power(light::TCubeLaser{ConstantPhotocurrent}) + +[`measured_photocurrent`](@ref) x `pd.wa_calibration`, in mW at the laser +output. A conversion through a one-time calibration, not a measurement. +""" +LightSourceInterface.indicated_output_power(light::TCubeLaser{ConstantPhotocurrent}) = + measured_photocurrent(light) * light.pd.wa_calibration * 1000 + +""" + loop_status(light::TCubeLaser) + +One snapshot of the controller: the status word and both readings, all polled +cache reads. See the interface's [`loop_status`](@ref) for the fields. +""" +function LightSourceInterface.loop_status(light::TCubeLaser) + word = UInt32(LD_GetStatusBits(light.serialNo)) + current = measured_current(light) + range = regulation_mode(light) isa ConstantPhotocurrent ? light.pd.tia_range : + LightSourceInterface.tia_range_from_word(word) + photocurrent = photocurrent_from_raw(light, Int(LD_GetPhotoCurrentReading(light.serialNo)), range) + commanded = regulation_mode(light) isa ConstantPhotocurrent ? !isnan(light.pd.output_power_requested) : + !isnan(light.drive_current) + return LightSourceInterface.status_snapshot(word, current, photocurrent; + threshold_current=light.threshold_current, commanded=commanded) +end + """ tcube_get_current(light::TCubeLaser) -Read the diode current the controller reports, in mA. +Read the diode current the controller reports, in mA, with an explicit +`LD_RequestReadings` and wait rather than the polled cache. Slower than +[`measured_current`](@ref), and independent of whether polling refreshes the +cache. """ function tcube_get_current(light::TCubeLaser) serialNo = light.serialNo check_err(LD_RequestReadings(serialNo), "LD_RequestReadings", serialNo) - sleep(0.1) + sleep(REQUEST_WAIT_S[]) out = LD_GetLaserDiodeCurrentReading(serialNo) return setpoint_current(light, out) end @@ -415,73 +972,63 @@ end """ export_state(light::TCubeLaser) -Snapshot for HDF5 serialization, matching the package-wide 1-argument -`export_state` contract. Before v0.2.3 the only method took an unused second -positional argument, so this 1-argument call fell through to the throwing -instrument-level stub. +Snapshot for HDF5 serialization. Pure field reads: no transport call, so its +cost and failure modes do not depend on the controller. It records what was +COMMANDED and configured, not what was measured -- evidence that the loop held +its setpoint comes from [`loop_status`](@ref), which a rig logs with its own +timestamps. -`"drive_current"` is the drive current in mA that `setpower` last accepted, and -is the attribute to read. `"power"`/`"power_unit"` are the deprecated derived -guess described in [`legacy_power`](@ref), written for continuity with files -saved by earlier versions and scheduled for removal in 0.3.0. +Every laser: `regulation_mode` (`"ConstantCurrent"`/`"ConstantPhotocurrent"`), +`setpoint_unit` (`"mA"`/`"mW"`), `min_current_mA`, `max_current_mA`, +`controller_max_current`, `threshold_current_mA`, `drive_current`, `is_on` and +the identifiers and DAQ names. + +`ConstantPhotocurrent` also: `power_reference` (`"laser output"`), +`min_output_power_mW`, `max_output_power_mW`, `wa_calibration_W_per_A`, +`tia_range_A`, `tec_stabilised` (`"true"`/`"false"`/`"unknown"`), +`max_current_clamp_mA`, `output_power_requested_mW`, `photocurrent_requested_A`. + +`power` and `power_unit` are no longer written: they held an uncalibrated +linear guess under a `"mW"` label. """ function export_state(light::TCubeLaser) - attributes = Dict( + mode = regulation_mode(light) + attributes = Dict{String,Any}( "unique_id" => light.unique_id, "laser_color" => light.laser_color, "serialNo" => light.serialNo, - "min_current" => light.min_current, "max_current" => light.max_current, + "regulation_mode" => string(nameof(typeof(mode))), + "setpoint_unit" => mode isa ConstantPhotocurrent ? "mW" : "mA", + "min_current_mA" => light.min_current, "max_current_mA" => light.max_current, "controller_max_current" => light.controller_max_current, + "threshold_current_mA" => light.threshold_current, "max_setcurrent" => light.max_setcurrent, "max_setpoint" => light.max_setpoint, # `nothing` is not an HDF5-writable attribute value; "" means "not set". "daq_device" => something(light.daq_device, ""), "ao_channel" => something(light.ao_channel, ""), "drive_current" => light.drive_current, - "power_unit" => light.properties.power_unit, "power" => light.properties.power, "is_on" => light.properties.is_on, - "min_power" => light.properties.min_power, "max_power" => light.properties.max_power + "is_on" => light.properties.is_on, ) + if mode isa ConstantPhotocurrent + pd = light.pd + merge!(attributes, Dict{String,Any}( + "power_reference" => "laser output", + "min_output_power_mW" => light.properties.min_power, + "max_output_power_mW" => light.properties.max_power, + "wa_calibration_W_per_A" => pd.wa_calibration, + "tia_range_A" => pd.tia_range, + # `missing` is not an HDF5-writable attribute value. + "tec_stabilised" => pd.tec_stabilised === missing ? "unknown" : string(pd.tec_stabilised), + "max_current_clamp_mA" => pd.max_current_clamp, + "output_power_requested_mW" => pd.output_power_requested, + "photocurrent_requested_A" => pd.photocurrent_requested, + )) + end data = nothing - children = Dict( + children = Dict{String,Any}( "daq" => export_state(light.daq) # export_state function from NIDAQcard module ) return attributes, data, children end -""" - EXPORT_STATE_2ARG_WARNED - -Whether the deprecated 2-argument [`export_state`](@ref) has already attempted -its warning in this session. Not `@warn`'s `maxlog=1`, so that the warning is -testable more than once per process; `Threads.Atomic` rather than a plain `Ref` -because a check followed by a store lets two concurrent callers both warn and -is a data race besides. Reset it with `[] = false` in a test. -""" -const EXPORT_STATE_2ARG_WARNED = Threads.Atomic{Bool}(false) - -""" - export_state(light::TCubeLaser, ignored) - -Deprecated forwarder to [`export_state(::TCubeLaser)`](@ref). Warns once per -session and ignores its second argument, which was never read. - -It is kept because removing it would be a break for no benefit. The bug was the -*absence* of the 1-argument method -- `export_state(laser)` matched nothing on -this type and fell through to the throwing instrument-level stub -- so adding -that method is the whole fix, and a caller that had to pass a second argument -to get anything at all keeps working. The forwarder is scheduled for removal in -0.3.0. -""" -function export_state(light::TCubeLaser, ignored) - # Test-and-set in one atomic step: a plain `Ref` check followed by a store - # lets two concurrent callers both observe `false` and both warn, and is a - # data race besides. Note this is "attempt to warn once", not "display - # once": a first call under a logger that swallows warnings still spends - # the allowance. - if !Threads.atomic_cas!(EXPORT_STATE_2ARG_WARNED, false, true) - @warn "export_state(::TCubeLaser, x): the second argument is ignored and this method is deprecated; " * - "call export_state(laser). The 2-argument form is scheduled for removal in 0.3.0." ignored_argument = ignored - end - return export_state(light) -end - """ tcube_refresh(light::TCubeLaser) @@ -494,15 +1041,12 @@ without being asked for a number. It is kept as a throwing stub rather than deleted so that `using MicroscopeControl; tcube_refresh(laser)` still resolves and explains itself, instead of failing with an `UndefVarError` that says nothing about what the call used to do. - -`setpower(light, current)` is the replacement: it takes the current explicitly -and refuses one outside the configured range. """ function tcube_refresh(light::TCubeLaser) error("TCubeLaser $(light.serialNo): tcube_refresh was removed in v0.2.3 and does nothing. " * "It opened the controller, enabled the output, drove a hardcoded 90 mA for one second, " * "then disabled and closed -- a bench procedure whose name warned nobody about the current it " * - "commanded. Use `setpower(light, current)` with the current you want, which is checked against " * - "min_current, max_current, the controller's limit and max_setcurrent before anything is sent; " * + "commanded. Use `setcurrent!(light, current)` (ConstantCurrent) or `setoutputpower!(light, mW)` " * + "(ConstantPhotocurrent) with the value you want, which is checked before anything is sent; " * "`light_on`/`light_off` control the output.") end diff --git a/src/hardware_implementations/tcube_laser/types.jl b/src/hardware_implementations/tcube_laser/types.jl index fc1e9ab..bf560b9 100644 --- a/src/hardware_implementations/tcube_laser/types.jl +++ b/src/hardware_implementations/tcube_laser/types.jl @@ -1,22 +1,37 @@ """ - `TCubeLaser` A TCubeLaserControl type inherited from `LightSource`. + TCubeLaser{M<:RegulationMode} <: DiodeLaser + +A laser diode on a Thorlabs TLD001 T-Cube controller, in one of two regulation +modes fixed at construction: + +- `TCubeLaser{ConstantCurrent}`: open loop. The controller holds the drive + current; command it with [`setcurrent!`](@ref), in mA. +- `TCubeLaser{ConstantPhotocurrent}`: closed loop. The controller holds the + monitor photodiode current; command it with [`setoutputpower!`](@ref), in mW + at the laser output, converted through `pd.wa_calibration`. See + `CALIBRATION.md` beside this file for how that number is measured. + +`setpower` is not defined for this type (it throws): it took mA while its +name and `properties.power_unit` said mW, and flipping the mode would have +turned an 80 mA call into an 80 mW one without a word. # Fields - `unique_id::String`: A unique identifier for the light source. -- `properties::LightSourceProperties`: The properties of the light source. - `power_unit` defaults to `"mW"` and `power` holds a **derived, uncalibrated** - figure -- see the note below; `drive_current` is the honest field, holding - the last drive current `setpower` accepted. Neither reports what reached the - wire: see `drive_current`'s own entry. +- `properties::LightSourceProperties`: `is_on` is the requested output state. In + `ConstantPhotocurrent` mode `min_power`/`max_power` are the ENFORCED bounds of + `setoutputpower!`, in mW at the laser output, and the endpoints of + `setlevel!`. In `ConstantCurrent` mode they are not read; `power` and + `power_unit` are not written by this driver in either mode. - `laser_color::String`: The color of the laser. -- `min_current::Float64`: Lower bound accepted by `setpower`, in mA. Defaults - to `0.0`; set it to a diode-specific floor if the diode has one. It is a +- `min_current::Float64`: The lowest drive current this rig will command, in + mA: `setcurrent!`'s floor and `setlevel!`'s zero. Defaults to `0.0`. It is a *lower* bound, so it protects nothing on its own -- the ceiling does. - `max_current::Float64`: The caller's current ceiling in mA, i.e. the rig's - own limit for this diode. `initialize` does **not** overwrite it (it writes - `controller_max_current` instead), so a ceiling passed here survives - initialization and keeps constraining `setpower`. + own limit for this diode. Never overwritten by `initialize`. In + `ConstantPhotocurrent` mode it is also the clamp `initialize` programs into + the controller's max-current potentiometer, because there the loop raises the + current by itself. - `max_setcurrent::Float64`: Full-scale current of the setpoint DAC, in mA. - `max_setpoint::Float64`: Full-scale value of the setpoint DAC. - `serialNo::String`: Thorlabs Kinesis serial number of the controller. @@ -30,33 +45,25 @@ `nothing` keeps the historical discovery behaviour (see `setupIO`). - `ao_channel::Union{Nothing,String}`: AO channel name `setupIO` should use. `nothing` keeps the historical discovery behaviour (see `setupIO`). -- `drive_current::Float64`: The drive current in mA that `setpower` last - accepted. It is the last accepted *request*, not what reached the wire: a - request below one setpoint code is accepted and encodes to zero, so the two - differ at the bottom of the range. It is still the honest field -- - `properties.power` is a derived guess -- but nothing here reports the - commanded code. Defaults to `NaN`, i.e. before the first successful - `setpower`; both constructors accept an explicit value. - -The first ten fields are in the order they have always been in, so positional -construction from before v0.2.3 still works; the four added fields are at the -end and an inner constructor taking the old ten defaults them. - -# `properties.power` is an uncalibrated guess (deprecated) - -`power` is `current * properties.max_power / `, a linear -current-to-power model this driver has no way to measure, and the bench -measurements preserved as a comment in `TCubeLaserControl.jl` contradict it. -The controller reports no optical power in the open-loop mode this driver -uses. `power`/`power_unit` are kept for compatibility, are **deprecated**, and -are scheduled for removal in 0.3.0. Read `drive_current` instead, which is the -number the driver actually acted on. - -`setpower` validates against the *smallest* of `min_current`'s counterparts: -`max_current`, `controller_max_current` (ignored while `NaN`) and -`max_setcurrent`. See [`check_current`](@ref). +- `drive_current::Float64`: The drive current in mA that `setcurrent!` last + accepted: the last accepted *request*, not what reached the wire (a request + below one setpoint code encodes to zero). `NaN` before the first. It stays + `NaN` for the life of a `ConstantPhotocurrent` laser: the loop owns the + current there, and a requested value would be a fiction. Read + [`measured_current`](@ref) for what the controller reports. +- `threshold_current::Float64`: The diode's lasing threshold in mA, declared by + the rig from a bench sweep (the controller cannot report it). `NaN` when + unknown. Used by the panel marker and by `loop_status`'s `below_threshold` + check; never as a bound. +- `pd::Union{Nothing,PhotodiodeLoop}`: The photodiode loop's calibration and + state; a [`PhotodiodeLoop`](@ref) exactly when `M === ConstantPhotocurrent`, + `nothing` otherwise. + +The first fourteen fields are in the order they have always been in, and the +two added in 0.3.0 are at the end, so the pre-0.3.0 positional forms still +construct: they build a `TCubeLaser{ConstantCurrent}` with both defaulted. """ -mutable struct TCubeLaser <: LightSource +mutable struct TCubeLaser{M<:RegulationMode} <: DiodeLaser unique_id::String properties::LightSourceProperties laser_color::String @@ -71,53 +78,98 @@ mutable struct TCubeLaser <: LightSource daq_device::Union{Nothing,String} ao_channel::Union{Nothing,String} drive_current::Float64 + threshold_current::Float64 + pd::Union{Nothing,PhotodiodeLoop} - function TCubeLaser(unique_id, properties, laser_color, min_current, max_current, + function TCubeLaser{M}(unique_id, properties, laser_color, min_current, max_current, max_setcurrent, max_setpoint, serialNo, task_mod, daq, - controller_max_current, daq_device, ao_channel, drive_current) - new(unique_id, properties, laser_color, min_current, max_current, + controller_max_current, daq_device, ao_channel, drive_current, + threshold_current, pd) where {M<:RegulationMode} + M in supported_modes(TCubeLaser) || throw(ArgumentError( + "TCubeLaser $serialNo: mode $(M) is not one of $(supported_modes(TCubeLaser))")) + name = "TCubeLaser $serialNo" + LightSourceInterface.check_diode_config(M, pd, properties, Float64(max_current), name) + if M === ConstantPhotocurrent + isnan(drive_current) || throw(ArgumentError( + "$name: drive_current must be NaN on a ConstantPhotocurrent laser, where the loop owns the current; got $(drive_current)")) + any(r -> isapprox(pd.tia_range, r; rtol=1e-9), TLD001_TIA_RANGES) || throw(ArgumentError( + "$name: tia_range = $(pd.tia_range) A is not a TLD001 photodiode range; it must be one of $(TLD001_TIA_RANGES) A, as set on the rear-panel DIP switch")) + max_current >= DIGPOT_MIN_mA || throw(ArgumentError( + "$name: max_current = $(max_current) mA is below the lowest current the TLD001's max-current potentiometer can be set to ($(DIGPOT_MIN_mA) mA), " * + "so power mode could not clamp this diode. Use mode = ConstantCurrent().")) + end + new{M}(unique_id, properties, laser_color, min_current, max_current, max_setcurrent, max_setpoint, serialNo, task_mod, daq, - controller_max_current, daq_device, ao_channel, drive_current) - end - - # The pre-v0.2.3 positional arity. The four fields added since are at the - # end of the struct precisely so this can default them, and so a caller - # that built one of these positionally does not have to be rewritten to - # receive the safety fixes. - function TCubeLaser(unique_id, properties, laser_color, min_current, max_current, - max_setcurrent, max_setpoint, serialNo, task_mod, daq) - new(unique_id, properties, laser_color, min_current, max_current, - max_setcurrent, max_setpoint, serialNo, task_mod, daq, - NaN, nothing, nothing, NaN) + controller_max_current, daq_device, ao_channel, drive_current, + threshold_current, pd) end end +# The pre-0.3.0 positional arities, both building a ConstantCurrent laser: the +# fields added since sit at the end of the struct precisely so these can +# default them. Positional construction cannot build a power-mode laser; that +# needs the calibration keywords. +TCubeLaser(unique_id, properties, laser_color, min_current, max_current, + max_setcurrent, max_setpoint, serialNo, task_mod, daq, + controller_max_current, daq_device, ao_channel, drive_current) = + TCubeLaser{ConstantCurrent}(unique_id, properties, laser_color, min_current, max_current, + max_setcurrent, max_setpoint, serialNo, task_mod, daq, + controller_max_current, daq_device, ao_channel, drive_current, NaN, nothing) + +TCubeLaser(unique_id, properties, laser_color, min_current, max_current, + max_setcurrent, max_setpoint, serialNo, task_mod, daq) = + TCubeLaser(unique_id, properties, laser_color, min_current, max_current, + max_setcurrent, max_setpoint, serialNo, task_mod, daq, + NaN, nothing, nothing, NaN) + +LightSourceInterface.supported_modes(::Type{<:TCubeLaser}) = (ConstantCurrent, ConstantPhotocurrent) +LightSourceInterface.regulation_mode(::Type{TCubeLaser{M}}) where {M} = M() """ - TCubeLaser(serialNo::String; kwargs...) + TCubeLaser(serialNo::String; mode, kwargs...) Construct a `TCubeLaser` for the Kinesis device `serialNo`. Pure: it opens -nothing, so every keyword below is a declaration of intent that `initialize` -and `setupIO` later act on. +nothing, so every keyword is a declaration that `initialize` and `setupIO` later +act on. The keywords match the field names documented on +[`TCubeLaser`](@ref). + +`mode` is **required**, with no default: `ConstantCurrent()` or +`ConstantPhotocurrent()`. It is required rather than defaulted until closed loop +has been verified on hardware, so that no existing construction line changes +meaning silently. + +```julia +# 642 nm rig, closed loop. Calibration: see CALIBRATION.md beside this file. +laser = TCubeLaser("64849775"; + mode = ConstantPhotocurrent(), + wa_calibration = 224.2, # W/A, measured with a power meter before the fibre + tia_range = 1e-3, # A: the rear-panel DIP switch, as set + tec_stabilised = missing, # honest until checked + threshold_current = 65.0, # mA, from a bench sweep + max_current = 160.0, # mA: programmed as the loop's clamp + properties = LightSourceProperties("mW", 0.0, false, 2.0, 80.0)) -The keywords match the field names documented on [`TCubeLaser`](@ref). The two -worth stating here: +# The same diode in open loop. +laser = TCubeLaser("64849775"; mode = ConstantCurrent(), min_current = 70.0, max_current = 160.0) +``` + +In `ConstantPhotocurrent` mode `wa_calibration`, `tia_range`, `tec_stabilised` +and `properties` (whose `min_power`/`max_power` become the enforced mW bounds) +are required: a rig cannot reach power mode without a measured calibration, a +stated amplifier range and an answer to the temperature question. In +`ConstantCurrent` mode passing any of the first three throws, and `properties` +defaults to `LightSourceProperties("mA", 0.0, false, min_current, max_current)`. - `max_current` is **your** ceiling for this diode and is never overwritten; `initialize` records the controller's own limit separately in - `controller_max_current`, and `setpower` enforces the smaller of the two. + `controller_max_current`, and `setcurrent!` enforces the smaller of the two. - `min_current` defaults to `0.0`. A non-zero default would reject safe small currents without protecting against large ones. - -`setpower` takes a drive **current in mA**, despite the interface name, and -records it in `drive_current`. `properties.power_unit` defaults to `"mW"` and -`properties.power` to the derived linear guess described on -[`TCubeLaser`](@ref); both are deprecated and unreliable, and neither is -checked or enforced. """ function TCubeLaser(serialNo::String; + mode::Union{Nothing,RegulationMode}=nothing, unique_id::String="TCubeLaser", - properties::LightSourceProperties=LightSourceProperties("mW", 0.0, false, 0.0, 100.0), + properties::Union{Nothing,LightSourceProperties}=nothing, laser_color::String="red", min_current::Float64=0.0, max_current::Float64=160.0, #220.0 is the max of the TCube @@ -128,9 +180,36 @@ function TCubeLaser(serialNo::String; daq::NIdaq=NIdaq(), daq_device::Union{Nothing,String}=nothing, ao_channel::Union{Nothing,String}=nothing, - drive_current::Float64=NaN + drive_current::Float64=NaN, + threshold_current::Float64=NaN, + wa_calibration::Union{Nothing,Real}=nothing, + tia_range::Union{Nothing,Real}=nothing, + tec_stabilised::Union{Nothing,Bool,Missing}=nothing, ) - TCubeLaser(unique_id, properties, laser_color, min_current, max_current, + name = "TCubeLaser $serialNo" + mode === nothing && throw(ArgumentError( + "$name: the `mode` keyword is required: ConstantCurrent() (open loop, setcurrent! in mA) or " * + "ConstantPhotocurrent() (closed loop, setoutputpower! in mW, needs wa_calibration, tia_range, tec_stabilised and properties). " * + "There is no default, so no construction line changes meaning when one is chosen.")) + pd = if mode isa ConstantPhotocurrent + absent = [kw for (kw, v) in (:wa_calibration => wa_calibration, :tia_range => tia_range, + :tec_stabilised => tec_stabilised, :properties => properties) if v === nothing] + isempty(absent) || throw(ArgumentError( + "$name: a ConstantPhotocurrent laser needs these keywords, none of which has a default: $(join(absent, ", ")). " * + "wa_calibration is W/A measured with a power meter at the laser output; tia_range is the rear-panel DIP switch in A; " * + "tec_stabilised is true, false or missing; properties carries the enforced min_power/max_power in mW.")) + PhotodiodeLoop(; wa_calibration=wa_calibration, tia_range=tia_range, tec_stabilised=tec_stabilised) + else + given = [kw for (kw, v) in (:wa_calibration => wa_calibration, :tia_range => tia_range, + :tec_stabilised => tec_stabilised) if v !== nothing] + isempty(given) || throw(ArgumentError( + "$name: $(join(given, ", ")) describe a photodiode loop, which only a ConstantPhotocurrent laser has; " * + "this one is $(nameof(typeof(mode)))")) + nothing + end + props = something(properties, LightSourceProperties("mA", 0.0, false, min_current, max_current)) + TCubeLaser{typeof(mode)}(unique_id, props, laser_color, min_current, max_current, max_setcurrent, max_setpoint, serialNo, task_mod, daq, - controller_max_current, daq_device, ao_channel, drive_current) + controller_max_current, daq_device, ao_channel, drive_current, + threshold_current, pd) end diff --git a/src/hardware_interfaces/lightsource_interface/LightSourceInterface.jl b/src/hardware_interfaces/lightsource_interface/LightSourceInterface.jl index cc14d54..848ac00 100644 --- a/src/hardware_interfaces/lightsource_interface/LightSourceInterface.jl +++ b/src/hardware_interfaces/lightsource_interface/LightSourceInterface.jl @@ -14,8 +14,17 @@ export LightSource, LightSourceProperties export setpower, light_on, light_off #, shutdown, initialize, export_state export gui +# Regulated laser diodes: the mode types, the photodiode loop and the unit-true +# setters and readbacks that replace `setpower` for them. See diode_laser.jl. +export DiodeLaser, RegulationMode, ConstantCurrent, ConstantPhotocurrent, PhotodiodeLoop +export setcurrent!, setoutputpower!, setlevel! +export measured_current, measured_photocurrent, indicated_output_power, loop_status +export regulation_mode, supported_modes + include("interface_types.jl") include("interface_functions.jl") +include("diode_laser.jl") include("gui.jl") +include("diode_laser_gui.jl") -end \ No newline at end of file +end diff --git a/src/hardware_interfaces/lightsource_interface/diode_laser.jl b/src/hardware_interfaces/lightsource_interface/diode_laser.jl new file mode 100644 index 0000000..b99283b --- /dev/null +++ b/src/hardware_interfaces/lightsource_interface/diode_laser.jl @@ -0,0 +1,159 @@ +""" + regulation_mode(laser::DiodeLaser) + +The laser's [`RegulationMode`](@ref), as an instance: `ConstantCurrent()` or +`ConstantPhotocurrent()`. To ask "do I command power here", write +`regulation_mode(laser) isa ConstantPhotocurrent`. + +There is no `LightSource` fallback: a light with no loop has no honest answer, +and "is this a regulated diode" is answered by `isa DiodeLaser`. +""" +regulation_mode(light::DiodeLaser) = regulation_mode(typeof(light)) + +function setlevel!(laser::DiodeLaser, frac::Float64) + (isfinite(frac) && 0.0 <= frac <= 1.0) || throw(ArgumentError( + "setlevel!: frac is a fraction of $(laser.unique_id)'s declared range and must be within [0, 1], got $(frac)")) + return _setlevel(regulation_mode(laser), laser, frac) +end + +_setlevel(::ConstantPhotocurrent, l::DiodeLaser, f::Float64) = + setoutputpower!(l, l.properties.min_power + f * (l.properties.max_power - l.properties.min_power)) +_setlevel(::ConstantCurrent, l::DiodeLaser, f::Float64) = + setcurrent!(l, l.min_current + f * (effective_max_current(l) - l.min_current)) + +""" + effective_max_current(laser::DiodeLaser) + +The highest drive current, in mA, this laser will accept or report as the top +of its range: the ceiling [`setlevel!`](@ref) maps `frac = 1.0` to in +[`ConstantCurrent`](@ref) mode. The fallback is `laser.max_current`; a driver +that knows further ceilings (a controller limit, a DAC full scale) extends this +to the smallest of them. +""" +effective_max_current(laser::DiodeLaser) = laser.max_current + +""" + STATUS_BITS + +The controller status-word layout the [`DiodeLaser`](@ref) drivers in this +package report, which is the Thorlabs TLD001's (`LD_GetStatusBits`, from the +Kinesis header): one definition, shared by the TCube driver and its simulated +twin so that [`loop_status`](@ref) reads the same from both. +""" +const STATUS_BITS = ( + output_enabled = 0x00000001, + key = 0x00000002, + closed_loop = 0x00000004, + interlock = 0x00000008, + tia_10uA = 0x00000010, + tia_100uA = 0x00000020, + tia_1mA = 0x00000040, + tia_10mA = 0x00000080, + saturated = 0x00000400, + open_circuit = 0x00000800, + psu_ok = 0x00001000, + tia_over = 0x00002000, + tia_under = 0x00004000, +) + +""" + TIA_RANGE_BITS + +Photodiode amplifier range, in A of full scale, for each range bit of +[`STATUS_BITS`](@ref). +""" +const TIA_RANGE_BITS = ( + STATUS_BITS.tia_10uA => 10e-6, + STATUS_BITS.tia_100uA => 100e-6, + STATUS_BITS.tia_1mA => 1e-3, + STATUS_BITS.tia_10mA => 10e-3, +) + +""" + tia_range_from_word(word) + +The amplifier range in A that `word` reports, or `NaN` unless exactly one range +bit is set. +""" +function tia_range_from_word(word) + w = UInt32(word) + set = [range for (bit, range) in TIA_RANGE_BITS if w & bit != 0] + return length(set) == 1 ? set[1] : NaN +end + +""" + status_snapshot(word, current_mA, photocurrent_A; threshold_current, commanded) + +Build [`loop_status`](@ref)'s `NamedTuple` from a raw status word and the two +readings. `commanded` says whether a level has been commanded since the laser +was constructed, which the `below_threshold` check needs. +""" +function status_snapshot(word, current_mA::Float64, photocurrent_A::Float64; + threshold_current::Float64, commanded::Bool) + w = UInt32(word) + has(bit) = w & bit != 0 + output_enabled = has(STATUS_BITS.output_enabled) + below_threshold = if isnan(threshold_current) + missing + else + output_enabled && commanded && current_mA < threshold_current + end + return ( + word = w, + output_enabled = output_enabled, + key = has(STATUS_BITS.key), + interlock = has(STATUS_BITS.interlock), + closed_loop = has(STATUS_BITS.closed_loop), + psu_ok = has(STATUS_BITS.psu_ok), + # Only meaningful while the output is on: with the output OFF the rig's + # TLD001 was seen to set 0x400 and report a drive current equal to its + # limit after a potentiometer change (2026-09-28). The raw bit stays in `word`. + saturated = output_enabled && has(STATUS_BITS.saturated), + open_circuit = has(STATUS_BITS.open_circuit), + # An over-range reading (decoded as Inf) counts as over range even if + # the status bit is clear, as it was on the rig. + tia_over = has(STATUS_BITS.tia_over) || photocurrent_A == Inf, + tia_under = has(STATUS_BITS.tia_under), + tia_range_A = tia_range_from_word(w), + current_mA = current_mA, + photocurrent_A = photocurrent_A, + below_threshold = below_threshold, + ) +end + +""" + check_diode_config(M, pd, properties, max_current, name) + +The construction-time invariants every [`DiodeLaser`](@ref) shares, for a +driver's inner constructor to call. Throws an `ArgumentError` naming the field +at fault: + +- `pd` is a [`PhotodiodeLoop`](@ref) exactly when `M === ConstantPhotocurrent`; +- in that mode `properties.min_power`/`max_power` are the ENFORCED bounds of + [`setoutputpower!`](@ref), so they must be finite with + `0 <= min_power < max_power`, and `max_power` must fit the amplifier's full + scale (`max_power / 1000 / wa_calibration <= tia_range`): a range the loop + cannot reach is refused here rather than at the first command; +- in that mode `max_current` is finite and positive, because it becomes the + clamp the loop is held under. +""" +function check_diode_config(::Type{M}, pd, properties, max_current::Float64, name) where {M<:RegulationMode} + if M === ConstantPhotocurrent + (isfinite(max_current) && max_current > 0) || throw(ArgumentError( + "$name: max_current is the rig's drive-current ceiling in mA, which power mode programs as the loop's clamp; it must be finite and positive, got $(max_current)")) + pd isa PhotodiodeLoop || throw(ArgumentError( + "$name: a ConstantPhotocurrent laser needs a PhotodiodeLoop (wa_calibration, tia_range, tec_stabilised); got $(repr(pd))")) + lo, hi = properties.min_power, properties.max_power + (isfinite(lo) && isfinite(hi) && 0 <= lo < hi) || throw(ArgumentError( + "$name: in ConstantPhotocurrent mode properties.min_power and max_power are the enforced output-power bounds in mW and must satisfy 0 <= min_power < max_power, got [$(lo), $(hi)]")) + full_scale_mW = pd.tia_range * pd.wa_calibration * 1000 + hi <= full_scale_mW || throw(ArgumentError( + "$name: properties.max_power = $(hi) mW needs more photocurrent than the amplifier's full scale: " * + "tia_range = $(pd.tia_range) A x wa_calibration = $(pd.wa_calibration) W/A is $(full_scale_mW) mW. " * + "Lower max_power, or select a less sensitive range on the controller (the TLD001's rear-panel DIP switch) and state it in tia_range.")) + else + pd === nothing || throw(ArgumentError( + "$name: only a ConstantPhotocurrent laser carries a PhotodiodeLoop; a $(nameof(M)) laser must have pd = nothing")) + end + return nothing +end diff --git a/src/hardware_interfaces/lightsource_interface/diode_laser_gui.jl b/src/hardware_interfaces/lightsource_interface/diode_laser_gui.jl new file mode 100644 index 0000000..c087570 --- /dev/null +++ b/src/hardware_interfaces/lightsource_interface/diode_laser_gui.jl @@ -0,0 +1,209 @@ +""" + gui(laser::DiodeLaser) + +Open the control panel for a regulated laser diode, chosen by its +[`RegulationMode`](@ref): [`current_panel`](@ref) for `ConstantCurrent`, +[`power_panel`](@ref) for `ConstantPhotocurrent`. + +`[guarantee]` opening either panel issues no command and no read. The readout is +filled by its Read button, or by the Poll toggle, which starts off. +""" +gui(laser::DiodeLaser) = _panel(regulation_mode(laser), laser) + +_panel(::ConstantCurrent, laser::DiodeLaser) = current_panel(laser) +_panel(::ConstantPhotocurrent, laser::DiodeLaser) = power_panel(laser) + +"Human-readable amplifier range: `1e-3` -> `1 mA`." +function _format_tia(range_A::Float64) + isnan(range_A) && return "unknown" + range_A >= 1e-3 ? "$(_trim(range_A * 1e3)) mA" : "$(_trim(range_A * 1e6)) µA" +end +_trim(x) = isapprox(x, round(x); atol=1e-9) ? string(Int(round(x))) : string(round(x; digits=3)) + +_format_tec(t) = t === missing ? "unknown" : (t ? "stabilised" : "not stabilised") + +"First line of an exception's message, for showing inside a panel." +_panel_message(e) = first(split(sprint(showerror, e), '\n')) + +""" + _readout_text(laser, s) + +Format a [`loop_status`](@ref) snapshot for a panel. +""" +function _readout_text(laser::DiodeLaser, s) + flags = String[] + s.output_enabled || push!(flags, "output OFF") + s.key || push!(flags, "KEY OFF") + s.interlock || push!(flags, "INTERLOCK OPEN") + s.saturated && push!(flags, "SATURATED (at current limit)") + s.open_circuit && push!(flags, "OPEN CIRCUIT") + s.tia_over && push!(flags, "TIA OVER") + s.tia_under && push!(flags, "TIA UNDER") + s.below_threshold === true && push!(flags, "BELOW THRESHOLD (not lasing)") + lines = [ + "drive current: $(round(s.current_mA; digits=2)) mA", + "photocurrent: $(round(s.photocurrent_A * 1e6; digits=2)) µA (TIA $(_format_tia(s.tia_range_A)))", + ] + if regulation_mode(laser) isa ConstantPhotocurrent && isfinite(s.photocurrent_A) + push!(lines, "indicated output power: $(round(s.photocurrent_A * laser.pd.wa_calibration * 1e3; digits=2)) mW") + end + push!(lines, isempty(flags) ? "status: ok" : "status: " * join(flags, ", ")) + return join(lines, "\n") +end + +""" + _attach_readout!(fig, row, laser) + +The shared readout block: a status text, a Read button and a Poll toggle that +is off at construction. Polling reads [`loop_status`](@ref) twice a second and +stops when the toggle is turned off or the window closes. +""" +function _attach_readout!(fig, row, laser::DiodeLaser) + readout = Label(fig[row, 1:3], "readout: not read yet (press Read, or turn on Poll)"; + halign=:left, justification=:left, tellwidth=false) + controls = GridLayout(fig[row+1, 1:3]; tellwidth=false, halign=:left) + read_button = Button(controls[1, 1], label="Read") + poll = Toggle(controls[1, 2], active=false) + Label(controls[1, 3], "Poll (2 Hz)") + + function refresh() + try + readout.text = _readout_text(laser, loop_status(laser)) + catch e + readout.text = "readout failed: " * _panel_message(e) + end + return nothing + end + on(_ -> refresh(), read_button.clicks) + + timer = Ref{Union{Nothing,Timer}}(nothing) + function stop_polling() + timer[] === nothing || close(timer[]) + timer[] = nothing + return nothing + end + on(poll.active) do active + stop_polling() + active && (timer[] = Timer(_ -> refresh(), 0.0; interval=0.5)) + end + on(fig.scene.events.window_open) do open + open || stop_polling() + end + return readout +end + +""" + _attach_setpoint!(fig, laser, range, startvalue, command!, describe) + +The slider and textbox both panels share. A slider change calls `command!(x)` +once. The textbox only moves the slider, so an entry issues one command, not +two, and lands on the slider's grid. The "commanded" line is refreshed from +`describe()` only after the command returned, so a refused request shows as +refused instead of as a value the device does not hold. + +The textbox accepts only a number within `[first(range), last(range)]`, the +same bounds the driver enforces; anything else is not applied and the box's +border turns red until it is corrected. The bounds are printed beside it. +""" +function _attach_setpoint!(fig, laser::DiodeLaser, range, startvalue, command!, describe) + lo, hi = first(range), last(range) + slider = Slider(fig[2, 1], range=range, startvalue=startvalue, width=460, linewidth=24, + color_active=:gray, halign=:left) + in_range(s) = (x = tryparse(Float64, s); x !== nothing && lo <= x <= hi) + textbox = Textbox(fig[2, 2], placeholder="$(lo) - $(hi)", validator=in_range, width=110, + bordercolor_focused_invalid=:red) + commanded = Label(fig[3, 1:3], describe(); halign=:left, tellwidth=false) + message = Label(fig[4, 1:3], ""; halign=:left, color=:firebrick, tellwidth=false) + + on(textbox.stored_string) do s + set_close_to!(slider, parse(Float64, s)) + end + # on (not lift): a hardware command must fire on a change, never at construction. + on(slider.value) do x + try + command!(x) + message.text = "" + catch e + message.text = "refused: " * _panel_message(e) + end + commanded.text = describe() + end + return message +end + +""" + _attach_output_toggle!(fig, row, laser, message) + +On/off toggle, initialised from `properties.is_on` so opening the panel does not +impose a state. The slider's lowest position is not "off"; this is. +""" +function _attach_output_toggle!(fig, row, laser::DiodeLaser, message) + toggle = Toggle(fig[row, 1], active=laser.properties.is_on, halign=:left) + Label(fig[row, 2], lift(x -> x ? "output on" : "output off", toggle.active)) + on(toggle.active) do x + try + x ? light_on(laser) : light_off(laser) + catch e + message.text = "refused: " * _panel_message(e) + end + end + return toggle +end + +function _show_panel(fig, laser::DiodeLaser) + GLMakie.activate!(title=laser.unique_id) + display(GLMakie.Screen(), fig) + return fig +end + +""" + current_panel(laser::DiodeLaser) + +Control panel for a [`ConstantCurrent`](@ref) laser: a slider in **mA** over +`min_current .. effective_max_current(laser)`, calling [`setcurrent!`](@ref), +the declared threshold, an on/off toggle, and a [`loop_status`](@ref) readout. +The slider's lowest position is `min_current`, which still emits if the rig set +it above threshold; the toggle is the off control. +""" +function current_panel(laser::DiodeLaser) + lo, hi = laser.min_current, effective_max_current(laser) + fig = Figure(size=(760, 360)) + threshold = isnan(laser.threshold_current) ? "unknown" : "$(laser.threshold_current) mA" + Label(fig[1, 1:3], "$(laser.unique_id): drive current [$(lo) mA - $(hi) mA], constant current. " * + "Threshold $(threshold)"; halign=:left, tellwidth=false) + start = isfinite(laser.drive_current) && lo <= laser.drive_current <= hi ? laser.drive_current : lo + describe() = isnan(laser.drive_current) ? "commanded: nothing yet" : + "commanded: $(laser.drive_current) mA" + message = _attach_setpoint!(fig, laser, lo:0.1:hi, start, x -> setcurrent!(laser, x), describe) + _attach_output_toggle!(fig, 5, laser, message) + _attach_readout!(fig, 6, laser) + return _show_panel(fig, laser) +end + +""" + power_panel(laser::DiodeLaser) + +Control panel for a [`ConstantPhotocurrent`](@ref) laser: a slider in **mW at +the laser output** over `properties.min_power .. max_power`, calling +[`setoutputpower!`](@ref); a basis line saying what that number is built from; +an on/off toggle; and a [`loop_status`](@ref) readout of what a power-mode user +must see -- the photocurrent with its range and over/under flags, the drive +current (a climbing current at a still setpoint is a blocked photodiode or an +ageing diode), and the `saturated` flag. +""" +function power_panel(laser::DiodeLaser) + pd = laser.pd + lo, hi = laser.properties.min_power, laser.properties.max_power + fig = Figure(size=(760, 380)) + Label(fig[1, 1:3], "$(laser.unique_id): output power at the laser output [$(lo) mW - $(hi) mW], constant photocurrent.\n" * + "Basis: photodiode x $(pd.wa_calibration) W/A, TIA $(_format_tia(pd.tia_range)), " * + "TEC $(_format_tec(pd.tec_stabilised))"; halign=:left, tellwidth=false) + start = isfinite(pd.output_power_requested) && lo <= pd.output_power_requested <= hi ? + pd.output_power_requested : lo + describe() = isnan(pd.output_power_requested) ? "commanded: nothing yet" : + "commanded: $(pd.output_power_requested) mW (loop holds $(round(pd.photocurrent_requested * 1e6; digits=3)) µA)" + message = _attach_setpoint!(fig, laser, lo:0.1:hi, start, x -> setoutputpower!(laser, x), describe) + _attach_output_toggle!(fig, 5, laser, message) + _attach_readout!(fig, 6, laser) + return _show_panel(fig, laser) +end diff --git a/src/hardware_interfaces/lightsource_interface/interface_functions.jl b/src/hardware_interfaces/lightsource_interface/interface_functions.jl index 7c7c727..86f6361 100644 --- a/src/hardware_interfaces/lightsource_interface/interface_functions.jl +++ b/src/hardware_interfaces/lightsource_interface/interface_functions.jl @@ -18,11 +18,15 @@ end Turn on the light source. +The stub used to take a second `ipower::Float64` argument that no driver +implemented. It was removed in 0.3.0 rather than implemented: "on at a power" +has no definable unit across lights, so it is two checkable calls -- set the +level, then `light_on`. + # Arguments - `lightsource::LightSource`: A LightSource type. -- `ipower::Float64`: The initial power of the light source. """ -function light_on(lightsource::LightSource, ipower::Float64) +function light_on(lightsource::LightSource) # turn on the lightsource error("light_on not implemented for $(typeof(lightsource))") end @@ -39,3 +43,154 @@ function light_off(lightsource::LightSource) # turn off the lightsource error("light_off not implemented for $(typeof(lightsource))") end + +""" + setpower(laser::DiodeLaser, x::Float64) + +Always throws, naming the replacement. `setpower` meant mA on a TCube while its +name and `power_unit` said mW, so with a power mode available the same call +could mean either; it is not defined for any `DiodeLaser`, deliberately with no +forwarder, so that no existing call silently changes unit. +""" +function setpower(laser::DiodeLaser, x::Float64) + error("setpower is not defined for $(typeof(laser)): it took mA on this driver while its name said mW. " * + "Use setcurrent!(laser, mA) on a ConstantCurrent laser, setoutputpower!(laser, mW) on a ConstantPhotocurrent " * + "laser, or the unit-free setlevel!(laser, frac) on either. This laser is $(nameof(typeof(regulation_mode(laser)))).") +end + +""" + supported_modes(::Type{<:DiodeLaser}) + +The concrete [`RegulationMode`](@ref) types this device may be constructed in, +as a tuple of types. The contract test expands a parametric device into its +instantiations through this -- a type parameter cannot be enumerated any other +way -- and a driver's inner constructor rejects a mode absent from it. +""" +supported_modes(::Type{<:DiodeLaser}) = () + +""" + setcurrent!(laser::DiodeLaser, current_mA::Float64) + +Command the diode DRIVE CURRENT, in mA. Defined only for +[`ConstantCurrent`](@ref) lasers: where a loop owns the current this has no +meaning, and the interface stub throws. +""" +function setcurrent!(laser::DiodeLaser, current_mA::Float64) + error("setcurrent! not implemented for $(typeof(laser))") +end + +""" + setoutputpower!(laser::DiodeLaser, power_mW::Float64) + +Command the optical power **at the laser output**, in mW -- the plane where the +`wa_calibration` of [`PhotodiodeLoop`](@ref) was measured with a power meter. + +This is NOT power at the sample. Everything between the laser output and the +sample -- splitters, attenuators, fibres, objectives -- belongs to the system +that owns those parts, and no driver holds a field describing any of it. + +Defined only for [`ConstantPhotocurrent`](@ref) lasers, where the number is the +driver's own arithmetic over the photodiode calibration: a command, not a +measurement. +""" +function setoutputpower!(laser::DiodeLaser, power_mW::Float64) + error("setoutputpower! not implemented for $(typeof(laser))") +end + +""" + setlevel!(light::LightSource, frac::Float64) + +Set the light to `frac` of its own declared operating range, `frac` in `0..1`. +Unit-free BY CONSTRUCTION: the endpoints come from the device, so no caller +names a unit it cannot check. + +For a [`DiodeLaser`](@ref) the mapping is LINEAR IN THE REGULATED QUANTITY: + +- [`ConstantCurrent`](@ref): `min_current + frac * (effective_max_current - min_current)` mA, + through [`setcurrent!`](@ref); +- [`ConstantPhotocurrent`](@ref): `min_power + frac * (max_power - min_power)` mW + at the laser output, from `properties`, through [`setoutputpower!`](@ref). + The mW-to-photocurrent conversion is linear, so this is linear in the + regulated photocurrent too. + +`frac = 0.0` is the bottom of the declared range, which is NOT off: use +`light_off`. Code that must survive a switch between the two modes should drive +the laser through this, not through the unit-true setters. + +`[limitation]` Methods ship only for `DiodeLaser`. The voltage-modulated lights +have no `setlevel!` method yet, and this stub throws for them. +""" +function setlevel!(light::LightSource, frac::Float64) + error("setlevel! not implemented for $(typeof(light))") +end + +""" + measured_current(laser::DiodeLaser) + +The diode drive current the controller reports, in mA. A reading, not a +request. In [`ConstantPhotocurrent`](@ref) mode it is **the diagnostic**: a drive +current climbing at a still setpoint is a blocked photodiode or an ageing diode. +""" +function measured_current(laser::DiodeLaser) + error("measured_current not implemented for $(typeof(laser))") +end + +""" + measured_photocurrent(laser::DiodeLaser) + +The monitor photodiode current the controller reports, in A. The regulated +quantity in [`ConstantPhotocurrent`](@ref) mode. +""" +function measured_photocurrent(laser::DiodeLaser) + error("measured_photocurrent not implemented for $(typeof(laser))") +end + +""" + indicated_output_power(laser::DiodeLaser) + +`measured_photocurrent x wa_calibration`, in mW at the laser output. The inverse +of [`setoutputpower!`](@ref)'s conversion, so a caller never recomputes it. + +It is INDICATED, not measured: a conversion through a one-time bench +calibration. With a drifting photodiode responsivity it stays flat while the +light moves. Defined only for [`ConstantPhotocurrent`](@ref) lasers. +""" +function indicated_output_power(laser::DiodeLaser) + error("indicated_output_power not implemented for $(typeof(laser))") +end + +""" + loop_status(laser::DiodeLaser) + +One consistent snapshot of the controller, as a `NamedTuple`: + +- `word::UInt32`: the raw status word; +- `output_enabled`, `key`, `interlock`, `closed_loop`, `psu_ok`: `Bool`; +- `current_mA`: `[limitation]` with the output OFF this is not a measurement: the + 642 nm rig's TLD001 reported a "drive current" equal to its limit after a + potentiometer change, with no current flowing (2026-09-28). +- `saturated::Bool`: the drive current is at its limit (bit `0x400`), reported + only while the output is enabled (the raw bit is still in `word`). In + [`ConstantPhotocurrent`](@ref) mode this means *the loop is at the clamp and + the power is not being held*; +- `open_circuit`, `tia_over`, `tia_under`: `Bool`. Either TIA flag invalidates + the photocurrent reading; +- `tia_range_A::Float64`: the amplifier range the controller reports, `NaN` + unless exactly one range bit is set; +- `current_mA::Float64`, `photocurrent_A::Float64`: the two readings; +- `below_threshold::Union{Bool,Missing}`: `true` when the output is enabled, a + level has been commanded and the drive current sits below the declared + `threshold_current`, i.e. the diode is not lasing and every power number is + junk -- a fault distinct from `saturated`. `false` when the output is off or + nothing has been commanded. `missing` when `threshold_current` is unknown + (`NaN`): a skipped check must not read as a passed one. + +It is the only place the status bits are decoded, so panels, fault checks and a +rig's logger share one definition. `[policy]` a rig running unattended polls +this and treats a sustained `saturated` as a fault: the driver reports, the +system decides. +""" +function loop_status(laser::DiodeLaser) + error("loop_status not implemented for $(typeof(laser))") +end + diff --git a/src/hardware_interfaces/lightsource_interface/interface_types.jl b/src/hardware_interfaces/lightsource_interface/interface_types.jl index 90ff28a..9f01dee 100644 --- a/src/hardware_interfaces/lightsource_interface/interface_types.jl +++ b/src/hardware_interfaces/lightsource_interface/interface_types.jl @@ -21,4 +21,119 @@ mutable struct LightSourceProperties is_on::Bool min_power::Float64 max_power::Float64 -end \ No newline at end of file +end + +""" + DiodeLaser <: LightSource + +A laser diode driven by a controller that REGULATES something: drive current, +or monitor photocurrent under photodiode feedback. Every `DiodeLaser` declares +which, as a type parameter fixed at construction; see [`RegulationMode`](@ref). + +A light that is only modulated by an analogue voltage (`CrystaLaser`, +`VortranLaser`, `DaqTrLight`) is NOT a `DiodeLaser`. It has no controller, no +loop and no readback, and none of the generics in this file are defined for it. + +A `DiodeLaser` carries, by name, the fields the shared panels and +[`setlevel!`](@ref) read: `unique_id`, `properties`, `min_current`, +`max_current`, `threshold_current`, `drive_current` and `pd`. + +`[guarantee]` `setpower` is not defined for any `DiodeLaser`: its unit changed +with the regulation mode, so it is replaced by [`setcurrent!`](@ref), +[`setoutputpower!`](@ref) and the unit-free [`setlevel!`](@ref). + +`[limitation]` `InteractiveUtils.subtypes` is one level deep, so +`subtypes(LightSource)` lists `DiodeLaser` and not the drivers beneath it. Walk +the hierarchy to its non-abstract leaves to enumerate devices. +""" +abstract type DiodeLaser <: LightSource end + +""" + RegulationMode + +The quantity a [`DiodeLaser`](@ref)'s controller holds constant: a type, not a +flag, so that it is fixed per instance and can be dispatched on. The two modes +are [`ConstantCurrent`](@ref) and [`ConstantPhotocurrent`](@ref). +""" +abstract type RegulationMode end + +""" + ConstantCurrent <: RegulationMode + +The loop holds the diode DRIVE CURRENT constant (open loop, in Thorlabs' +terms). Optical power follows diode efficiency, which drifts with temperature +and age. Commanded with [`setcurrent!`](@ref), in mA. +""" +struct ConstantCurrent <: RegulationMode end + +""" + ConstantPhotocurrent <: RegulationMode + +The loop holds the MONITOR PHOTOCURRENT constant (closed loop, "constant power" +in Thorlabs' terms). Optical power follows photodiode responsivity, which is +temperature dependent: without a TEC-stabilised mount the delivered power +drifts against a still setpoint. Commanded with [`setoutputpower!`](@ref), in mW +at the laser output, through the calibration held in [`PhotodiodeLoop`](@ref). + +There is deliberately no `ConstantPower`: the loop does not hold optical power +anywhere, and the name would say it did. +""" +struct ConstantPhotocurrent <: RegulationMode end + +""" + PhotodiodeLoop + +State of a monitor-photodiode regulation loop. Present on a +[`ConstantPhotocurrent`](@ref) laser and on no other (`laser.pd === nothing`). + +The loop regulates PHOTOCURRENT. `wa_calibration` converts photocurrent into a +power NUMBER at the LASER OUTPUT -- the plane where it was measured with a power +meter -- and it does not make the loop hold optical power anywhere. Without a +TEC-stabilised mount the photodiode's responsivity drifts with temperature, so +delivered power drifts while photocurrent is held steady. That is why +`tec_stabilised` has no default and may be `missing`. + +# Fields +- `wa_calibration::Float64`: W/A at the laser output, rig-measured with a power + meter. Conversion and display only. +- `tia_range::Float64`: A, full scale of the photodiode amplifier. The rig's + STATEMENT of the controller's range setting (a rear-panel DIP switch on the + TLD001); `initialize` throws if the controller disagrees. +- `tec_stabilised::Union{Bool,Missing}`: rig fact. `missing` is legitimate and + is the only honest answer before it has been checked. +- `max_current_clamp::Float64`: mA. The drive-current clamp `initialize` + programmed, as READ BACK from the controller, never as requested. `NaN` until + `initialize` has programmed and verified it; a laser whose clamp is `NaN` + refuses [`setoutputpower!`](@ref). +- `output_power_requested::Float64`: mW at the laser output, the last request + [`setoutputpower!`](@ref) completed. `NaN` before the first. +- `photocurrent_requested::Float64`: A. The DECODED setpoint the loop was + actually given, which the downward-rounding encoding makes differ from + `output_power_requested / wa_calibration`. `NaN` before the first command. + +Construct it with the keyword form, which fills the last three fields. +""" +mutable struct PhotodiodeLoop + wa_calibration::Float64 + tia_range::Float64 + tec_stabilised::Union{Bool,Missing} + max_current_clamp::Float64 + output_power_requested::Float64 + photocurrent_requested::Float64 +end + +""" + PhotodiodeLoop(; wa_calibration, tia_range, tec_stabilised) + +All three are required, and none has a default: a power-mode laser cannot be +built without a measured calibration, a stated amplifier range and an answer +(possibly `missing`) to whether the diode's temperature is stabilised. +""" +function PhotodiodeLoop(; wa_calibration::Real, tia_range::Real, tec_stabilised::Union{Bool,Missing}) + (isfinite(wa_calibration) && wa_calibration > 0) || throw(ArgumentError( + "PhotodiodeLoop: wa_calibration is W/A measured at the laser output and must be finite and positive, got $(wa_calibration)")) + (isfinite(tia_range) && tia_range > 0) || throw(ArgumentError( + "PhotodiodeLoop: tia_range is the photodiode amplifier's full scale in A and must be finite and positive, got $(tia_range)")) + return PhotodiodeLoop(Float64(wa_calibration), Float64(tia_range), tec_stabilised, NaN, NaN, NaN) +end + diff --git a/src/skills.jl b/src/skills.jl index d5d2701..40a4a1c 100644 --- a/src/skills.jl +++ b/src/skills.jl @@ -44,6 +44,27 @@ end # with. _sig_parameters(m::Method) = Base.unwrap_unionall(m.sig).parameters +# Devices are the non-abstract leaves. `subtypes` is one level deep, so an +# abstract intermediate such as `DiodeLaser` would hide every driver beneath it +# from the map. `isabstracttype`, not `isconcretetype`: a parametric driver +# (`TCubeLaser`, a `UnionAll`) must be kept. +function _device_types(T) + out = Any[] + for S in InteractiveUtils.subtypes(T) + isabstracttype(S) ? append!(out, _device_types(S)) : push!(out, S) + end + return out +end + +# A method belongs to device `T` if it is written against `T` itself or, for a +# parametric device, against one of its instantiations (`TCubeLaser{ConstantCurrent}`). +_is_device_param(p, T) = p === T || + (T isa UnionAll && p isa DataType && p.name === Base.unwrap_unionall(T).name) + +# `TCubeLaser`, or `TCubeLaser{ConstantPhotocurrent}` for an instantiation. +_type_label(p) = p isa DataType && !isempty(p.parameters) && all(x -> x isa Type, p.parameters) ? + string(nameof(p), "{", join(nameof.(p.parameters), ", "), "}") : string(nameof(p)) + # ---- Path containment ----------------------------------------------------- # # A skill name or relative file path can come from data this package does @@ -254,7 +275,7 @@ function _generate_api_map() println(io) println(io, "## $(nameof(iface))") - devtypes = [T for T in InteractiveUtils.subtypes(iface) + devtypes = [T for T in _device_types(iface) if nameof(T) !== :StageFormat && !startswith(String(nameof(T)), "_")] for T in devtypes @@ -267,7 +288,7 @@ function _generate_api_map() inherited_method = Dict{Symbol,Method}() for fname in exported_functions f = getfield(MC, fname) - if any(m -> length(_sig_parameters(m)) >= 2 && _sig_parameters(m)[2] === T, methods(f)) + if any(m -> length(_sig_parameters(m)) >= 2 && _is_device_param(_sig_parameters(m)[2], T), methods(f)) push!(device_specific, fname) elseif hasmethod(f, Tuple{T}) m = which(f, Tuple{T}) @@ -278,7 +299,9 @@ function _generate_api_map() # "not this device's own code" and worth surfacing, so a # missing lifecycle method (e.g. ThorCamCSCCamera's # `initialize`) is visible instead of silently absent. - if length(params) >= 2 && (params[2] === iface || params[2] === MC.AbstractInstrument) + # An intermediate abstract type (`DiodeLaser`) counts as the interface too. +if length(params) >= 2 && (params[2] === iface || params[2] === MC.AbstractInstrument || + (params[2] isa Type && params[2] <: iface && T <: params[2] && params[2] !== T)) inherited_method[fname] = m push!(_is_contract_stub_file(String(m.file)) ? stub : shared, fname) end @@ -295,9 +318,9 @@ function _generate_api_map() f = getfield(MC, fname) for m in methods(f) params = _sig_parameters(m) - (length(params) >= 2 && params[2] === T) || continue + (length(params) >= 2 && _is_device_param(params[2], T)) || continue args = join(params[3:end], ", ") - println(io, "- `$(fname)($(nameof(T))$(isempty(args) ? "" : ", " * args))`") + println(io, "- `$(fname)($(_type_label(params[2]))$(isempty(args) ? "" : ", " * args))`") end end end diff --git a/test/contract.jl b/test/contract.jl index 36494b7..64dbaee 100644 --- a/test/contract.jl +++ b/test/contract.jl @@ -38,7 +38,24 @@ function has_specific_method(f, T, argtypes...) sig = Tuple{T, argtypes...} hasmethod(f, sig) || return false m = which(f, sig) - return m.sig.parameters[2] === T + # A `where`-method has a `UnionAll` signature, whose `.parameters` throws; + # unwrap it so the gate fails rather than errors on one. + p = m.sig isa UnionAll ? Base.unwrap_unionall(m.sig).parameters[2] : m.sig.parameters[2] + return p === T +end + +# Devices are the non-abstract leaves. `subtypes` is one level deep, so an +# abstract intermediate (`DiodeLaser`) hides every driver beneath it from any +# loop over `subtypes(iface)`: `TCubeLaser` would silently stop being tested the +# moment it moved under one. `isabstracttype`, not `isconcretetype`: a +# parametric driver such as `TCubeLaser` is a `UnionAll`, not concrete, and +# must be kept. +function device_types(T) + out = Any[] + for S in subtypes(T) + isabstracttype(S) ? append!(out, device_types(S)) : push!(out, S) + end + return out end @testset "Interface Contract" begin @@ -90,9 +107,8 @@ end # 1-arg contract call never reached it and fell through to the # (throwing) instrument-level stub. As of 0.2.3 the 1-arg method exists # and the exclusion is gone with it, which is what makes the generic - # assertion below cover this type. The 2-arg form survives as a - # deprecated forwarder, so the assertion below is about which method - # answers a 1-arg call, not about the other one being absent. + # assertion below cover this type. The 2-arg form was a deprecated + # forwarder until 0.3.0 removed it. no_core_methods = Set([:MLSLM]) no_initialize = Set([:ThorCamCSCCamera]) no_shutdown = Set{Symbol}() @@ -102,7 +118,7 @@ end @testset "Core AbstractInstrument contract" begin for iface in interfaces - for T in subtypes(iface) + for T in device_types(iface) nameof(T) === :StageFormat && continue # not a device, a config format nameof(T) === :_ContractDummyStage && continue # test fixture, not a device T === _ShadowFixture.S && continue # shadow-guard fixture, not a device @@ -131,7 +147,9 @@ end @testset "No shadowed generics in submodules" begin generics = (:gui, :initialize, :shutdown, :export_state, :move, :getposition, :getrange, :stopmotion, :home, :servo, :capture, :getdata, :getlastframe, :abort, :live, :sequence, :setexposuretime!, :setroi!, :settriggermode!, - :setpower, :light_on, :light_off, :setdrivevoltage, :getdrivevoltage, :settransmission, :gettransmission) + :setpower, :light_on, :light_off, :setcurrent!, :setoutputpower!, :setlevel!, + :measured_current, :measured_photocurrent, :indicated_output_power, :loop_status, + :regulation_mode, :supported_modes, :setdrivevoltage, :getdrivevoltage, :settransmission, :gettransmission) # PI (pi_stage) deliberately names its low-level ccall wrappers # `move`, `getposition`, `getrange`, `stopmotion` and `servo` -- the @@ -207,32 +225,53 @@ end end @testset "LightSource contract" begin - for T in subtypes(MC.LightSource) - @test has_specific_method(MC.setpower, T, Float64) - # `light_on`'s own docstring and every driver implement the 1-arg - # `light_on(::T)` form, but the interface stub in - # lightsource_interface/interface_functions.jl is declared with a - # second `ipower::Float64` argument that no driver actually takes. - # `MC.light_on(light, power)` therefore throws for every - # LightSource today -- a pre-existing interface/driver arity - # mismatch, not something to paper over with a fake 2-arg wrapper. - # Tracked as broken rather than skipped so a real fix flips it. - @test_broken has_specific_method(MC.light_on, T, Float64) - @test has_specific_method(MC.light_on, T) # the arity drivers actually implement + lights = device_types(MC.LightSource) + # The regression the hierarchy made possible: `subtypes(LightSource)` + # lists `DiodeLaser`, not the drivers beneath it. If the walk ever + # loses them again, this fails instead of coverage quietly vanishing. + @test :TCubeLaser in nameof.(lights) + @test :SimDiodeLaser in nameof.(lights) + @test :DiodeLaser ∉ nameof.(lights) + + for T in lights + # Mode-shared methods are written against the bare (UnionAll) type. + # The 2-arg `light_on(light, ipower)` stub was removed in 0.3.0: + # "on at a power" has no definable unit across lights. + @test has_specific_method(MC.light_on, T) @test has_specific_method(MC.light_off, T) + @test !hasmethod(MC.light_on, Tuple{T,Float64}) + if T <: MC.DiodeLaser + modes = MC.supported_modes(T) + @test !isempty(modes) + for M in modes + TM = T{M} + # Asserted by value: `regulation_mode` is a `where` method. + @test MC.regulation_mode(TM) isa M + # Mode-specific methods are written against the instantiation. + if M === MC.ConstantCurrent + @test has_specific_method(MC.setcurrent!, TM, Float64) + @test !has_specific_method(MC.setoutputpower!, TM, Float64) + else + @test has_specific_method(MC.setoutputpower!, TM, Float64) + @test has_specific_method(MC.indicated_output_power, TM) + @test !has_specific_method(MC.setcurrent!, TM, Float64) + end + # `setlevel!` is the shared DiodeLaser method, never the + # LightSource stub; `setpower` is the DiodeLaser refusal, + # never a driver method. + @test which(MC.setlevel!, Tuple{TM,Float64}).sig.parameters[2] === MC.DiodeLaser + @test which(MC.setpower, Tuple{TM,Float64}).sig.parameters[2] === MC.DiodeLaser + end + for f in (MC.measured_current, MC.measured_photocurrent, MC.loop_status) + @test has_specific_method(f, T) + end + else + @test has_specific_method(MC.setpower, T, Float64) + end end - - # `export_state` arity, named for the type it was actually wrong on. - # The generic loop above now covers `TCubeLaser` (it is no longer in - # `no_export_state`). The bug was that the only method took an unused - # second positional argument, so `export_state(laser)` matched nothing - # on this type and fell through to the throwing stub; adding the 1-arg - # method is the whole fix. The 2-arg form is deliberately still here as - # a deprecated forwarder (removal scheduled for 0.3.0), so assert both: - # the 1-arg form dispatches to this type, and the 2-arg form is a - # method on this type rather than the generic stub it used to shadow. @test has_specific_method(MC.export_state, MC.TCubeLaser) - @test has_specific_method(MC.export_state, MC.TCubeLaser, Any) + # The 2-arg deprecated forwarder was removed in 0.3.0. + @test !hasmethod(MC.export_state, Tuple{MC.TCubeLaser,Any}) end @testset "Stub throws" begin diff --git a/test/gui.jl b/test/gui.jl index da226f2..9d217b0 100644 --- a/test/gui.jl +++ b/test/gui.jl @@ -118,4 +118,33 @@ MicroscopeControl.light_off(light::RecordingLight) = (push!(light.log, :light_of @test state_snapshot(stage) == before end end + + # The DiodeLaser panels extend the no-commands-on-open rule to reads: a + # panel must issue neither a command nor a poll until the user asks. + # `SimDiodeLaser` logs reads as well as commands, and the fake Kinesis SDK + # records every call, so both halves are assertable on both. + @testset "Diode laser panels issue nothing on open" begin + cp_props() = LightSourceProperties("mW", 0.0, false, 1.0, 70.0) + sims = (SimDiodeLaser(; mode=ConstantCurrent(), min_current=70.0, max_current=150.0), + SimDiodeLaser(; mode=ConstantPhotocurrent(), wa_calibration=224.2, tia_range=1e-3, + tec_stabilised=missing, properties=cp_props())) + for sim in sims + initialize(sim) + empty!(sim.log) + before = state_snapshot(sim) + gui(sim) + GLMakie.closeall() + @test isempty(sim.log) + @test isequal(state_snapshot(sim), before) + end + + FakeKinesis.reset!() + for laser in (TCubeLaser("00000000"; mode=ConstantCurrent()), + TCubeLaser("00000000"; mode=ConstantPhotocurrent(), wa_calibration=224.2, + tia_range=1e-3, tec_stabilised=missing, properties=cp_props())) + gui(laser) + GLMakie.closeall() + end + @test isempty(FakeKinesis.calls) + end end diff --git a/test/runtests.jl b/test/runtests.jl index 5faf0e5..69a85ef 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -115,12 +115,32 @@ include("tcube_fake_sdk.jl") # unmodified against the recorder installed by `tcube_fake_sdk.jl`, which # is what makes their *own* control flow (not a helper's) a thing the suite # can fail on. What no test here can tell you is how a real controller - # answers: see CHANGELOG 0.2.3, hardware verification NOT DONE. + # answers: see CHANGELOG 0.2.3 and 0.3.0, hardware verification NOT DONE. @testset "TCube Laser (no hardware)" begin TCube = MicroscopeControl.HardwareImplementations.TCubeLaserControl + # Open-loop and closed-loop lasers as the rigs would build them. The + # closed-loop numbers are the 642 nm rig's measured calibration. + cc(; kw...) = TCubeLaser("00000000"; mode=ConstantCurrent(), kw...) + cp_props() = LightSourceProperties("mW", 0.0, false, 1.0, 70.0) + cp(; kw...) = TCubeLaser("00000000"; mode=ConstantPhotocurrent(), wa_calibration=224.2, + tia_range=1e-3, tec_stabilised=missing, properties=cp_props(), kw...) @testset "Constructor defaults" begin - laser = TCubeLaser("00000000") + # `mode` is required and has no default, so no construction line + # changes meaning when a default is eventually chosen. + err = try + TCubeLaser("00000000") + nothing + catch e + e + end + @test err isa ArgumentError + @test occursin("mode", err.msg) + + laser = cc() + @test laser isa TCubeLaser{ConstantCurrent} + @test laser isa DiodeLaser + @test regulation_mode(laser) === ConstantCurrent() # 60.0 mA used to be the default floor: not a floor at all, since # it is a *lower* bound, so it only rejected safe small currents. @test laser.min_current == 0.0 @@ -128,26 +148,25 @@ include("tcube_fake_sdk.jl") # `initialize` records the controller's limit here, not over the # caller's `max_current`; NaN means "not read yet". @test isnan(laser.controller_max_current) - # `setpower` takes mA and records what it accepted here; NaN means - # "nothing commanded yet". + # `setcurrent!` records what it accepted here; NaN = nothing yet. @test isnan(laser.drive_current) - # The deprecated derived-power pair is unchanged from 0.2.2: the - # label is still "mW" and the figure is still the linear guess, so - # a rig reading them across this upgrade reads what it read before. - @test laser.properties.power_unit == "mW" - @test laser.properties.power == 0.0 + @test isnan(laser.threshold_current) + @test laser.pd === nothing + # The unit label no longer says mW for a laser commanded in mA. + @test laser.properties.power_unit == "mA" @test laser.daq_device === nothing @test laser.ao_channel === nothing end @testset "Positional construction, old arity and new" begin - # The four fields added since 0.2.2 sit at the end of the struct so - # that the ten-argument positional call a pre-0.2.3 caller wrote - # still constructs, with the new fields defaulted. Deleting the - # ten-argument inner constructor must fail this. + # The fields added since 0.2.2 sit at the end of the struct so that + # the ten- and fourteen-argument positional calls a pre-0.3.0 + # caller wrote still construct -- as open-loop lasers, since only + # the keyword form can supply a photodiode calibration. props = LightSourceProperties("mW", 0.0, false, 0.0, 100.0) old = TCubeLaser("TCubeLaser", props, "red", 0.0, 160.0, 220.0, 32767.0, "00000000", 0, NIdaq()) + @test old isa TCubeLaser{ConstantCurrent} @test old.serialNo == "00000000" @test old.max_current == 160.0 @test old.max_setcurrent == 220.0 # slot 6, as in 0.2.2 @@ -156,24 +175,64 @@ include("tcube_fake_sdk.jl") @test old.daq_device === nothing @test old.ao_channel === nothing @test isnan(old.drive_current) + @test isnan(old.threshold_current) + @test old.pd === nothing - # ... and the full arity names the new fields explicitly. full = TCubeLaser("TCubeLaser", props, "red", 0.0, 160.0, 220.0, 32767.0, "00000000", 0, NIdaq(), 150.0, "Dev2", "Dev2/ao1", 40.0) + @test full isa TCubeLaser{ConstantCurrent} @test full.controller_max_current == 150.0 @test full.daq_device == "Dev2" @test full.ao_channel == "Dev2/ao1" @test full.drive_current == 40.0 - # Neither positional form runs any check the keyword constructor - # runs, and neither does the keyword constructor refuse a label: - # `power_unit` is documentation, not an enforced invariant. - @test TCubeLaser("00000000"; properties=LightSourceProperties("mA", 0.0, false, 0.0, 220.0)).properties.power_unit == "mA" + # A caller-supplied `properties` is kept as given. + @test cc(; properties=LightSourceProperties("mA", 0.0, false, 0.0, 220.0)).properties.max_power == 220.0 + end + + @testset "Power-mode construction refuses what it cannot run" begin + laser = cp() + @test laser isa TCubeLaser{ConstantPhotocurrent} + @test regulation_mode(laser) === ConstantPhotocurrent() + @test laser.pd isa PhotodiodeLoop + @test laser.pd.wa_calibration == 224.2 + @test laser.pd.tia_range == 1e-3 + @test laser.pd.tec_stabilised === missing + # Nothing has been programmed or commanded yet. + @test isnan(laser.pd.max_current_clamp) + @test isnan(laser.pd.output_power_requested) + @test isnan(laser.pd.photocurrent_requested) + @test isnan(laser.drive_current) # the loop owns the current + + msg(f) = try + f() + "" + catch e + e isa ArgumentError || rethrow() + e.msg + end + # Every calibration keyword is required, and the message names the absent ones. + m = msg(() -> TCubeLaser("00000000"; mode=ConstantPhotocurrent(), wa_calibration=224.2)) + @test occursin("tia_range", m) && occursin("tec_stabilised", m) && occursin("properties", m) + @test !occursin("wa_calibration,", m) + # A photodiode calibration on an open-loop laser is a contradiction. + @test occursin("ConstantPhotocurrent", msg(() -> cc(; wa_calibration=224.2))) + # Only the TLD001's four amplifier ranges exist. + @test occursin("DIP", msg(() -> cp(; tia_range=2e-3))) + # A power range the amplifier cannot reach is refused up front: + # 224.2 W/A x 1 mA is 224.2 mW of full scale, so 300 mW is not reachable. + @test occursin("full scale", msg(() -> cp(; properties=LightSourceProperties("mW", 0.0, false, 1.0, 300.0)))) + @test occursin("min_power", msg(() -> cp(; properties=LightSourceProperties("mW", 0.0, false, 5.0, 5.0)))) + # A ceiling below the potentiometer's lowest position cannot be clamped. + @test occursin("potentiometer", msg(() -> cp(; max_current=10.0))) + # A requested drive current would be a fiction in closed loop. + @test occursin("drive_current", msg(() -> cp(; drive_current=40.0))) + @test_throws ArgumentError PhotodiodeLoop(; wa_calibration=-1.0, tia_range=1e-3, tec_stabilised=true) end @testset "Current validation" begin - laser = TCubeLaser("00000000") + laser = cc() @test TCube.check_current(laser, 100.0) == 100.0 @test TCube.check_current(laser, 0.0) == 0.0 @@ -193,12 +252,12 @@ include("tcube_fake_sdk.jl") @test occursin("0.0", err.msg) # A caller floor is honoured. - floored = TCubeLaser("00000000"; min_current=20.0) + floored = cc(; min_current=20.0) @test_throws ArgumentError TCube.check_current(floored, 10.0) # Above the setpoint DAC's full scale is rejected as out of range # rather than dying in `UInt16(...)` with an InexactError. - wide = TCubeLaser("00000000"; max_current=400.0) + wide = cc(; max_current=400.0) @test TCube.effective_max_current(wide) == wide.max_setcurrent @test_throws ArgumentError TCube.check_current(wide, 300.0) end @@ -207,10 +266,9 @@ include("tcube_fake_sdk.jl") # The 1b-bis regression: `initialize` used to assign the # controller's limit straight over `max_current`, so a rig that # asked for 80 mA on a weak diode silently got the controller's - # 160-220 mA, and `setpower` then validated against the - # controller. `record_controller_limit!` is the field-writing half + # 160-220 mA. `record_controller_limit!` is the field-writing half # of `initialize` with the SDK read removed. - laser = TCubeLaser("00000000"; max_current=80.0) + laser = cc(; max_current=80.0) raw = UInt16(round(160.0 / laser.max_setcurrent * laser.max_setpoint)) TCube.record_controller_limit!(laser, raw) @@ -222,99 +280,127 @@ include("tcube_fake_sdk.jl") @test_throws ArgumentError TCube.check_current(laser, 100.0) # ... and the controller wins when it is the stricter of the two. - strict = TCubeLaser("00000000"; max_current=200.0) + strict = cc(; max_current=200.0) TCube.record_controller_limit!(strict, UInt16(round(120.0 / strict.max_setcurrent * strict.max_setpoint))) @test TCube.effective_max_current(strict) ≈ 120.0 rtol = 1e-3 @test_throws ArgumentError TCube.check_current(strict, 150.0) end - @testset "setpower rejects before touching the SDK" begin - # Ordering, not just rejection. `setpower`'s first statement is - # the range check, so an out-of-range request must fail with the - # check's own `ArgumentError`. Under the old code the check was an - # `@error` log and execution continued, so the exception *type* is - # what distinguishes "refused" from "attempted". - # - # 200.0 mA is the case that matters and the reason it is here - # rather than 500.0 alone: it is over the 160 mA ceiling but under - # `max_setcurrent`, so it converts to a valid `UInt16` setpoint and - # the old code carried it all the way into the - # `LD_SetLaserSetPoint` ccall (executed against the pre-fix driver: - # `ErrorException`, "could not load library ...LaserDiode.dll" -- - # on a Windows rig that call would have driven 200 mA into the - # diode). 500.0 and -5.0 happen to die earlier, in `UInt16(...)` - # with an `InexactError`, which is a crash rather than a refusal. + @testset "setcurrent! rejects before touching the SDK" begin + # Ordering, not just rejection. The range check is the first + # statement, so an out-of-range request fails with the check's own + # `ArgumentError` and reaches nothing. 200.0 mA is the case that + # matters: over the 160 mA ceiling but under `max_setcurrent`, so + # it converts to a valid setpoint -- before v0.2.3 it was sent. FakeKinesis.reset!() - laser = TCubeLaser("00000000") - @test_throws ArgumentError setpower(laser, 200.0) - @test_throws ArgumentError setpower(laser, 500.0) - @test_throws ArgumentError setpower(laser, -5.0) - # A refused request must not be recorded as the laser's state. - # The old code wrote it before the call: `setpower(laser, 200.0)` - # left `properties.power == 125.0` (executed). - @test laser.properties.power == 0.0 + laser = cc() + @test_throws ArgumentError setcurrent!(laser, 200.0) + @test_throws ArgumentError setcurrent!(laser, 500.0) + @test_throws ArgumentError setcurrent!(laser, -5.0) @test isnan(laser.drive_current) # nothing was commanded - # And "before touching the SDK" is now an observation rather than - # an inference: the fake records every setpoint it is handed. @test isempty(FakeKinesis.setpoints) @test isempty(FakeKinesis.calls) end + @testset "setpower is gone, and says what replaced it" begin + # `setpower` took mA while its name and power_unit said mW. With a + # power mode available the same call could mean either, so it is + # not defined -- deliberately with no forwarder -- and the error + # names the replacements. It reaches nothing. + FakeKinesis.reset!() + for laser in (cc(), cp()) + err = try + setpower(laser, 80.0) + nothing + catch e + e + end + @test err isa ErrorException + @test occursin("setcurrent!", err.msg) && occursin("setoutputpower!", err.msg) + @test occursin(string(nameof(typeof(regulation_mode(laser)))), err.msg) + end + @test isempty(FakeKinesis.calls) + # ... and each unit-true setter exists only in its own mode. + @test_throws "not implemented" setoutputpower!(cc(), 10.0) + @test_throws "not implemented" setcurrent!(cp(), 80.0) + @test_throws "not implemented" indicated_output_power(cc()) + @test isempty(FakeKinesis.calls) + end + @testset "initialize keeps the caller's ceiling (fake SDK)" begin # The regression this driver was fixed for lives inside # `initialize`, so this runs the real `initialize` against the fake # Kinesis SDK. Restoring the old `light.max_current = ...` - # assignment must fail this testset; testing - # `record_controller_limit!` alone did not, which is why this - # exists. + # assignment must fail this testset. FakeKinesis.reset!() # controller reports a 160 mA limit - laser = TCubeLaser("00000000"; max_current=80.0) + laser = cc(; max_current=80.0) initialize(laser) @test FakeKinesis.calls == ["TLI_BuildDeviceList", "TLI_GetDeviceListSize", - "LD_Open", "LD_SetOpenLoopMode", "LD_RequestReadings", + "LD_Open", "LD_StartPolling", "LD_SetOpenLoopMode", "LD_RequestReadings", "LD_RequestLaserDiodeMaxCurrentLimit", "LD_GetLaserDiodeMaxCurrentLimit"] @test laser.max_current == 80.0 # survived initialize @test laser.controller_max_current ≈ 160.0 rtol = 1e-3 @test TCube.effective_max_current(laser) == 80.0 - # The consequence, which is the point: a post-initialize request - # between the caller's ceiling and the controller's is refused, and - # nothing reaches the SDK. + # A post-initialize request between the caller's ceiling and the + # controller's is refused, and nothing reaches the SDK. empty!(FakeKinesis.setpoints) - @test_throws ArgumentError setpower(laser, 100.0) + @test_throws ArgumentError setcurrent!(laser, 100.0) + @test isempty(FakeKinesis.setpoints) + # ... while one under the caller's ceiling is accepted. With the + # output OFF it is only recorded: the controller would ignore it. + # 40 mA is floor(40/220*32767) = 5957, which decodes to 39.9957 mA. + empty!(FakeKinesis.calls) + setcurrent!(laser, 40.0) @test isempty(FakeKinesis.setpoints) - # ... while one under the caller's ceiling is sent. The exact code - # matters, not just that a call happened: sending code 0 for this - # accepted request passed every assertion here until the setpoint - # itself was pinned. 40 mA is floor(40/220*32767) = 5957, which - # decodes to 39.9957 mA -- at or below the request, as always. - setpower(laser, 40.0) + @test FakeKinesis.calls == ["LD_GetStatusBits"] + @test laser.drive_current == 40.0 # the accepted current, in mA + # light_on enables, THEN sends it, and confirms it. + empty!(FakeKinesis.calls) + light_on(laser) + @test FakeKinesis.calls == ["LD_EnableOutput", "LD_SetLaserSetPoint", "LD_GetLaserSetPoint"] @test FakeKinesis.setpoints == [UInt16(5957)] + @test FakeKinesis.setpoint_held[] == 5957 @test Float64(5957) / laser.max_setpoint * laser.max_setcurrent <= 40.0 - @test laser.drive_current == 40.0 # the accepted current, in mA - # The deprecated derived figure divides by the controller's limit - # once `initialize` has read it -- which is the value 0.2.2's - # `max_current` held at this point, since `initialize` overwrote - # it. Same number, from a field that is no longer destroyed. - @test laser.properties.power == 40.0 * laser.properties.max_power / laser.controller_max_current - @test laser.properties.power ≈ 25.0 rtol = 1e-3 - - # A second initialize, with a different controller reading. One - # fixture does not establish that the reading is what determines - # the stored limit: replacing the recording with a constant 160.0 - # while still calling the SDK passed everything above. This limit - # is also *below* the caller's ceiling, so the `min` in + # With the output on, a new current is sent and confirmed at once. + empty!(FakeKinesis.calls) + setcurrent!(laser, 30.0) + @test FakeKinesis.calls == ["LD_GetStatusBits", "LD_SetLaserSetPoint", "LD_GetLaserSetPoint"] + @test FakeKinesis.setpoint_held[] == TCube.setpoint_code(laser, 30.0) + + # A setpoint the controller does not confirm is an error, and is + # not recorded as the laser's state. + FakeKinesis.setpoint_readback[] = UInt16(1) + @test_throws ErrorException setcurrent!(laser, 20.0) + @test laser.drive_current == 30.0 + FakeKinesis.setpoint_readback[] = nothing + # ... but one code of difference is the controller's own rounding + # (the rig read back 10424 for 10425) and is accepted. + FakeKinesis.setpoint_readback[] = TCube.setpoint_code(laser, 25.0) - UInt16(1) + setcurrent!(laser, 25.0) + @test laser.drive_current == 25.0 + FakeKinesis.setpoint_readback[] = nothing + light_off(laser) + + # A second initialize, with a different controller reading. The + # limit is *below* the caller's ceiling, so the `min` in # `effective_max_current` is exercised in the other direction too. FakeKinesis.reset!(limit_raw=8936) # 8936/32767*220 = 59.997 mA - weak = TCubeLaser("00000000"; max_current=80.0) + weak = cc(; max_current=80.0) initialize(weak) @test weak.controller_max_current ≈ 60.0 rtol = 1e-3 @test weak.max_current == 80.0 # still the caller's - @test TCube.effective_max_current(weak) == weak.controller_max_current # controller is stricter now - @test_throws ArgumentError setpower(weak, 70.0) # between the two ceilings + @test TCube.effective_max_current(weak) == weak.controller_max_current + @test_throws ArgumentError setcurrent!(weak, 70.0) # between the two ceilings @test isempty(FakeKinesis.setpoints) + + # Polling is a success FLAG, not a status code: `false` fails. + FakeKinesis.reset!() + FakeKinesis.polling_ok[] = false + @test_throws "LD_StartPolling" initialize(cc()) + @test FakeKinesis.calls[end] == "LD_Close" # and the handle is released end @testset "a failed initialize closes the connection (fake SDK)" begin @@ -323,7 +409,7 @@ include("tcube_fake_sdk.jl") # caller sees must be the one that stopped initialization. FakeKinesis.reset!() FakeKinesis.fail!("LD_SetOpenLoopMode", 3) - laser = TCubeLaser("00000000") + laser = cc() err = try initialize(laser) nothing @@ -334,26 +420,22 @@ include("tcube_fake_sdk.jl") @test occursin("LD_SetOpenLoopMode", err.msg) @test occursin("3", err.msg) # the Thorlabs code, not a cleanup error @test FakeKinesis.calls == ["TLI_BuildDeviceList", "TLI_GetDeviceListSize", - "LD_Open", "LD_SetOpenLoopMode", "LD_Close"] + "LD_Open", "LD_StartPolling", "LD_SetOpenLoopMode", "LD_StopPolling", "LD_Close"] @test isnan(laser.controller_max_current) # nothing recorded # A failure at the open itself has no handle to close. FakeKinesis.reset!() FakeKinesis.fail!("LD_Open", 2) - @test_throws ErrorException initialize(TCubeLaser("00000000")) + @test_throws ErrorException initialize(cc()) @test "LD_Close" ∉ FakeKinesis.calls # A close that *throws* must not displace the error that stopped - # initialization. This is the case the cleanup's inner try/catch - # exists for, and the only failure `LD_Close` can express: the - # binding returns void, so there is no status code to fail with. - # Until the fake could raise, deleting that handler -- letting the - # close error propagate in place of the real one -- failed nothing. + # initialization: the binding returns void, so raising is the only + # failure `LD_Close` can express. FakeKinesis.reset!() FakeKinesis.fail!("LD_SetOpenLoopMode", 3) FakeKinesis.throw!("LD_Close", "fake close explosion") - both = TCubeLaser("00000000") - # The close failure is reported, hence the expected @error record. + both = cc() bothErr = @test_logs (:error,) match_mode = :any try initialize(both) nothing @@ -365,19 +447,340 @@ include("tcube_fake_sdk.jl") @test occursin("3", bothErr.msg) @test !occursin("fake close explosion", bothErr.msg) # ... not the cleanup's @test FakeKinesis.calls == ["TLI_BuildDeviceList", "TLI_GetDeviceListSize", - "LD_Open", "LD_SetOpenLoopMode", "LD_Close"] # and the close was still attempted + "LD_Open", "LD_StartPolling", "LD_SetOpenLoopMode", "LD_StopPolling", "LD_Close"] @test isnan(both.controller_max_current) end + @testset "closed-loop initialize: the protected sequence (fake SDK)" begin + FakeKinesis.reset!() + FakeKinesis.limit_follows_pot[] = true # the rig controller's pot scale + FakeKinesis.setpoint_held[] = UInt16(11915) # an open-loop 80 mA left on the controller + FakeKinesis.setbits!(FakeKinesis.ENABLED) # ... and its output left on + laser = cp(; max_current=160.0) + laser.properties.is_on = true + initialize(laser) + + clamp_read = "LD_RequestMaxCurrentDigPot", "LD_GetMaxCurrentDigPot" + limit_read = "LD_RequestLaserDiodeMaxCurrentLimit", "LD_GetLaserDiodeMaxCurrentLimit" + @test FakeKinesis.calls == ["TLI_BuildDeviceList", "TLI_GetDeviceListSize", "LD_Open", + # polling first: the setpoint read-back only refreshes through it + "LD_StartPolling", + # 1-2: key, interlock and the amplifier range, from a fresh read + "LD_RequestStatusBits", "LD_GetStatusBits", + # 3: output off (no setpoint: the controller ignores one with the output off) + "LD_DisableOutput", + # 4: the clamp. At position 204 the controller reports 160.74 mA, + # over the 160 mA ceiling, so it steps to 203 (159.91 mA) and stops. + clamp_read..., limit_read..., + "LD_EnableMaxCurrentAdjust", "LD_SetMaxCurrentDigPot", clamp_read..., "LD_EnableMaxCurrentAdjust", + limit_read..., + # 5: closed loop, confirmed from the status word + "LD_SetClosedLoopMode", "LD_RequestStatusBits", "LD_GetStatusBits", + # 6: the display calibration, confirmed + "LD_SetWACalibFactor", "LD_RequestWACalibFactor", "LD_GetWACalibFactor", + # shared tail: the controller's limit + "LD_RequestReadings", limit_read...] + @test "LD_EnableOutput" ∉ FakeKinesis.calls # initialize never emits + @test FakeKinesis.bits[] & FakeKinesis.ENABLED == 0 + @test laser.properties.is_on == false + # No setpoint is sent with the output off (the controller would ignore + # it); the stale word stays on the controller until light_on replaces it. + @test isempty(FakeKinesis.setpoints) + @test FakeKinesis.setpoint_held[] == 11915 + # The diode flag is never raised: passed as false both times. + @test FakeKinesis.adjust_calls == [(true, false), (false, false)] + @test FakeKinesis.digpot_sets == [203] + # The clamp is the controller's own reported limit, under the ceiling. + reported = TCube.setpoint_current(laser, floor(Int, FakeKinesis.limit_mA_for(203) / 220 * 32767)) + @test laser.pd.max_current_clamp == reported + @test laser.pd.max_current_clamp <= 160.0 + @test laser.controller_max_current == reported + @test FakeKinesis.bits[] & FakeKinesis.CLOSED != 0 + @test FakeKinesis.wa[] == Float32(224.2) + @test laser.max_current == 160.0 + end + + @testset "the clamp search, against the rig controller's pot scale (fake SDK)" begin + # Highest position whose REPORTED limit is <= max_current, from any + # starting position, never trusting the header's position scale. + best(ceiling) = maximum(p for p in 20:255 if FakeKinesis.limit_mA_for(p) <= ceiling) + for (ceiling, start) in ((160.0, 204), (160.0, 185), (160.0, 20), (160.0, 255), (100.0, 204), (150.0, 120)) + FakeKinesis.reset!() + FakeKinesis.limit_follows_pot[] = true + FakeKinesis.digpot[] = start + laser = cp(; max_current=ceiling, + properties=LightSourceProperties("mW", 0.0, false, 1.0, 20.0)) + initialize(laser) + @test FakeKinesis.digpot[] == best(ceiling) + @test laser.pd.max_current_clamp <= ceiling + @test ceiling - laser.pd.max_current_clamp < 0.831 + 0.01 + @test length(FakeKinesis.digpot_sets) <= 6 + @test all(==((false, false)), FakeKinesis.adjust_calls[2:2:end]) # adjust mode always left + @test all(t -> t[2] == false, FakeKinesis.adjust_calls) # diode flag never raised + end + # Already under the ceiling, and within one step of it: nothing is written. + FakeKinesis.reset!() # fixed 160.0 mA limit + initialize(cp(; max_current=160.0)) + @test isempty(FakeKinesis.digpot_sets) + @test "LD_EnableMaxCurrentAdjust" ∉ FakeKinesis.calls + end + + @testset "closed-loop initialize refuses, and closes, on each precondition (fake SDK)" begin + function refusal(setup!; laser=cp()) + FakeKinesis.reset!() + setup!() + err = try + initialize(laser) + nothing + catch e + e + end + return err, laser + end + closed_loop_attempted() = "LD_SetClosedLoopMode" in FakeKinesis.calls + + err, _ = refusal(() -> FakeKinesis.setbits!(FakeKinesis.KEY; on=false)) + @test occursin("key switch", err.msg) + @test !closed_loop_attempted() && FakeKinesis.calls[end] == "LD_Close" + + err, _ = refusal(() -> FakeKinesis.setbits!(FakeKinesis.INTERLOCK; on=false)) + @test occursin("interlock", err.msg) + @test !closed_loop_attempted() + + # A DIP switch moved since the calibration: a silent factor of ten. + err, _ = refusal(() -> (FakeKinesis.setbits!(FakeKinesis.TIA_1mA; on=false); + FakeKinesis.setbits!(FakeKinesis.TIA_10mA))) + @test occursin("DIP", err.msg) && occursin("0.01", err.msg) + @test !closed_loop_attempted() + err, _ = refusal(() -> FakeKinesis.setbits!(FakeKinesis.TIA_1mA; on=false)) + @test occursin("no single photodiode range", err.msg) + + # A clamp that does not take, and reads back above the ceiling. + err, laser = refusal(() -> (FakeKinesis.limit_follows_pot[] = true; FakeKinesis.digpot_takes[] = false)) + @test occursin("potentiometer", err.msg) && occursin("reads back", err.msg) + @test !closed_loop_attempted() + @test isnan(laser.pd.max_current_clamp) # never recorded from a failed read-back + @test FakeKinesis.adjust_calls[end] == (false, false) # adjust mode was left + @test FakeKinesis.calls[end] == "LD_Close" + + # A controller whose limit stays over the ceiling even at the lowest position. + err, laser = refusal(() -> FakeKinesis.diode_limit_raw[] = 32767) # 220 mA, whatever the position + @test occursin("lowest potentiometer position", err.msg) + @test isnan(laser.pd.max_current_clamp) + @test !closed_loop_attempted() && FakeKinesis.calls[end] == "LD_Close" + + # A mode change the status word does not confirm. + err, _ = refusal(() -> FakeKinesis.closed_loop_takes[] = false) + @test occursin("0x4", err.msg) + + # A calibration factor that does not read back. + err, _ = refusal(() -> FakeKinesis.wa_readback[] = 100f0) + @test occursin("W/A", err.msg) + + @test "LD_EnableOutput" ∉ FakeKinesis.calls + end + + @testset "setoutputpower! (fake SDK)" begin + # Refused before initialize: the clamp is not programmed, and it is + # the only real protection in closed loop. + FakeKinesis.reset!() + laser = cp() + @test_throws "clamp" setoutputpower!(laser, 10.0) + @test_throws "clamp" light_on(laser) + @test isempty(FakeKinesis.calls) + + initialize(laser) + # Out of the declared [1, 70] mW is refused before anything is sent. + empty!(FakeKinesis.calls); empty!(FakeKinesis.setpoints) + @test_throws ArgumentError setoutputpower!(laser, 80.0) + @test_throws ArgumentError setoutputpower!(laser, 0.5) + @test_throws ArgumentError setoutputpower!(laser, NaN) + @test isempty(FakeKinesis.calls) + @test isnan(laser.pd.output_power_requested) + + # 50 mW -> 50/1000/224.2 A of photocurrent -> a word of 1 mA full scale. + # With the output off it is recorded only; light_on sends it after enabling. + setoutputpower!(laser, 50.0) + i_pd = 50.0 / 1000 / 224.2 + @test FakeKinesis.calls == ["LD_GetStatusBits"] + @test isempty(FakeKinesis.setpoints) + @test laser.pd.output_power_requested == 50.0 + empty!(FakeKinesis.calls) + light_on(laser) + @test FakeKinesis.calls == ["LD_EnableOutput", "LD_SetLaserSetPoint", "LD_GetLaserSetPoint"] + code = FakeKinesis.setpoints[end] + @test code == UInt16(floor(i_pd / 1e-3 * 32767)) + @test FakeKinesis.setpoint_held[] == code + # With the output on, a new power is sent and confirmed at once (after + # checking the photodiode is not reading 0x8000, over range). + empty!(FakeKinesis.calls) + setoutputpower!(laser, 50.0) + @test FakeKinesis.calls == ["LD_GetStatusBits", "LD_GetPhotoCurrentReading", "LD_SetLaserSetPoint", "LD_GetLaserSetPoint"] + # 0x8000 is the rig controller's over-range reading: refuse, and report it. + FakeKinesis.photocurrent_raw[] = -32768 + @test_throws "OVER" setoutputpower!(laser, 20.0) + @test measured_photocurrent(laser) == Inf + @test loop_status(laser).tia_over + FakeKinesis.photocurrent_raw[] = 0 + light_off(laser) + # The DECODED setpoint: never above the request, within one code of it. + @test laser.pd.photocurrent_requested == Float64(code) / 32767 * 1e-3 + @test laser.pd.photocurrent_requested <= i_pd + @test i_pd - laser.pd.photocurrent_requested < 1e-3 / 32767 + @test isnan(laser.drive_current) # the loop owns the current + + # The helper's guarantee holds across the range. + for p in range(1.0, 70.0; length=200) + c = TCube.photocurrent_code(laser, p / 1000 / 224.2) + @test TCube.photocurrent_from_code(laser, c, 1e-3) <= p / 1000 / 224.2 + end + @test_throws "DIP" TCube.photocurrent_code(laser, 2e-3) + + # The status word can refuse: over range always ... + FakeKinesis.setbits!(FakeKinesis.TIA_OVER) + @test_throws "OVER" setoutputpower!(laser, 20.0) + FakeKinesis.setbits!(FakeKinesis.TIA_OVER; on=false) + # ... under range only while the output is on. + FakeKinesis.setbits!(FakeKinesis.TIA_UNDER) + setoutputpower!(laser, 20.0) + @test laser.pd.output_power_requested == 20.0 + light_on(laser) + @test_throws "UNDER" setoutputpower!(laser, 30.0) + @test laser.pd.output_power_requested == 20.0 # unchanged by a refusal + FakeKinesis.setbits!(FakeKinesis.TIA_UNDER; on=false) + # ... and a controller that left closed loop behind the driver's back. + FakeKinesis.setbits!(FakeKinesis.CLOSED; on=false) + @test_throws "closed loop" setoutputpower!(laser, 30.0) + FakeKinesis.setbits!(FakeKinesis.CLOSED) + # A setpoint the controller does not confirm is not recorded. + FakeKinesis.setpoint_readback[] = UInt16(3) + @test_throws ErrorException setoutputpower!(laser, 30.0) + @test laser.pd.output_power_requested == 20.0 + end + + @testset "light_on / light_off around the setpoint (fake SDK)" begin + # The rig's TLD001 ignores setpoints while its output is off. So + # light_on must send the recorded request right AFTER enabling, and + # light_off must zero the setpoint BEFORE disabling. + FakeKinesis.reset!() + FakeKinesis.setpoint_held[] = FakeKinesis.STALE_SETPOINT # a controller left near full scale + laser = cc() + initialize(laser) + # Nothing requested yet: light_on replaces the stale word with 0. + light_on(laser) + @test FakeKinesis.setpoints == [UInt16(0)] + @test laser.properties.is_on + setcurrent!(laser, 80.0) + empty!(FakeKinesis.calls); empty!(FakeKinesis.setpoints) + light_off(laser) + @test FakeKinesis.calls == ["LD_GetStatusBits", "LD_SetLaserSetPoint", "LD_GetLaserSetPoint", "LD_DisableOutput"] + @test FakeKinesis.setpoints == [UInt16(0)] + @test FakeKinesis.setpoint_held[] == 0 + @test laser.drive_current == 80.0 # the request survives the off + # ... and light_on returns to it. + light_on(laser) + @test FakeKinesis.setpoint_held[] == TCube.setpoint_code(laser, 80.0) + light_off(laser) + + # If light_on cannot confirm the setpoint, the output goes back off. + FakeKinesis.setpoint_readback[] = UInt16(3) + @test_throws ErrorException light_on(laser) + @test FakeKinesis.bits[] & FakeKinesis.ENABLED == 0 + @test laser.properties.is_on == false + FakeKinesis.setpoint_readback[] = nothing + + # A zero that cannot be confirmed never prevents light_off's disable. + light_on(laser) + FakeKinesis.setpoint_readback[] = UInt16(3) + @test_logs (:error,) match_mode = :any light_off(laser) + @test FakeKinesis.bits[] & FakeKinesis.ENABLED == 0 + @test laser.properties.is_on == false + FakeKinesis.setpoint_readback[] = nothing + end + + @testset "readbacks (fake SDK)" begin + FakeKinesis.reset!() + laser = cc(; threshold_current=65.0) + # The diode current reading is signed: -5957 is -40 mA, not ~440. + FakeKinesis.current_raw[] = -5957 + @test measured_current(laser) ≈ -40.0 rtol = 1e-3 + FakeKinesis.current_raw[] = 40000 + @test_throws "protocol" measured_current(laser) + FakeKinesis.current_raw[] = 5957 + @test measured_current(laser) == TCube.setpoint_current(laser, 5957) + + # Photocurrent is scaled over the range: in open loop the range the + # status word reports, which is how a W/A calibration is measured. + FakeKinesis.photocurrent_raw[] = 16383 + @test measured_photocurrent(laser) ≈ 0.5e-3 rtol = 1e-4 + FakeKinesis.photocurrent_raw[] = 40000 + @test_throws "protocol" measured_photocurrent(laser) + FakeKinesis.photocurrent_raw[] = 16383 + + # loop_status: one snapshot. Output off -> not "below threshold". + s = loop_status(laser) + @test s.word == FakeKinesis.bits[] + @test s.key && s.interlock && s.psu_ok && !s.output_enabled && !s.closed_loop + @test s.tia_range_A == 1e-3 + @test s.current_mA == TCube.setpoint_current(laser, 5957) + @test s.below_threshold === false + # On, commanded, and the current under the declared threshold: not lasing. + FakeKinesis.setbits!(FakeKinesis.ENABLED) + laser.drive_current = 40.0 + @test loop_status(laser).below_threshold === true + # An unknown threshold is reported as unchecked, never as passed. + @test loop_status(cc()).below_threshold === missing + FakeKinesis.setbits!(0x00000400) + @test loop_status(laser).saturated + + # Power mode: scaled over the stated range, and converted at the output. + FakeKinesis.reset!() + pl = cp() + FakeKinesis.photocurrent_raw[] = 7307 + @test measured_photocurrent(pl) == 7307 / 32767 * 1e-3 + @test indicated_output_power(pl) ≈ 7307 / 32767 * 1e-3 * 224.2 * 1000 + @test indicated_output_power(pl) ≈ 50.0 rtol = 1e-3 + @test loop_status(pl).photocurrent_A == measured_photocurrent(pl) + # Every readback is a cache read: no request, no command. + @test all(c -> startswith(c, "LD_Get"), FakeKinesis.calls) + + # `tcube_get_current` still issues its own request. + FakeKinesis.reset!(limit_raw=5957) + @test TCube.tcube_get_current(cc()) == TCube.setpoint_current(cc(), 5957) + @test "LD_RequestReadings" in FakeKinesis.calls + end + + @testset "setlevel! maps onto each mode's declared range (fake SDK)" begin + FakeKinesis.reset!() + laser = cc(; min_current=70.0, max_current=150.0) + initialize(laser) # the controller's 160 mA limit does not narrow 150 + setlevel!(laser, 0.0) + @test laser.drive_current == 70.0 # the floor, which still emits: not off + setlevel!(laser, 1.0) + @test laser.drive_current == 150.0 + setlevel!(laser, 0.25) + @test laser.drive_current ≈ 90.0 + @test_throws ArgumentError setlevel!(laser, 1.5) + @test_throws ArgumentError setlevel!(laser, -0.1) + + FakeKinesis.reset!() + pl = cp() + initialize(pl) + setlevel!(pl, 0.0) + @test pl.pd.output_power_requested == 1.0 + setlevel!(pl, 1.0) + @test pl.pd.output_power_requested == 70.0 + setlevel!(pl, 0.5) + @test pl.pd.output_power_requested ≈ 35.5 + end + @testset "shutdown closes the connection either way (fake SDK)" begin - # `shutdown` runs against the recorder like the rest of the - # lifecycle; nothing invoked it until this testset, so the - # commentary above was ahead of the tests. FakeKinesis.reset!() - laser = TCubeLaser("00000000") + laser = cc() laser.properties.is_on = true shutdown(laser) - @test FakeKinesis.calls == ["LD_DisableOutput", "LD_Close"] + # Output already off (polled word): nothing to zero, straight to the disable. + @test FakeKinesis.calls == ["LD_GetStatusBits", "LD_DisableOutput", "LD_StopPolling", "LD_Close"] @test laser.properties.is_on == false # A failed disable throws -- and the handle is closed anyway, @@ -385,7 +788,7 @@ include("tcube_fake_sdk.jl") # needs in order to retry that disable. FakeKinesis.reset!() FakeKinesis.fail!("LD_DisableOutput", 5) - stuck = TCubeLaser("00000000") + stuck = cc() stuck.properties.is_on = true err = try shutdown(stuck) @@ -395,10 +798,25 @@ include("tcube_fake_sdk.jl") end @test err isa ErrorException @test occursin("LD_DisableOutput", err.msg) - @test FakeKinesis.calls == ["LD_DisableOutput", "LD_Close"] # closed regardless - # The disable failed, so the output is not recorded as off: the - # field follows the call, not the request. + @test FakeKinesis.calls == ["LD_GetStatusBits", "LD_DisableOutput", "LD_StopPolling", "LD_Close"] # closed regardless + # The disable failed, so the output is not recorded as off. @test stuck.properties.is_on == true + + # With the output on, the setpoint is zeroed BEFORE the disable, so + # the controller is left holding 0: the next enable, by this driver or + # any other software, starts dark instead of on a stale word. + FakeKinesis.reset!() + pl = cp() + initialize(pl) + setoutputpower!(pl, 50.0) + light_on(pl) + @test FakeKinesis.setpoint_held[] != 0 + empty!(FakeKinesis.calls) + shutdown(pl) + @test FakeKinesis.calls == ["LD_GetStatusBits", "LD_SetLaserSetPoint", "LD_GetLaserSetPoint", + "LD_DisableOutput", "LD_StopPolling", "LD_Close"] + @test FakeKinesis.setpoint_held[] == 0 + @test pl.pd.output_power_requested == 50.0 # the request is kept end @testset "Setpoint encoding" begin @@ -407,150 +825,71 @@ include("tcube_fake_sdk.jl") # the driver's own scale, so the one request sitting exactly on the # enforced limit was the one to exceed it. FakeKinesis.reset!() - laser = TCubeLaser("00000000") - setpower(laser, 160.0) + laser = cc() + FakeKinesis.setbits!(FakeKinesis.ENABLED) # output on: setpoints are sent at once + setcurrent!(laser, 160.0) @test FakeKinesis.setpoints == [UInt16(23830)] encoded(l, code) = Float64(code) / l.max_setpoint * l.max_setcurrent @test encoded(laser, FakeKinesis.setpoints[end]) <= 160.0 # The guarantee is stated in terms of the driver's own decode, so - # check that this testset's `encoded` is that same arithmetic - # before using it to check the guarantee. + # check that this testset's `encoded` is that same arithmetic. @test encoded(laser, 12345) == TCube.setpoint_current(laser, 12345) - # The commanded current never decodes above the requested one, at - # any setpoint. for request in (0.0, 0.5, 1.0, 37.3, 99.9, 159.999, 160.0) @test encoded(laser, TCube.setpoint_code(laser, request)) <= request end @test TCube.setpoint_code(laser, 0.0) == 0x0000 - # Truncation alone did NOT deliver that, which is why the encoder - # corrects downward afterwards. `current / max_setcurrent * - # max_setpoint` can round a request one ulp below a code boundary - # up onto the boundary itself, leaving `floor` nothing to cut. The - # first such request in the default range: + # Truncation alone did NOT deliver that: the scaling can round a + # request one ulp below a code boundary up onto the boundary. just_under_9 = prevfloat(9.0 / 32767.0 * 220.0) # 0.06042664876247444 - @test floor(just_under_9 / laser.max_setcurrent * laser.max_setpoint) == 9.0 # what floor alone gives - @test encoded(laser, UInt16(9)) > just_under_9 # and it is over the request - @test TCube.setpoint_code(laser, just_under_9) == UInt16(8) # so the encoder steps down + @test floor(just_under_9 / laser.max_setcurrent * laser.max_setpoint) == 9.0 + @test encoded(laser, UInt16(9)) > just_under_9 + @test TCube.setpoint_code(laser, just_under_9) == UInt16(8) - # Not one special case: 1794 of these exist below the default 160 - # mA ceiling. Sweep the predecessor of every code boundary in it. + # Not one special case: sweep the predecessor of every code + # boundary below the default 160 mA ceiling. boundary_neighbours = [prevfloat(Float64(k) / laser.max_setpoint * laser.max_setcurrent) for k in 1:23830] @test all(r -> encoded(laser, TCube.setpoint_code(laser, r)) <= r, boundary_neighbours) - # The correction is a step of exactly one code, and only when it is - # needed: the boundary itself still encodes to its own code. @test all(k -> TCube.setpoint_code(laser, boundary_neighbours[k]) == UInt16(k - 1), 1:23830) @test all(k -> TCube.setpoint_code(laser, Float64(k) / laser.max_setpoint * laser.max_setcurrent) == UInt16(k), (1, 9, 5957, 23830)) - # The bottom of the range commands nothing, and says so. A positive - # request under one code (220/32767 = 0.006714 mA here) rounds down - # to code 0 -- rounding it up would command more than was asked for - # -- but `properties.power` still records the request, so the field - # is what the caller asked for and not what went to the wire. + # The bottom of the range commands nothing, and says so. one_code = laser.max_setcurrent / laser.max_setpoint @test TCube.setpoint_code(laser, prevfloat(one_code)) == 0x0000 @test TCube.setpoint_code(laser, 0.001) == 0x0000 FakeKinesis.reset!() - setpower(laser, 0.001) + FakeKinesis.setbits!(FakeKinesis.ENABLED) + setcurrent!(laser, 0.001) @test FakeKinesis.setpoints == [UInt16(0)] # a ZERO SETPOINT is sent -- @test laser.properties.is_on == false # not an "off": is_on is untouched @test laser.drive_current == 0.001 # and the field keeps the request - # What `export_state` writes is the request too, not the zero that - # went to the wire and not the decoded current. Exporting either of - # those instead survived every other assertion here. @test export_state(laser)[1]["drive_current"] == 0.001 - # `tcube_get_current` decodes the controller's raw reading through - # the same shared decode as the encoder. Returning zero from it - # survived every other assertion, so pin a non-zero reading. - FakeKinesis.reset!(limit_raw = 5957) - reading = TCube.tcube_get_current(laser) - @test reading > 0 - @test reading == TCube.setpoint_current(laser, 5957) - @test "LD_RequestReadings" in FakeKinesis.calls + # Conversion parameters are validated before converting. FakeKinesis.reset!() - - # Conversion parameters are validated before converting. Each of - # these passes check_current and used to die in `UInt16(...)` with - # an InexactError. - empty!(FakeKinesis.setpoints) - @test_throws ArgumentError setpower(TCubeLaser("00000000"; max_setcurrent=0.0), 0.0) - @test_throws ArgumentError setpower(TCubeLaser("00000000"; max_setcurrent=NaN), 80.0) - @test_throws ArgumentError setpower(TCubeLaser("00000000"; max_setpoint=100000.0), 160.0) - @test_throws ArgumentError setpower(TCubeLaser("00000000"; min_current=-10.0), -5.0) + @test_throws ArgumentError setcurrent!(cc(; max_setcurrent=0.0), 0.0) + @test_throws ArgumentError setcurrent!(cc(; max_setcurrent=NaN), 80.0) + @test_throws ArgumentError setcurrent!(cc(; max_setpoint=100000.0), 160.0) + @test_throws ArgumentError setcurrent!(cc(; min_current=-10.0), -5.0) @test isempty(FakeKinesis.setpoints) # none of them reached the SDK - # The message has to name the field at fault. offender(l, c) = try - setpower(l, c) + setcurrent!(l, c) "" catch e e.msg end - @test occursin("max_setcurrent", offender(TCubeLaser("00000000"; max_setcurrent=NaN), 80.0)) - @test occursin("max_setpoint", offender(TCubeLaser("00000000"; max_setpoint=100000.0), 160.0)) - @test occursin("non-negative", offender(TCubeLaser("00000000"; min_current=-10.0), -5.0)) - end - - @testset "properties.power keeps its pre-0.2.3 value (deprecated)" begin - # `properties.power`/`power_unit` are an uncalibrated linear guess - # and are on their way out, but removing them would have forced a - # migration on every rig reading them. So they keep the exact value - # 0.2.2 produced, which is not the same as keeping 0.2.2's line: - # that line divided by `light.max_current`, and 0.2.2's - # `initialize` overwrote that field with the controller's limit. - # Both moments are checked against the old expression written out - # literally, with the divisor 0.2.2 would have had in the field. - v022_power(divisor, current, max_power) = current * max_power / divisor - - FakeKinesis.reset!() - fresh = TCubeLaser("00000000"; max_current=80.0) - @test fresh.properties.power_unit == "mW" # unchanged label - setpower(fresh, 40.0) - # Before initialize, 0.2.2's max_current was the caller's 80.0. - @test fresh.properties.power == v022_power(80.0, 40.0, fresh.properties.max_power) - @test fresh.properties.power == 50.0 - @test fresh.drive_current == 40.0 # the truth, alongside it - - # After initialize, 0.2.2's max_current was the controller's limit, - # decoded from the same raw reading this driver decodes into - # controller_max_current. - FakeKinesis.reset!() - initialize(fresh) - v022_max_current = Float64(FakeKinesis.diode_limit_raw[]) / fresh.max_setpoint * fresh.max_setcurrent - @test fresh.max_current == 80.0 # the caller's ceiling still survives - @test fresh.controller_max_current == v022_max_current - setpower(fresh, 40.0) - @test fresh.properties.power == v022_power(v022_max_current, 40.0, fresh.properties.max_power) - @test fresh.properties.power ≈ 25.0 rtol = 1e-3 - @test fresh.drive_current == 40.0 - - # A caller's own max_power scales it, as it always did. - scaled = TCubeLaser("00000000"; properties=LightSourceProperties("mW", 0.0, false, 0.0, 250.0)) - setpower(scaled, 80.0) - @test scaled.properties.power == v022_power(160.0, 80.0, 250.0) - @test scaled.properties.power == 125.0 - - # A refused request updates neither field. - FakeKinesis.reset!() - refused = TCubeLaser("00000000"; max_current=80.0) - @test_throws ArgumentError setpower(refused, 100.0) - @test refused.properties.power == 0.0 - @test isnan(refused.drive_current) - @test isempty(FakeKinesis.calls) + @test occursin("max_setcurrent", offender(cc(; max_setcurrent=NaN), 80.0)) + @test occursin("max_setpoint", offender(cc(; max_setpoint=100000.0), 160.0)) + @test occursin("non-negative", offender(cc(; min_current=-10.0), -5.0)) end @testset "tcube_refresh explains itself instead of firing" begin - # It drove a hardcoded 90 mA under a name that warned nobody, so it - # is gone as a behaviour -- but it was an exported name, and - # deleting an exported name turns a caller's line into an - # `UndefVarError` that explains nothing. The throwing stub is the - # non-breaking form of the same removal. FakeKinesis.reset!() - laser = TCubeLaser("00000000") + laser = cc() err = try tcube_refresh(laser) nothing @@ -558,73 +897,208 @@ include("tcube_fake_sdk.jl") e end @test err isa ErrorException - @test occursin("90 mA", err.msg) # says what it used to do ... - @test occursin("setpower", err.msg) # ... and what to call instead - @test isempty(FakeKinesis.calls) # and reaches no hardware - @test isempty(FakeKinesis.setpoints) - # Still exported, which is the point of keeping it. + @test occursin("90 mA", err.msg) # says what it used to do ... + @test occursin("setcurrent!", err.msg) # ... and what to call instead + @test isempty(FakeKinesis.calls) # and reaches no hardware @test :tcube_refresh in names(MicroscopeControl) end @testset "export_state" begin - laser = TCubeLaser("00000000"; daq_device="Dev2", ao_channel="Dev2/ao1") - attrs, data, children = export_state(laser) # 1-arg: used to throw + laser = cc(; daq_device="Dev2", ao_channel="Dev2/ao1", threshold_current=65.0) + attrs, data, children = export_state(laser) @test attrs isa Dict{String,Any} - @test attrs["power_unit"] == "mW" - @test attrs["min_current"] == 0.0 - @test attrs["max_current"] == 160.0 + @test attrs["regulation_mode"] == "ConstantCurrent" + @test attrs["setpoint_unit"] == "mA" + @test attrs["min_current_mA"] == 0.0 + @test attrs["max_current_mA"] == 160.0 + @test attrs["threshold_current_mA"] == 65.0 @test isnan(attrs["controller_max_current"]) @test isnan(attrs["drive_current"]) # nothing commanded yet @test attrs["daq_device"] == "Dev2" @test attrs["ao_channel"] == "Dev2/ao1" + # The deprecated linear guess under a "mW" label is gone. + @test !haskey(attrs, "power") && !haskey(attrs, "power_unit") + @test !haskey(attrs, "wa_calibration_W_per_A") # open loop has no calibration @test data === nothing @test haskey(children, "daq") - # `nothing` is not writable as an HDF5 attribute; unset must be "". - @test export_state(TCubeLaser("00000000"))[1]["daq_device"] == "" - - # The diode current limit has a dedicated Kinesis request; - # `LD_RequestReadings` does not refresh it. Since the limit feeds - # the enforced ceiling, reading it after only the generic request - # can enforce a stale bound. Assert the dedicated request is made, - # and made BEFORE the read. - FakeKinesis.reset!(limit_raw = 23830) - initialize(TCubeLaser("00000000")) - @test "LD_RequestLaserDiodeMaxCurrentLimit" in FakeKinesis.calls - @test findfirst(==("LD_RequestLaserDiodeMaxCurrentLimit"), FakeKinesis.calls) < - findfirst(==("LD_GetLaserDiodeMaxCurrentLimit"), FakeKinesis.calls) + @test export_state(cc())[1]["daq_device"] == "" + # The 2-argument forwarder was removed in 0.3.0. + @test_throws MethodError export_state(laser, nothing) + FakeKinesis.reset!() + pl = cp(; threshold_current=65.0) + initialize(pl) + setoutputpower!(pl, 50.0) + a = export_state(pl)[1] + @test a["regulation_mode"] == "ConstantPhotocurrent" + @test a["setpoint_unit"] == "mW" + @test a["power_reference"] == "laser output" + @test a["min_output_power_mW"] == 1.0 && a["max_output_power_mW"] == 70.0 + @test a["wa_calibration_W_per_A"] == 224.2 + @test a["tia_range_A"] == 1e-3 + @test a["tec_stabilised"] == "unknown" # `missing` is not HDF5-safe + @test a["max_current_clamp_mA"] == pl.pd.max_current_clamp + @test a["output_power_requested_mW"] == 50.0 + @test a["photocurrent_requested_A"] == pl.pd.photocurrent_requested + @test export_state(cp(; tec_stabilised=true))[1]["tec_stabilised"] == "true" + # And it round-trips through HDF5. + mktempdir() do dir + path = joinpath(dir, "cp.h5") + wait(save_h5(path, export_state(pl))) + HDF5.h5open(path, "r") do f + @test HDF5.read_attribute(f["Main"], "regulation_mode") == "ConstantPhotocurrent" + @test HDF5.read_attribute(f["Main"], "tec_stabilised") == "unknown" + end + end + end + end + + # The simulated twin carries the physics the fake SDK cannot: a diode whose + # light follows current above threshold, a photodiode whose responsivity + # can drift, and a loop that raises current until the photocurrent matches + # its setpoint or the clamp stops it. `true_output_power` is the oracle a + # driver can never report. + @testset "Simulated Diode Laser" begin + SimDL = MicroscopeControl.HardwareImplementations.SimulatedDiodeLaser + sim_cc(; kw...) = SimDiodeLaser(; mode=ConstantCurrent(), kw...) + sim_cp(; kw...) = SimDiodeLaser(; mode=ConstantPhotocurrent(), wa_calibration=224.2, tia_range=1e-3, + tec_stabilised=missing, + properties=LightSourceProperties("mW", 0.0, false, 1.0, 70.0), kw...) + + @test sim_cc() isa DiodeLaser + @test_throws ArgumentError SimDiodeLaser() + @test_throws ArgumentError SimDiodeLaser(; mode=ConstantPhotocurrent()) + + @testset "1. the loop converges on the requested power" begin + sim = sim_cp() + initialize(sim) + setoutputpower!(sim, 40.0) + light_on(sim) + @test indicated_output_power(sim) ≈ 40.0 rtol = 1e-3 + @test SimDL.true_output_power(sim) ≈ 40.0 rtol = 1e-3 # the calibration starts true + @test measured_current(sim) > sim.threshold_current + s = loop_status(sim) + @test s.closed_loop && s.output_enabled && !s.saturated + @test s.below_threshold === false + end + + @testset "2. a blocked photodiode drives the current to the clamp" begin + sim = sim_cp(; pd_blocked=true) + initialize(sim) + setoutputpower!(sim, 40.0) + light_on(sim) + @test measured_current(sim) == sim.pd.max_current_clamp + @test loop_status(sim).saturated + @test indicated_output_power(sim) ≈ 0.0 atol = 1e-9 + end + + @testset "3. responsivity drift: the number stays flat, the light does not" begin + sim = sim_cp() + initialize(sim) + setoutputpower!(sim, 40.0) + light_on(sim) + before = SimDL.true_output_power(sim) + sim.responsivity_drift = 0.15 # the photodiode warms up + @test indicated_output_power(sim) ≈ 40.0 rtol = 1e-3 # what the driver reports + @test abs(SimDL.true_output_power(sim) - before) / before > 0.1 # what the sample gets + end + + @testset "4. each unit-true setter exists only in its own mode" begin + c, p = sim_cc(), sim_cp() + initialize(c); initialize(p) + @test_throws "not implemented" setcurrent!(p, 80.0) + @test_throws "not implemented" setoutputpower!(c, 10.0) + @test_throws "setcurrent!" setpower(c, 80.0) + end + + @testset "5. commands before initialize and after shutdown throw, state unchanged" begin + for sim in (sim_cc(), sim_cp()) + @test_throws "not initialized" light_on(sim) + @test_throws "not initialized" loop_status(sim) + @test_throws "not initialized" setlevel!(sim, 0.5) + @test isnan(sim.drive_current) + initialize(sim) + setlevel!(sim, 0.5) + shutdown(sim) + requested = sim.pd === nothing ? sim.drive_current : sim.pd.output_power_requested + @test_throws "not initialized" setlevel!(sim, 0.9) + @test_throws "not initialized" measured_current(sim) + @test requested == (sim.pd === nothing ? sim.drive_current : sim.pd.output_power_requested) + end + end + + @testset "6. amplifier over/under range make setoutputpower! refuse" begin + sim = sim_cp() + initialize(sim) + sim.tia_over_fault = true + @test_throws "OVER" setoutputpower!(sim, 20.0) + sim.tia_over_fault = false + sim.tia_under_fault = true + setoutputpower!(sim, 20.0) # under range with the output off is expected + light_on(sim) + @test_throws "UNDER" setoutputpower!(sim, 30.0) + @test sim.pd.output_power_requested == 20.0 + # A moved DIP switch is refused at initialize. + @test_throws "range" initialize(sim_cp(; tia_range_A=10e-3)) + @test_throws "interlock" initialize(sim_cp(; interlock=false)) + end + + # 7 (opening a panel issues nothing) is in test/gui.jl. + + @testset "8. setlevel! endpoints, linear in the regulated quantity" begin + c = sim_cc(; min_current=70.0, max_current=150.0, threshold_current=65.0) + initialize(c) + setlevel!(c, 0.0) + @test c.drive_current == 70.0 + light_on(c) + @test SimDL.true_output_power(c) > 0 # the floor is above threshold, so 0 emits + setlevel!(c, 1.0) + @test c.drive_current == 150.0 + setlevel!(c, 0.5) + @test c.drive_current ≈ 110.0 + + p = sim_cp() + initialize(p) + setlevel!(p, 0.0) + @test p.pd.output_power_requested == 1.0 + setlevel!(p, 1.0) + @test p.pd.output_power_requested == 70.0 + setlevel!(p, 0.5) + @test p.pd.output_power_requested ≈ 35.5 + light_on(p) + @test indicated_output_power(p) ≈ 35.5 rtol = 1e-3 + end + + @testset "9. the same setlevel! sequence runs against both modes" begin + # The property the one-line mode switch depends on: code written + # against setlevel!, light_on, loop_status and export_state needs + # no edit when the construction line changes mode. + function sequence!(laser) + initialize(laser) + for f in (0.0, 0.3, 1.0, 0.6) + setlevel!(laser, f) + end + light_on(laser) + s = loop_status(laser) + attrs = export_state(laser)[1] + light_off(laser) + shutdown(laser) + return s, attrs + end + for laser in (sim_cc(; min_current=70.0, max_current=150.0), sim_cp()) + s, attrs = sequence!(laser) + @test s.output_enabled + @test !s.saturated + @test attrs["regulation_mode"] == string(nameof(typeof(regulation_mode(laser)))) + end + end - # The one place `legacy_power` deliberately diverges from 0.2.2: - # a caller assigning `max_current` AFTER `initialize`. 0.2.2 divided - # by the newly assigned value because `initialize` had overwritten - # that same field; we divide by the separately recorded controller - # limit. Pinned rather than chased -- reproducing it would mean - # intercepting field writes to rebuild a number the driver invents, - # and 0.3.0 removes it. `drive_current` is the exact one. - diverge = TCubeLaser("00000000"; max_current = 80.0) - @test TCube.legacy_power(diverge, 40.0) == 40.0 * 100.0 / 80.0 # 50.0, as 0.2.2 - TCube.record_controller_limit!(diverge, 23830) - diverge.max_current = 80.0 # the post-init write - @test TCube.legacy_power(diverge, 40.0) == - 40.0 * 100.0 / TCube.setpoint_current(diverge, 23830) # not 50.0 - @test TCube.legacy_power(diverge, 40.0) != 50.0 - - # The 2-argument form is the one that existed before 0.2.3; the bug - # was that it was the ONLY one, so `export_state(laser)` fell - # through to the throwing stub. Adding the 1-arg method is the - # whole fix, so the old form survives as a deprecated forwarder - # rather than becoming a MethodError. It warns once, ignores its - # argument and returns the same thing. - TCube.EXPORT_STATE_2ARG_WARNED[] = false - forwarded = @test_logs (:warn,) match_mode = :any export_state(laser, nothing) - @test isequal(forwarded[1], attrs) # isequal: NaN attributes - @test forwarded[2] === data - @test keys(forwarded[3]) == keys(children) - # The argument really is ignored, whatever it is ... - @test isequal(export_state(laser, "anything at all")[1], attrs) - # ... and the warning does not repeat. - @test_logs export_state(laser, nothing) - @test TCube.EXPORT_STATE_2ARG_WARNED[] + @testset "export_state matches the hardware driver's attribute names" begin + hw = Set(keys(export_state(TCubeLaser("00000000"; mode=ConstantPhotocurrent(), wa_calibration=224.2, + tia_range=1e-3, tec_stabilised=missing, properties=LightSourceProperties("mW", 0.0, false, 1.0, 70.0)))[1])) + simk = Set(keys(export_state(sim_cp())[1])) + @test issubset(simk, hw) + @test "power_reference" in simk && "wa_calibration_W_per_A" in simk end end diff --git a/test/tcube_fake_sdk.jl b/test/tcube_fake_sdk.jl index a784876..cbe2621 100644 --- a/test/tcube_fake_sdk.jl +++ b/test/tcube_fake_sdk.jl @@ -19,7 +19,7 @@ # # What this seam does NOT cover, established by review rather than assumed: # -# * The eleven replacements are global and permanent for the process. Every +# * The replacements are global and permanent for the process. Every # later testset -- contract, GUI, skills -- runs against them. That is the # intent (there is no real controller to reach) and no current test depends # on the original wrapper bodies: the contract tests inspect public @@ -44,11 +44,21 @@ # * Julia prints a "Method definition ... overwritten" warning per wrapper as # this file is included. They are expected. +# +# Since 0.3.0 the fake also carries the small amount of controller STATE the +# closed-loop path reads back -- status bits, the setpoint, the max-current +# potentiometer, the W/A factor, the two readings -- because that path verifies +# each of its writes, and a recorder that only returned 0 could not tell a +# verified write from an unverified one. Each piece of state can be made to +# misbehave (a pot that does not take, a mode bit that does not set, a +# setpoint that reads back wrong) so that every refusal has a test. + """ FakeKinesis Recorder standing in for the Thorlabs Kinesis SDK: what the driver called, in -what order, what it sent, and what each call reports back. +what order, what it sent, what each call reports back, and the little state a +TLD001 would hold between calls. """ module FakeKinesis @@ -81,6 +91,59 @@ driver's default scale. """ const diode_limit_raw = Ref{Int}(23830) +"Raw diode-current reading; `nothing` returns `diode_limit_raw`, as before 0.3.0." +const current_raw = Ref{Union{Nothing,Int}}(nothing) + +"Raw photocurrent reading (`LD_GetPhotoCurrentReading`)." +const photocurrent_raw = Ref{Int}(0) + +const KEY, CLOSED, INTERLOCK, ENABLED = 0x00000002, 0x00000004, 0x00000008, 0x00000001 +const TIA_1mA, TIA_10mA, PSU_OK = 0x00000040, 0x00000080, 0x00001000 +const TIA_OVER, TIA_UNDER = 0x00002000, 0x00004000 + +"The status word: key, interlock, PSU OK and the 1 mA range by default." +const bits = Ref{UInt32}(KEY | INTERLOCK | PSU_OK | TIA_1mA) + +"Whether `LD_SetClosedLoopMode` actually sets the closed-loop bit." +const closed_loop_takes = Ref(true) + +"The setpoint the controller holds, and an override for what it reads back." +const setpoint_held = Ref{UInt16}(0) +const setpoint_readback = Ref{Union{Nothing,UInt16}}(nothing) + +""" +The rig's TLD001 IGNORES a setpoint sent while its output is off, and with the +output off `LD_GetLaserSetPoint` returns a stale word (65530 on the rig) that +has nothing to do with what was sent. Measured 2026-09-28; the fake does the +same, so a driver that sends setpoints with the output off fails here as it did +on the rig. +""" +const STALE_SETPOINT = UInt16(65530) + +"The max-current potentiometer position, whether a set takes, and every set." +const digpot = Ref{Int}(204) +const digpot_takes = Ref(true) +const digpot_sets = Int[] + +""" +When `true`, the diode current limit follows the potentiometer on the scale +measured on the 642 nm rig's TLD001 (position 204 -> 160.74 mA, 194 -> 152.43 +mA, about 0.831 mA per step) instead of returning `diode_limit_raw`. The +closed-loop tests turn it on; the open-loop ones rely on a fixed limit. +""" +const limit_follows_pot = Ref(false) +limit_mA_for(pos) = 160.74 + (pos - 204) * 0.831 + +"Every `(enableAdjust, enableDiode)` pair sent to `LD_EnableMaxCurrentAdjust`." +const adjust_calls = Tuple{Any,Any}[] + +"The W/A factor the controller holds, and an override for what it reads back." +const wa = Ref{Float32}(0f0) +const wa_readback = Ref{Union{Nothing,Float32}}(nothing) + +"What `LD_StartPolling` returns: a success FLAG." +const polling_ok = Ref(true) + "Forget the recorded history and restore the default responses." function reset!(; limit_raw::Integer=23830) empty!(calls) @@ -88,6 +151,20 @@ function reset!(; limit_raw::Integer=23830) empty!(setpoints) empty!(throws) diode_limit_raw[] = limit_raw + current_raw[] = nothing + photocurrent_raw[] = 0 + bits[] = KEY | INTERLOCK | PSU_OK | TIA_1mA + closed_loop_takes[] = true + setpoint_held[] = 0 + setpoint_readback[] = nothing + digpot[] = 204 + limit_follows_pot[] = false + digpot_takes[] = true + empty!(digpot_sets) + empty!(adjust_calls) + wa[] = 0f0 + wa_readback[] = nothing + polling_ok[] = true return nothing end @@ -108,6 +185,9 @@ fail!(op::AbstractString, code::Integer=1) = (status[op] = code; nothing) "Make `op` raise an `ErrorException` from now on, rather than report a code." throw!(op::AbstractString, msg::AbstractString="fake Kinesis failure in $op") = (throws[op] = msg; nothing) +"Set or clear status bits." +setbits!(mask; on::Bool=true) = (bits[] = on ? (bits[] | UInt32(mask)) : (bits[] & ~UInt32(mask)); nothing) + end @eval MicroscopeControl.HardwareImplementations.TCubeLaserControl begin @@ -115,22 +195,95 @@ end TLI_GetDeviceListSize() = (Main.FakeKinesis.record!("TLI_GetDeviceListSize"); 1) LD_Open(serialNo) = Main.FakeKinesis.record!("LD_Open") LD_Close(serialNo) = (Main.FakeKinesis.record!("LD_Close"); nothing) - LD_SetOpenLoopMode(serialNo) = Main.FakeKinesis.record!("LD_SetOpenLoopMode") + function LD_SetOpenLoopMode(serialNo) + s = Main.FakeKinesis.record!("LD_SetOpenLoopMode") + s == 0 && Main.FakeKinesis.setbits!(Main.FakeKinesis.CLOSED; on=false) + return s + end + function LD_SetClosedLoopMode(serialNo) + s = Main.FakeKinesis.record!("LD_SetClosedLoopMode") + (s == 0 && Main.FakeKinesis.closed_loop_takes[]) && Main.FakeKinesis.setbits!(Main.FakeKinesis.CLOSED) + return s + end LD_RequestReadings(serialNo) = Main.FakeKinesis.record!("LD_RequestReadings") LD_RequestLaserDiodeMaxCurrentLimit(serialNo) = Main.FakeKinesis.record!("LD_RequestLaserDiodeMaxCurrentLimit") - LD_EnableOutput(serialNo) = Main.FakeKinesis.record!("LD_EnableOutput") - LD_DisableOutput(serialNo) = Main.FakeKinesis.record!("LD_DisableOutput") + function LD_EnableOutput(serialNo) + s = Main.FakeKinesis.record!("LD_EnableOutput") + s == 0 && Main.FakeKinesis.setbits!(Main.FakeKinesis.ENABLED) + return s + end + function LD_DisableOutput(serialNo) + s = Main.FakeKinesis.record!("LD_DisableOutput") + s == 0 && Main.FakeKinesis.setbits!(Main.FakeKinesis.ENABLED; on=false) + return s + end function LD_SetLaserSetPoint(serialNo, laserDiodeCurrent) push!(Main.FakeKinesis.setpoints, laserDiodeCurrent) - return Main.FakeKinesis.record!("LD_SetLaserSetPoint") + s = Main.FakeKinesis.record!("LD_SetLaserSetPoint") + on = Main.FakeKinesis.bits[] & Main.FakeKinesis.ENABLED != 0 + (s == 0 && on) && (Main.FakeKinesis.setpoint_held[] = laserDiodeCurrent) # ignored with the output off + return s + end + LD_RequestLaserSetPoint(serialNo) = Main.FakeKinesis.record!("LD_RequestLaserSetPoint") + function LD_GetLaserSetPoint(serialNo) + Main.FakeKinesis.record!("LD_GetLaserSetPoint") + Main.FakeKinesis.setpoint_readback[] === nothing || return Main.FakeKinesis.setpoint_readback[] + on = Main.FakeKinesis.bits[] & Main.FakeKinesis.ENABLED != 0 + return on ? Main.FakeKinesis.setpoint_held[] : Main.FakeKinesis.STALE_SETPOINT end function LD_GetLaserDiodeMaxCurrentLimit(serialNo) Main.FakeKinesis.record!("LD_GetLaserDiodeMaxCurrentLimit") - return Main.FakeKinesis.diode_limit_raw[] + Main.FakeKinesis.limit_follows_pot[] || return Main.FakeKinesis.diode_limit_raw[] + return floor(Int, Main.FakeKinesis.limit_mA_for(Main.FakeKinesis.digpot[]) / 220 * 32767) end function LD_GetLaserDiodeCurrentReading(serialNo) Main.FakeKinesis.record!("LD_GetLaserDiodeCurrentReading") - return Main.FakeKinesis.diode_limit_raw[] + return something(Main.FakeKinesis.current_raw[], Main.FakeKinesis.diode_limit_raw[]) + end + function LD_GetPhotoCurrentReading(serialNo) + Main.FakeKinesis.record!("LD_GetPhotoCurrentReading") + return Main.FakeKinesis.photocurrent_raw[] end + LD_RequestStatusBits(serialNo) = Main.FakeKinesis.record!("LD_RequestStatusBits") + function LD_GetStatusBits(serialNo) + Main.FakeKinesis.record!("LD_GetStatusBits") + return Main.FakeKinesis.bits[] + end + function LD_EnableMaxCurrentAdjust(serialNo, enableAdjust, enableDiode) + push!(Main.FakeKinesis.adjust_calls, (enableAdjust, enableDiode)) + return Main.FakeKinesis.record!("LD_EnableMaxCurrentAdjust") + end + function LD_SetMaxCurrentDigPot(serialNo, maxCurrent) + push!(Main.FakeKinesis.digpot_sets, Int(maxCurrent)) + s = Main.FakeKinesis.record!("LD_SetMaxCurrentDigPot") + (s == 0 && Main.FakeKinesis.digpot_takes[]) && (Main.FakeKinesis.digpot[] = Int(maxCurrent)) + return s + end + LD_RequestMaxCurrentDigPot(serialNo) = Main.FakeKinesis.record!("LD_RequestMaxCurrentDigPot") + function LD_GetMaxCurrentDigPot(serialNo) + Main.FakeKinesis.record!("LD_GetMaxCurrentDigPot") + return UInt16(Main.FakeKinesis.digpot[]) + end + function LD_SetWACalibFactor(serialNo, calibFactor) + s = Main.FakeKinesis.record!("LD_SetWACalibFactor") + s == 0 && (Main.FakeKinesis.wa[] = Float32(calibFactor)) + return s + end + LD_RequestWACalibFactor(serialNo) = Main.FakeKinesis.record!("LD_RequestWACalibFactor") + function LD_GetWACalibFactor(serialNo) + Main.FakeKinesis.record!("LD_GetWACalibFactor") + return something(Main.FakeKinesis.wa_readback[], Main.FakeKinesis.wa[]) + end + function LD_StartPolling(serialNo, milliseconds) + Main.FakeKinesis.record!("LD_StartPolling") + return Main.FakeKinesis.polling_ok[] + end + LD_StopPolling(serialNo) = (Main.FakeKinesis.record!("LD_StopPolling"); nothing) end + +# The driver waits between a Kinesis request and the read of its answer; the +# fake answers immediately, so the suite does not need to wait. +MicroscopeControl.HardwareImplementations.TCubeLaserControl.REQUEST_WAIT_S[] = 0.0 +MicroscopeControl.HardwareImplementations.TCubeLaserControl.CLAMP_WAIT_S[] = 0.0 +MicroscopeControl.HardwareImplementations.TCubeLaserControl.SETPOINT_CONFIRM_TIMEOUT_S[] = 0.05 From db046c2a98033e360c9d7e491b0ad320e64b64e0 Mon Sep 17 00:00:00 2001 From: Ali Kazemi Date: Tue, 29 Sep 2026 00:43:20 -0600 Subject: [PATCH 04/37] TCube closed loop: ramp upward setpoints; verified to 40 mW on the 642 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 --- CHANGELOG.md | 16 +++-- skills/mc-extend/references/rig-causes.md | 2 +- .../references/driver-caveats.md | 2 +- .../tcube_laser/CALIBRATION.md | 52 +++++++++++----- .../tcube_laser/interface_methods.jl | 61 +++++++++++++++++-- test/runtests.jl | 26 ++++++-- test/tcube_fake_sdk.jl | 1 + 7 files changed, 128 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 570ab89..3d94aa6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,12 +21,16 @@ Laser diodes in two regulation modes, and closed loop (photodiode feedback, - **Open loop: verified.** 70 / 90 / 110 mA gave 70.0 / 90.0 / 110.0 mA on the front display and 1.69 / 20.67 / 39.42 mW. -- **Closed loop: verified from 1 to 5 mW only.** Measured = requested - 0.46 mW, - slope 0.99, so the 224.2 W/A calibration holds. **Above about 5 mW it failed on - this rig**: the photodiode reading clips at about 3213 counts (~21 mW) and - reads `0x8000` above it, and the loop held ~21 mW for a 10 mW request. That is - the controller's photodiode channel (range DIP switch or TIA gain), not this - driver or the calibration; `CALIBRATION.md` says what to check. +- **Closed loop: verified from 1 to 40 mW** (2026-09-28/29): 1, 2, 3, 5, 10, + 20 and 40 mW gave 0.56, 1.55, 2.54, 4.52, 9.30, 19.04 and 38.74 mW, so the + 224.2 W/A calibration holds. One controller behaviour had to be worked + around: **a setpoint jumped from 0 locks the loop** at ~21 mW / 90 mA + whatever the request (3 of 3 attempts at 10 mW), while the same target + reached in steps regulates exactly, as the Kinesis application does. The + driver therefore **ramps** upward closed-loop steps, 3 mW every 10 ms (40 mW + in ~0.2 s; `RAMP_STEP_mW`, `RAMP_STEP_S`). The mechanism is not known; the + ramp is empirical. The photodiode's UNDER-range flag now warns instead of + refusing (it was set at 1 mW while the loop regulated correctly). - **Found on the rig and fixed before release**, none of which the fake SDK could have shown: the TLD001 **ignores setpoints sent while its output is off** (so the driver sends them right after enabling, and zeroes the setpoint diff --git a/skills/mc-extend/references/rig-causes.md b/skills/mc-extend/references/rig-causes.md index f6d3356..5dd2c6a 100644 --- a/skills/mc-extend/references/rig-causes.md +++ b/skills/mc-extend/references/rig-causes.md @@ -22,7 +22,7 @@ and each looks like a broken `ccall`. | `initialize` in power mode throws "the controller's photodiode range is X A but tia_range states Y A" or "reports no single photodiode range" | The rear-panel TIA DIP switch does not match the `tia_range` keyword. **[guarantee]** refused because the W/A calibration is valid only on the range it was measured on. | Set the switch to the calibrated range (`10e-6`, `100e-6`, `1e-3` or `10e-3` A); do not just edit `tia_range` unless you recalibrate on the new range. | | `initialize` in power mode throws about the max-current potentiometer or clamp | **[guarantee]** `initialize` leaves the max-current potentiometer at the highest position whose limit, **as the controller reports it**, is `<= max_current`, and records that reported limit in `pd.max_current_clamp`. Hardware-verified on the 642 nm rig (2026-09-28): a plain pot write is ignored, adjust mode is required with a pause before leaving it, and the header's position-to-mA scale is wrong for that unit, so no clamp value is computed from a position. The pot position does not survive a controller power cycle. | Report it with the message, the pinned tag and the controller firmware; until then run `ConstantCurrent`. `light_on`/`setoutputpower!` refuse while `laser.pd.max_current_clamp` is `NaN`. | | A `TCubeLaser` goes to its current limit whatever setpoint was sent (front display at the limit, power far above the request) | The TLD001 **ignores a setpoint sent while its output is off**, and on enable runs on its stored setpoint (on the 642 nm rig a word above full scale). **[guarantee]** since 0.3.0 the driver never sends a setpoint with the output off: `light_on` sends it right after enabling, and `light_off` zeroes it before disabling. Code or software that sets the current *before* enabling the output (including the pre-0.3.0 driver used in that order) hits this. | Upgrade, or enable the output first and then set the current. | -| Power mode holds about 21 mW whatever is requested above ~5 mW, with `loop_status().photocurrent_A` flat near 98 µA | **[limitation]** observed on the 642 nm rig (2026-09-28): the photodiode reading clips at about 3213 counts (~10 % of the 1 mA range the status bits report) and reads `0x8000` (over range) above it, and the loop then holds the clip level instead of the setpoint. 1-5 mW regulated correctly. This is the controller's photodiode channel set-up (DIP range switch or TIA gain), not the calibration: 224.2 W/A held where the channel worked. | Check the rear-panel photodiode-range DIP switch and the TIA gain calibration per the TLD001 manual (see `tcube_laser/CALIBRATION.md`); until then keep `properties.max_power` at or below ~5 mW, or use `ConstantCurrent`. | +| Power mode holds about 21 mW (~90 mA, `loop_status().photocurrent_A` pinned near 98 µA) whatever is requested | The TLD001 locks its loop when the setpoint is JUMPED up from a low value; observed 3 of 3 times at 10 mW on the 642 nm rig (2026-09-28/29), while the same target reached in steps regulated exactly (9.30 mW measured, as Kinesis). **[guarantee]** since 0.3.0 the driver ramps upward closed-loop steps (`RAMP_STEP_mW` = 3 mW every `RAMP_STEP_S` = 10 ms; 40 mW in ~0.2 s), verified at 10, 20 and 40 mW. **[limitation]** the mechanism is unknown; the ramp is empirical. | Upgrade. If it recurs, lower `RAMP_STEP_mW[]` (1 mW was also verified) and report the run. | | `loop_status(laser).saturated` is `true` in power mode | The drive current is at the clamp, so the power is not being held: a blocked or misaligned monitor photodiode, or a requested power the diode cannot reach under the clamp. **[policy]** treat a sustained `saturated` as a fault in unattended runs. | Check `measured_current` against `pd.max_current_clamp`; unblock the photodiode, lower the request, or (if the rig allows) raise `max_current`. `below_threshold === true` is a different fault: the diode is not lasing. | | `initialize(::TCubeLaser)` fails once, then every retry fails at `LD_Open` | **[fixed in v0.2.3]** a failure after a successful `LD_Open` — a mode change, a readings request — threw without closing, and a controller left open refuses the next `LD_Open`, so the first failure poisoned the retry. From v0.2.3 the handle is closed on the way out and the original error is the one raised. | On an older pinned tag, call `TCubeLaserControl.LD_Close(laser.serialNo)` before retrying, or power-cycle the cube. | | `setupIO(::TCubeLaser)` throws `BoundsError`, or modulates the wrong analogue output | It hardcoded `devs[2]` and `channelsAO[2]`. **[fixed in v0.2.3]** the defaults are unchanged (a working rig keeps working) but the indices are validated with a message naming what discovery found, and `TCubeLaser(serialNo; daq_device=, ao_channel=)` names them explicitly. | `NIDAQcard.showdevices(NIdaq())` from Julia; pass `daq_device=`/`ao_channel=`. | diff --git a/skills/mc-system-design/references/driver-caveats.md b/skills/mc-system-design/references/driver-caveats.md index dcaf162..1628074 100644 --- a/skills/mc-system-design/references/driver-caveats.md +++ b/skills/mc-system-design/references/driver-caveats.md @@ -45,7 +45,7 @@ The runtime constraints come from object ownership: |---|---|---|---| | `LCC1620` | `scope::Triggerscope4` | keyword `scope=`; defaults to a fresh `Triggerscope4()` on `COM3` | construct the scope first if you want to share it. `initialize(att)` calls `initialize(att.scope)` itself when the scope's port is not open, then sets the DAC range and drives to `min_voltage`. `shutdown(att)` drives the channel to `min_voltage` (0 V, which is **full transmission** on the LCC1620) and leaves the port open. | | `XEM` (module `OK_XEM`) | `daq::NIdaq` | keyword `daq=`; defaults to `NIdaq()` | none: `NIdaq` holds no connection (tasks are created and deleted per operation, `initialize`/`shutdown` are no-ops) | -| `TCubeLaser` | `daq::NIdaq` for the modulation channel | keyword `daq=` | none, as above. `TCubeLaserControl.setupIO(laser)` creates the AO task and picks the **second** discovered device and its **second** AO channel unless you pass `daq_device=`/`ao_channel=` (those keywords, and a validating error message in place of a bare `BoundsError`, are v0.2.3). **[guarantee]** (0.3.0, traced) `initialize` in `ConstantPhotocurrent` mode is a protected sequence and never enables the output: it requires key switch and interlock; requires the controller's TIA range bits to match `tia_range`; disables the output (it cannot zero the setpoint: the TLD001 ignores setpoints while the output is off, so `light_on` sends the setpoint right after enabling and `light_off` zeroes it before disabling); leaves the max-current potentiometer at the highest position whose controller-reported limit is `<= max_current` and records that limit in `pd.max_current_clamp`; enters closed loop and verifies the status bit; writes and reads back the W/A factor. Any failed step throws and closes the handle. `light_on` and `setoutputpower!` refuse until the clamp is verified. Both modes start Kinesis polling (50 ms), so the readbacks are cache reads. Hardware-verified on the 642 nm rig (2026-09-28, output off and on): polling refreshes the setpoint cache, the pot needs adjust mode, setpoints only take with the output on. **[limitation]** on that rig the photodiode reading clipped at ~3213 counts and power mode held ~21 mW for requests above ~5 mW; see `mc-extend/references/rig-causes.md` | +| `TCubeLaser` | `daq::NIdaq` for the modulation channel | keyword `daq=` | none, as above. `TCubeLaserControl.setupIO(laser)` creates the AO task and picks the **second** discovered device and its **second** AO channel unless you pass `daq_device=`/`ao_channel=` (those keywords, and a validating error message in place of a bare `BoundsError`, are v0.2.3). **[guarantee]** (0.3.0, traced) `initialize` in `ConstantPhotocurrent` mode is a protected sequence and never enables the output: it requires key switch and interlock; requires the controller's TIA range bits to match `tia_range`; disables the output (it cannot zero the setpoint: the TLD001 ignores setpoints while the output is off, so `light_on` sends the setpoint right after enabling and `light_off` zeroes it before disabling); leaves the max-current potentiometer at the highest position whose controller-reported limit is `<= max_current` and records that limit in `pd.max_current_clamp`; enters closed loop and verifies the status bit; writes and reads back the W/A factor. Any failed step throws and closes the handle. `light_on` and `setoutputpower!` refuse until the clamp is verified. Both modes start Kinesis polling (50 ms), so the readbacks are cache reads. Hardware-verified on the 642 nm rig (2026-09-28, output off and on): polling refreshes the setpoint cache, the pot needs adjust mode, setpoints only take with the output on. Closed loop verified at 1-40 mW; upward setpoint steps are ramped (3 mW / 10 ms) because a jump from 0 locks the controller's loop at ~21 mW; see `mc-extend/references/rig-causes.md` | | `CrystaLaser`, `VortranLaser`, `DaqTrLight` | `daq::NIdaq` | **built internally**; no keyword, cannot be shared | none; each constructor does its own discovery (see side effects) | Because `NIdaq` is a stateless handle, several devices each holding their own diff --git a/src/hardware_implementations/tcube_laser/CALIBRATION.md b/src/hardware_implementations/tcube_laser/CALIBRATION.md index 5117bd1..c9eebf8 100644 --- a/src/hardware_implementations/tcube_laser/CALIBRATION.md +++ b/src/hardware_implementations/tcube_laser/CALIBRATION.md @@ -64,9 +64,7 @@ laser = TCubeLaser("64849775"; tec_stabilised = missing, # true / false once known; `missing` is honest until then threshold_current = 65.0, # mA max_current = 160.0, # mA: programmed into the controller as the loop's clamp - properties = LightSourceProperties("mW", 0.0, false, 1.0, 5.0)) # [1 mW, 5 mW] -# 5 mW, not 70: until the photodiode channel is fixed (see "Hardware check, -# 2026-09-28"), closed loop on this rig only regulated up to about 5 mW. + properties = LightSourceProperties("mW", 0.0, false, 1.0, 70.0)) # [1 mW, 70 mW] initialize(laser) # checks key, interlock and the DIP switch; programs the clamp; never emits setoutputpower!(laser, 20.0) # mW at the laser output @@ -101,14 +99,37 @@ Closed loop (`ConstantPhotocurrent`, 224.2 W/A, 1 mA range): | 10 mW | 21.42 mW | 98.09 µA (stuck) | 90.8 mA | Where the loop regulated, measured = requested - 0.46 mW with a slope of 0.99: -**the 224.2 W/A calibration still holds.** But the photodiode channel **clips -at about 3213 counts (98 µA, ~21 mW at this W/A) and reads `0x8000` above it**, -and above roughly 5 mW the loop held that clip level (~21 mW) instead of the -setpoint. 98 µA is almost exactly the full scale of the **100 µA** range, while -the status bits report the 1 mA range, and in 2024 the same calibration was -verified in closed loop up to 80 mW. So the photodiode channel's set-up has -changed: see "The photodiode range and the TIA gain" below. **Until it is -fixed, closed loop is only usable up to about 5 mW** (`properties.max_power`). +**the 224.2 W/A calibration still holds.** The 5 and 10 mW failures were +diagnosed the next day (below): not the photodiode channel, but a **jump of +the setpoint from 0**, which locks the loop at ~21 mW (~90 mA, photocurrent +pinned near 98 µA) whatever the request. The Kinesis application at 10 mW +gave 9.30 mW / 77.9 mA / 44.56 µA, i.e. exactly the driver's setpoint; the +difference was only how the setpoint is reached. + +### 2026-09-29: the ramp, and closed loop verified to 40 mW + +Re-checked after the manual's PD range / gain procedure (1 mA range confirmed +"In Range"; 386 µA at the 160 mA limit in Kinesis; gain re-optimised). The +driver now **ramps** upward closed-loop steps ([`RAMP_STEP_mW`] = 3 mW every +[`RAMP_STEP_S`] = 10 ms, i.e. 40 mW in about 0.2 s): + +| requested | reached by | measured | drive current | +|---:|---|---:|---:| +| 10 mW | jump from 0 (before the fix) | 21.38 mW | 90.4 mA | +| 10 mW | 1 mW steps, 2 s apart | 9.31 mW | 77 mA | +| 10 mW | 1 mW steps, 50 / 20 / 10 / 5 ms apart | 9.29 / 9.31 / 9.30 / 9.31 mW | 77.7 mA | +| 20 mW | 1 mW steps, 50 ms | 19.04 mW | 88.2 mA | +| 40 mW | 1 mW steps, 50 ms | 38.79 mW | 109.1 mA | +| 40 mW | **3 mW steps, 10 ms (shipped default)** | **38.74 mW** | 109.3 mA | + +Open loop the same day: 110 mA -> 39.43 mW, 130 mA -> 57.71 mW. + +Measured power is requested x 0.97 - ~0.3 mW over 1-40 mW (the loop holds the +photocurrent exactly; the meter reads a little under 224.2 W/A's prediction). +Closed loop is therefore usable over the rig's range; `properties.max_power` +can be set to what the rig needs (70 mW has not been re-verified since 2024, +but nothing changed between 20 and 40 mW). Why a jump locks the loop is not +known; the ramp is empirical, verified at 10, 20 and 40 mW. Found on the same day, and fixed in the driver: the controller ignores a setpoint sent while its output is off (it then runs on a stale stored setpoint, @@ -171,10 +192,11 @@ matched. The manuals are on the lab share: `Z:\Computers and Software\isos and Install Files\ThorLabs\TLD001 Laser Diode Driver\` (17874-d03.pdf = Kinesis, 17874-d01.pdf = APT). -What to check first on the 642 nm rig: which range the DIP switch is physically -on, and whether the reading ceiling moves when it is changed. The 98 µA ceiling -with the status bits saying 1 mA suggests the switch position and the reported -range disagree, or that the TIA gain is set for the wrong range. +Done on the 642 nm rig on 2026-09-28: the 1 mA range showed "In Range" with +386 µA at the 160 mA limit (10 mA showed 383 µA, i.e. under range), and the +gain was re-optimised and persisted. It did not change the light (110 mA gave +39.43 mW before and after); the ~98 µA "ceiling" seen that day turned out to +be the setpoint-jump lock described above, not the photodiode channel. ## When to recalibrate diff --git a/src/hardware_implementations/tcube_laser/interface_methods.jl b/src/hardware_implementations/tcube_laser/interface_methods.jl index 1e383ea..6dba760 100644 --- a/src/hardware_implementations/tcube_laser/interface_methods.jl +++ b/src/hardware_implementations/tcube_laser/interface_methods.jl @@ -482,6 +482,56 @@ function send_setpoint(light::TCubeLaser, code::UInt16) return nothing end +""" + RAMP_STEP_mW, RAMP_STEP_S + +In closed loop a large upward setpoint step from a low level locks the loop: +hardware-verified on the 642 nm rig's TLD001 (2026-09-29), a jump from 0 to +10 mW settled at ~90 mA / 21 mW (the photodiode reading pinned at 98 µA) +whatever the request, while the same target reached in steps settled at +78 mA / 9.3 mW, exactly as the Kinesis application does. So +[`send_setpoint_ramped`](@ref) walks any upward closed-loop step in +`RAMP_STEP_mW` increments `RAMP_STEP_S` apart. Downward steps and open loop +are sent directly. + +The defaults, 3 mW every 10 ms, were chosen on the rig: 1 mW steps worked at +2 s, 50 ms, 20 ms, 10 ms and 5 ms spacing (9.29-9.31 mW measured for 10 mW +each time); below ~10 ms the USB round trip per write (~15 ms) sets the pace, +so 5 ms was no faster than 10 ms; and 3 mW steps at 10 ms reached 40 mW in +0.22 s at 38.74 mW measured, the same as 1 mW steps. 0 -> 5 mW in one jump +worked once and failed once, so the step is kept at 3 mW. Both are `Ref`s so +the test suite can zero the pause and a rig can tune the step. Whether the +mechanism is a loop transient or something in the controller's firmware is +not known; the ramp is an empirical fix, verified at 10, 20 and 40 mW against +three failures out of three for the jump. +""" +const RAMP_STEP_mW = Ref(3.0) + +"See [`RAMP_STEP_mW`](@ref)." +const RAMP_STEP_S = Ref(0.01) + +""" + send_setpoint_ramped(light::TCubeLaser, code::UInt16) + +Send a setpoint with the output on. In `ConstantPhotocurrent` mode an upward +step larger than [`RAMP_STEP_mW`](@ref) is walked up from the setpoint the +controller currently holds, one step every `RAMP_STEP_S`; the final code is +confirmed by [`send_setpoint`](@ref). +""" +send_setpoint_ramped(light::TCubeLaser{ConstantCurrent}, code::UInt16) = send_setpoint(light, code) +function send_setpoint_ramped(light::TCubeLaser{ConstantPhotocurrent}, code::UInt16) + serialNo, pd = light.serialNo, light.pd + start = Int(LD_GetLaserSetPoint(serialNo)) + step = max(1, round(Int, RAMP_STEP_mW[] / 1000 / pd.wa_calibration / pd.tia_range * light.max_setpoint)) + if 0 <= start && Int(code) - start > step + for c in (start + step):step:(Int(code) - 1) + check_err(LD_SetLaserSetPoint(serialNo, UInt16(c)), "LD_SetLaserSetPoint", serialNo) + sleep(RAMP_STEP_S[]) + end + end + send_setpoint(light, code) +end + """ intended_code(light::TCubeLaser) @@ -664,7 +714,7 @@ function LightSourceInterface.light_on(light::TCubeLaser) code = intended_code(light) check_err(LD_EnableOutput(serialNo), "LD_EnableOutput", serialNo) try - send_setpoint(light, code) + send_setpoint_ramped(light, code) catch try LD_DisableOutput(serialNo) @@ -755,10 +805,13 @@ function LightSourceInterface.setoutputpower!(light::TCubeLaser{ConstantPhotocur "was the mode changed on the front panel? Call initialize again.") (bits & STATUS_BITS.tia_over != 0 || (on && Int(LD_GetPhotoCurrentReading(serialNo)) == PHOTOCURRENT_OVER_RANGE)) && error( "TCubeLaser $serialNo: setoutputpower! refused: the photodiode amplifier reports OVER range, so the loop's feedback is invalid") - (bits & STATUS_BITS.tia_under != 0 && on) && error( - "TCubeLaser $serialNo: setoutputpower! refused: the photodiode amplifier reports UNDER range with the output on, so the loop's feedback is invalid") + # UNDER range means the photocurrent is small for the selected range, not + # that it is invalid: on the 642 nm rig the flag was set at 1 mW (4.5 µA on + # the 1 mA range, 2026-09-29) while the loop regulated correctly. Warn only. + (bits & STATUS_BITS.tia_under != 0 && on) && @warn( + "TCubeLaser $serialNo: the photodiode amplifier reports UNDER range with the output on; the photocurrent is small for the selected range and its resolution is reduced") code = photocurrent_code(light, power_mW / 1000 / pd.wa_calibration) - on && send_setpoint(light, code) + on && send_setpoint_ramped(light, code) pd.output_power_requested = power_mW pd.photocurrent_requested = photocurrent_from_code(light, code, pd.tia_range) println("Laser output power set to $power_mW mW (photocurrent setpoint $(pd.photocurrent_requested) A)", diff --git a/test/runtests.jl b/test/runtests.jl index 69a85ef..f939d46 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -607,15 +607,29 @@ include("tcube_fake_sdk.jl") @test laser.pd.output_power_requested == 50.0 empty!(FakeKinesis.calls) light_on(laser) - @test FakeKinesis.calls == ["LD_EnableOutput", "LD_SetLaserSetPoint", "LD_GetLaserSetPoint"] + # From 0 to 50 mW the setpoint is RAMPED in ~3 mW steps (the rig's loop + # locks on a big jump): enable, read the held setpoint, ~16 intermediate + # sends, then the final one, confirmed. + @test FakeKinesis.calls[1:2] == ["LD_EnableOutput", "LD_GetLaserSetPoint"] + @test FakeKinesis.calls[end] == "LD_GetLaserSetPoint" + @test all(==("LD_SetLaserSetPoint"), FakeKinesis.calls[3:end-1]) code = FakeKinesis.setpoints[end] @test code == UInt16(floor(i_pd / 1e-3 * 32767)) @test FakeKinesis.setpoint_held[] == code + step = round(Int, TCube.RAMP_STEP_mW[] / 1000 / 224.2 / 1e-3 * 32767) # 3 mW steps + @test 15 <= length(FakeKinesis.setpoints) <= 18 + @test issorted(FakeKinesis.setpoints) + @test all(d -> 0 < d <= step, diff(Int.(FakeKinesis.setpoints))) # With the output on, a new power is sent and confirmed at once (after # checking the photodiode is not reading 0x8000, over range). empty!(FakeKinesis.calls) setoutputpower!(laser, 50.0) - @test FakeKinesis.calls == ["LD_GetStatusBits", "LD_GetPhotoCurrentReading", "LD_SetLaserSetPoint", "LD_GetLaserSetPoint"] + @test FakeKinesis.calls == ["LD_GetStatusBits", "LD_GetPhotoCurrentReading", "LD_GetLaserSetPoint", "LD_SetLaserSetPoint", "LD_GetLaserSetPoint"] + # Downward steps are sent directly, no ramp. + empty!(FakeKinesis.calls); empty!(FakeKinesis.setpoints) + setoutputpower!(laser, 10.0) + @test length(FakeKinesis.setpoints) == 1 + setoutputpower!(laser, 50.0) # 0x8000 is the rig controller's over-range reading: refuse, and report it. FakeKinesis.photocurrent_raw[] = -32768 @test_throws "OVER" setoutputpower!(laser, 20.0) @@ -645,8 +659,10 @@ include("tcube_fake_sdk.jl") setoutputpower!(laser, 20.0) @test laser.pd.output_power_requested == 20.0 light_on(laser) - @test_throws "UNDER" setoutputpower!(laser, 30.0) - @test laser.pd.output_power_requested == 20.0 # unchanged by a refusal + # UNDER range (small signal for the range) warns but is not a refusal: + # the rig regulated correctly with it set at 1 mW. + @test_logs (:warn,) match_mode = :any setoutputpower!(laser, 30.0) + @test laser.pd.output_power_requested == 30.0 FakeKinesis.setbits!(FakeKinesis.TIA_UNDER; on=false) # ... and a controller that left closed loop behind the driver's back. FakeKinesis.setbits!(FakeKinesis.CLOSED; on=false) @@ -655,7 +671,7 @@ include("tcube_fake_sdk.jl") # A setpoint the controller does not confirm is not recorded. FakeKinesis.setpoint_readback[] = UInt16(3) @test_throws ErrorException setoutputpower!(laser, 30.0) - @test laser.pd.output_power_requested == 20.0 + @test laser.pd.output_power_requested == 30.0 # the last confirmed request end @testset "light_on / light_off around the setpoint (fake SDK)" begin diff --git a/test/tcube_fake_sdk.jl b/test/tcube_fake_sdk.jl index cbe2621..cd6f60a 100644 --- a/test/tcube_fake_sdk.jl +++ b/test/tcube_fake_sdk.jl @@ -287,3 +287,4 @@ end MicroscopeControl.HardwareImplementations.TCubeLaserControl.REQUEST_WAIT_S[] = 0.0 MicroscopeControl.HardwareImplementations.TCubeLaserControl.CLAMP_WAIT_S[] = 0.0 MicroscopeControl.HardwareImplementations.TCubeLaserControl.SETPOINT_CONFIRM_TIMEOUT_S[] = 0.05 +MicroscopeControl.HardwareImplementations.TCubeLaserControl.RAMP_STEP_S[] = 0.0 From 40260f7917780723b6d44cf434753f88268e6369 Mon Sep 17 00:00:00 2001 From: Ali Kazemi Date: Tue, 29 Sep 2026 00:47:38 -0600 Subject: [PATCH 05/37] Record the Kinesis handover power-cycle and the ramp's USB-bound speed Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 9 ++++++++- skills/mc-extend/references/rig-causes.md | 1 + .../tcube_laser/CALIBRATION.md | 13 +++++++++++++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d94aa6..fffa1d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,7 +43,14 @@ Laser diodes in two regulation modes, and closed loop (photodiode feedback, Per the plan's contingency (§8.1), `mode` is a **required keyword with no default**: closed loop is not yet verified across the rig's working range. Not verified: the meaning of `LD_EnableMaxCurrentAdjust`'s second flag (always -passed `false`). +passed `false`); and how the Kinesis application itself brings the loop up +(the C API offers only `LD_SetLaserSetPoint`; the protocol document could not +be fetched and the application was not traced), so whether a single write +could be made to work is unknown. The ramp's ~0.2 s to 40 mW is almost all +USB round trips (~15 ms per write), so the most a single write could save is +that 0.2 s. Also observed: the controller must be power-cycled when switching +between the Kinesis application and this driver, in either direction, or the +next open fails (error code 2 / "load device failed"). **Breaking.** Every downstream rig using a `TCubeLaser` must change its construction line and its `setpower` calls; nothing it can write silently diff --git a/skills/mc-extend/references/rig-causes.md b/skills/mc-extend/references/rig-causes.md index 5dd2c6a..63f97b5 100644 --- a/skills/mc-extend/references/rig-causes.md +++ b/skills/mc-extend/references/rig-causes.md @@ -24,6 +24,7 @@ and each looks like a broken `ccall`. | A `TCubeLaser` goes to its current limit whatever setpoint was sent (front display at the limit, power far above the request) | The TLD001 **ignores a setpoint sent while its output is off**, and on enable runs on its stored setpoint (on the 642 nm rig a word above full scale). **[guarantee]** since 0.3.0 the driver never sends a setpoint with the output off: `light_on` sends it right after enabling, and `light_off` zeroes it before disabling. Code or software that sets the current *before* enabling the output (including the pre-0.3.0 driver used in that order) hits this. | Upgrade, or enable the output first and then set the current. | | Power mode holds about 21 mW (~90 mA, `loop_status().photocurrent_A` pinned near 98 µA) whatever is requested | The TLD001 locks its loop when the setpoint is JUMPED up from a low value; observed 3 of 3 times at 10 mW on the 642 nm rig (2026-09-28/29), while the same target reached in steps regulated exactly (9.30 mW measured, as Kinesis). **[guarantee]** since 0.3.0 the driver ramps upward closed-loop steps (`RAMP_STEP_mW` = 3 mW every `RAMP_STEP_S` = 10 ms; 40 mW in ~0.2 s), verified at 10, 20 and 40 mW. **[limitation]** the mechanism is unknown; the ramp is empirical. | Upgrade. If it recurs, lower `RAMP_STEP_mW[]` (1 mW was also verified) and report the run. | | `loop_status(laser).saturated` is `true` in power mode | The drive current is at the clamp, so the power is not being held: a blocked or misaligned monitor photodiode, or a requested power the diode cannot reach under the clamp. **[policy]** treat a sustained `saturated` as a fault in unattended runs. | Check `measured_current` against `pd.max_current_clamp`; unblock the photodiode, lower the request, or (if the rig allows) raise `max_current`. `below_threshold === true` is a different fault: the diode is not lasing. | +| `initialize(::TCubeLaser)` fails at `LD_Open` with Thorlabs error code 2 right after the Kinesis application used the controller (or Kinesis reports "load device failed" right after this driver did) | **[limitation]** observed on the 642 nm rig (2026-09-28/29): the TLD001 is not handed over cleanly between the Kinesis application and this DLL in either direction, even with the other side closed and no process holding it. | Power-cycle the TLD001 (off, 10 s, on) before switching between Kinesis and MicroscopeControl. | | `initialize(::TCubeLaser)` fails once, then every retry fails at `LD_Open` | **[fixed in v0.2.3]** a failure after a successful `LD_Open` — a mode change, a readings request — threw without closing, and a controller left open refuses the next `LD_Open`, so the first failure poisoned the retry. From v0.2.3 the handle is closed on the way out and the original error is the one raised. | On an older pinned tag, call `TCubeLaserControl.LD_Close(laser.serialNo)` before retrying, or power-cycle the cube. | | `setupIO(::TCubeLaser)` throws `BoundsError`, or modulates the wrong analogue output | It hardcoded `devs[2]` and `channelsAO[2]`. **[fixed in v0.2.3]** the defaults are unchanged (a working rig keeps working) but the indices are validated with a message naming what discovery found, and `TCubeLaser(serialNo; daq_device=, ao_channel=)` names them explicitly. | `NIDAQcard.showdevices(NIdaq())` from Julia; pass `daq_device=`/`ao_channel=`. | | A method "does nothing" or throws `not implemented for ` | It is a contract stub, not a defect in a `ccall`. | `which(f, Tuple{typeof(dev)}).sig` names `AbstractInstrument` or the interface type. See `mc-api-map`. Still worth reporting, as a missing method, not a wrong one. | diff --git a/src/hardware_implementations/tcube_laser/CALIBRATION.md b/src/hardware_implementations/tcube_laser/CALIBRATION.md index c9eebf8..323b84e 100644 --- a/src/hardware_implementations/tcube_laser/CALIBRATION.md +++ b/src/hardware_implementations/tcube_laser/CALIBRATION.md @@ -124,6 +124,19 @@ driver now **ramps** upward closed-loop steps ([`RAMP_STEP_mW`] = 3 mW every Open loop the same day: 110 mA -> 39.43 mW, 130 mA -> 57.71 mW. +How the Kinesis application reaches a power setpoint was not determined (the C +API has only `LD_SetLaserSetPoint`; the APT protocol document could not be +fetched and the application was not traced with a USB capture). The ramp's +time is almost all USB round trips, about 15 ms per write, so 5 ms pauses were +no faster than 10 ms; a USB trace of Kinesis (Wireshark + USBPcap while it +sets 10 mW) is the way to find out whether a single write can work. + +Two practical notes from the session: the controller must be **power-cycled +when switching between the Kinesis application and this driver**, in either +direction (`LD_Open` error 2, or "load device failed" in Kinesis, until then); +and the max-current potentiometer position does not survive a power cycle +(`initialize` re-programs it every time). + Measured power is requested x 0.97 - ~0.3 mW over 1-40 mW (the loop holds the photocurrent exactly; the meter reads a little under 224.2 W/A's prediction). Closed loop is therefore usable over the rig's range; `properties.max_power` From 9689d22c4d42226354021905a10329a979986e52 Mon Sep 17 00:00:00 2001 From: Ali Kazemi Date: Tue, 29 Sep 2026 01:09:34 -0600 Subject: [PATCH 06/37] TCube closed loop: single-write setpoints by default, ramp kept as fallback; 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 --- CHANGELOG.md | 21 +++++---- skills/mc-extend/references/rig-causes.md | 2 +- .../references/driver-caveats.md | 2 +- .../tcube_laser/CALIBRATION.md | 25 ++++++++--- .../tcube_laser/interface_methods.jl | 43 ++++++++++--------- test/runtests.jl | 35 ++++++++++----- 6 files changed, 80 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fffa1d6..6ef6756 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,15 +21,18 @@ Laser diodes in two regulation modes, and closed loop (photodiode feedback, - **Open loop: verified.** 70 / 90 / 110 mA gave 70.0 / 90.0 / 110.0 mA on the front display and 1.69 / 20.67 / 39.42 mW. -- **Closed loop: verified from 1 to 40 mW** (2026-09-28/29): 1, 2, 3, 5, 10, - 20 and 40 mW gave 0.56, 1.55, 2.54, 4.52, 9.30, 19.04 and 38.74 mW, so the - 224.2 W/A calibration holds. One controller behaviour had to be worked - around: **a setpoint jumped from 0 locks the loop** at ~21 mW / 90 mA - whatever the request (3 of 3 attempts at 10 mW), while the same target - reached in steps regulates exactly, as the Kinesis application does. The - driver therefore **ramps** upward closed-loop steps, 3 mW every 10 ms (40 mW - in ~0.2 s; `RAMP_STEP_mW`, `RAMP_STEP_S`). The mechanism is not known; the - ramp is empirical. The photodiode's UNDER-range flag now warns instead of +- **Closed loop: verified from 1 to 70 mW** (2026-09-28/29): 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, so the 224.2 W/A calibration holds. One controller behaviour had + to be guarded against: **a setpoint jumped from 0 locked the loop** at + ~21 mW / 90 mA whatever the request, 3 of 3 attempts at 10 mW, while the same + target reached in steps regulated exactly, as the Kinesis application does. + Later the same night the jump worked at 10 mW (4 of 4), 40 and 70 mW, so + the lock is intermittent and its cause unknown. The single write is the + default (fastest, ~10 ms); an optional ramp of upward closed-loop steps is + kept in the driver as the verified fallback (`RAMP_STEP_mW[] = 3.0`, + `RAMP_STEP_S[] = 0.01`: 40 mW in ~0.2 s, 70 mW in ~0.4 s; ramped runs never + failed, 10 of 10). The photodiode's UNDER-range flag now warns instead of refusing (it was set at 1 mW while the loop regulated correctly). - **Found on the rig and fixed before release**, none of which the fake SDK could have shown: the TLD001 **ignores setpoints sent while its output is diff --git a/skills/mc-extend/references/rig-causes.md b/skills/mc-extend/references/rig-causes.md index 63f97b5..a19f7a5 100644 --- a/skills/mc-extend/references/rig-causes.md +++ b/skills/mc-extend/references/rig-causes.md @@ -22,7 +22,7 @@ and each looks like a broken `ccall`. | `initialize` in power mode throws "the controller's photodiode range is X A but tia_range states Y A" or "reports no single photodiode range" | The rear-panel TIA DIP switch does not match the `tia_range` keyword. **[guarantee]** refused because the W/A calibration is valid only on the range it was measured on. | Set the switch to the calibrated range (`10e-6`, `100e-6`, `1e-3` or `10e-3` A); do not just edit `tia_range` unless you recalibrate on the new range. | | `initialize` in power mode throws about the max-current potentiometer or clamp | **[guarantee]** `initialize` leaves the max-current potentiometer at the highest position whose limit, **as the controller reports it**, is `<= max_current`, and records that reported limit in `pd.max_current_clamp`. Hardware-verified on the 642 nm rig (2026-09-28): a plain pot write is ignored, adjust mode is required with a pause before leaving it, and the header's position-to-mA scale is wrong for that unit, so no clamp value is computed from a position. The pot position does not survive a controller power cycle. | Report it with the message, the pinned tag and the controller firmware; until then run `ConstantCurrent`. `light_on`/`setoutputpower!` refuse while `laser.pd.max_current_clamp` is `NaN`. | | A `TCubeLaser` goes to its current limit whatever setpoint was sent (front display at the limit, power far above the request) | The TLD001 **ignores a setpoint sent while its output is off**, and on enable runs on its stored setpoint (on the 642 nm rig a word above full scale). **[guarantee]** since 0.3.0 the driver never sends a setpoint with the output off: `light_on` sends it right after enabling, and `light_off` zeroes it before disabling. Code or software that sets the current *before* enabling the output (including the pre-0.3.0 driver used in that order) hits this. | Upgrade, or enable the output first and then set the current. | -| Power mode holds about 21 mW (~90 mA, `loop_status().photocurrent_A` pinned near 98 µA) whatever is requested | The TLD001 locks its loop when the setpoint is JUMPED up from a low value; observed 3 of 3 times at 10 mW on the 642 nm rig (2026-09-28/29), while the same target reached in steps regulated exactly (9.30 mW measured, as Kinesis). **[guarantee]** since 0.3.0 the driver ramps upward closed-loop steps (`RAMP_STEP_mW` = 3 mW every `RAMP_STEP_S` = 10 ms; 40 mW in ~0.2 s), verified at 10, 20 and 40 mW. **[limitation]** the mechanism is unknown; the ramp is empirical. | Upgrade. If it recurs, lower `RAMP_STEP_mW[]` (1 mW was also verified) and report the run. | +| Power mode holds about 21 mW (~90 mA, `loop_status().photocurrent_A` pinned near 98 µA) whatever is requested | The TLD001 locks its loop when the setpoint is JUMPED up from a low value; observed 3 of 3 times at 10 mW on the 642 nm rig (2026-09-28/29), while the same target reached in steps regulated exactly (9.30 mW measured, as Kinesis). **[limitation]** the lock is intermittent (later the same night the jump worked at 10 mW 4 of 4 times, and at 40 and 70 mW) and its mechanism unknown. **[guarantee]** since 0.3.0 the driver sends a single write by default and carries an optional ramp of upward closed-loop steps, verified at 10-70 mW (10 of 10 ramped runs): `RAMP_STEP_mW[] = 3.0`, `RAMP_STEP_S[] = 0.01` (40 mW in ~0.2 s). | If it recurs, turn the ramp on (see the driver docstring) and report the run with the pinned tag. | | `loop_status(laser).saturated` is `true` in power mode | The drive current is at the clamp, so the power is not being held: a blocked or misaligned monitor photodiode, or a requested power the diode cannot reach under the clamp. **[policy]** treat a sustained `saturated` as a fault in unattended runs. | Check `measured_current` against `pd.max_current_clamp`; unblock the photodiode, lower the request, or (if the rig allows) raise `max_current`. `below_threshold === true` is a different fault: the diode is not lasing. | | `initialize(::TCubeLaser)` fails at `LD_Open` with Thorlabs error code 2 right after the Kinesis application used the controller (or Kinesis reports "load device failed" right after this driver did) | **[limitation]** observed on the 642 nm rig (2026-09-28/29): the TLD001 is not handed over cleanly between the Kinesis application and this DLL in either direction, even with the other side closed and no process holding it. | Power-cycle the TLD001 (off, 10 s, on) before switching between Kinesis and MicroscopeControl. | | `initialize(::TCubeLaser)` fails once, then every retry fails at `LD_Open` | **[fixed in v0.2.3]** a failure after a successful `LD_Open` — a mode change, a readings request — threw without closing, and a controller left open refuses the next `LD_Open`, so the first failure poisoned the retry. From v0.2.3 the handle is closed on the way out and the original error is the one raised. | On an older pinned tag, call `TCubeLaserControl.LD_Close(laser.serialNo)` before retrying, or power-cycle the cube. | diff --git a/skills/mc-system-design/references/driver-caveats.md b/skills/mc-system-design/references/driver-caveats.md index 1628074..a9fb191 100644 --- a/skills/mc-system-design/references/driver-caveats.md +++ b/skills/mc-system-design/references/driver-caveats.md @@ -45,7 +45,7 @@ The runtime constraints come from object ownership: |---|---|---|---| | `LCC1620` | `scope::Triggerscope4` | keyword `scope=`; defaults to a fresh `Triggerscope4()` on `COM3` | construct the scope first if you want to share it. `initialize(att)` calls `initialize(att.scope)` itself when the scope's port is not open, then sets the DAC range and drives to `min_voltage`. `shutdown(att)` drives the channel to `min_voltage` (0 V, which is **full transmission** on the LCC1620) and leaves the port open. | | `XEM` (module `OK_XEM`) | `daq::NIdaq` | keyword `daq=`; defaults to `NIdaq()` | none: `NIdaq` holds no connection (tasks are created and deleted per operation, `initialize`/`shutdown` are no-ops) | -| `TCubeLaser` | `daq::NIdaq` for the modulation channel | keyword `daq=` | none, as above. `TCubeLaserControl.setupIO(laser)` creates the AO task and picks the **second** discovered device and its **second** AO channel unless you pass `daq_device=`/`ao_channel=` (those keywords, and a validating error message in place of a bare `BoundsError`, are v0.2.3). **[guarantee]** (0.3.0, traced) `initialize` in `ConstantPhotocurrent` mode is a protected sequence and never enables the output: it requires key switch and interlock; requires the controller's TIA range bits to match `tia_range`; disables the output (it cannot zero the setpoint: the TLD001 ignores setpoints while the output is off, so `light_on` sends the setpoint right after enabling and `light_off` zeroes it before disabling); leaves the max-current potentiometer at the highest position whose controller-reported limit is `<= max_current` and records that limit in `pd.max_current_clamp`; enters closed loop and verifies the status bit; writes and reads back the W/A factor. Any failed step throws and closes the handle. `light_on` and `setoutputpower!` refuse until the clamp is verified. Both modes start Kinesis polling (50 ms), so the readbacks are cache reads. Hardware-verified on the 642 nm rig (2026-09-28, output off and on): polling refreshes the setpoint cache, the pot needs adjust mode, setpoints only take with the output on. Closed loop verified at 1-40 mW; upward setpoint steps are ramped (3 mW / 10 ms) because a jump from 0 locks the controller's loop at ~21 mW; see `mc-extend/references/rig-causes.md` | +| `TCubeLaser` | `daq::NIdaq` for the modulation channel | keyword `daq=` | none, as above. `TCubeLaserControl.setupIO(laser)` creates the AO task and picks the **second** discovered device and its **second** AO channel unless you pass `daq_device=`/`ao_channel=` (those keywords, and a validating error message in place of a bare `BoundsError`, are v0.2.3). **[guarantee]** (0.3.0, traced) `initialize` in `ConstantPhotocurrent` mode is a protected sequence and never enables the output: it requires key switch and interlock; requires the controller's TIA range bits to match `tia_range`; disables the output (it cannot zero the setpoint: the TLD001 ignores setpoints while the output is off, so `light_on` sends the setpoint right after enabling and `light_off` zeroes it before disabling); leaves the max-current potentiometer at the highest position whose controller-reported limit is `<= max_current` and records that limit in `pd.max_current_clamp`; enters closed loop and verifies the status bit; writes and reads back the W/A factor. Any failed step throws and closes the handle. `light_on` and `setoutputpower!` refuse until the clamp is verified. Both modes start Kinesis polling (50 ms), so the readbacks are cache reads. Hardware-verified on the 642 nm rig (2026-09-28, output off and on): polling refreshes the setpoint cache, the pot needs adjust mode, setpoints only take with the output on. Closed loop verified at 1-70 mW with single-write setpoints; an optional ramp (`RAMP_STEP_mW[]`) is kept because a jump from 0 once locked the controller's loop at ~21 mW, intermittently; see `mc-extend/references/rig-causes.md` | | `CrystaLaser`, `VortranLaser`, `DaqTrLight` | `daq::NIdaq` | **built internally**; no keyword, cannot be shared | none; each constructor does its own discovery (see side effects) | Because `NIdaq` is a stateless handle, several devices each holding their own diff --git a/src/hardware_implementations/tcube_laser/CALIBRATION.md b/src/hardware_implementations/tcube_laser/CALIBRATION.md index 323b84e..db84e2b 100644 --- a/src/hardware_implementations/tcube_laser/CALIBRATION.md +++ b/src/hardware_implementations/tcube_laser/CALIBRATION.md @@ -121,6 +121,8 @@ driver now **ramps** upward closed-loop steps ([`RAMP_STEP_mW`] = 3 mW every | 20 mW | 1 mW steps, 50 ms | 19.04 mW | 88.2 mA | | 40 mW | 1 mW steps, 50 ms | 38.79 mW | 109.1 mA | | 40 mW | **3 mW steps, 10 ms (shipped default)** | **38.74 mW** | 109.3 mA | +| 70 mW | 3 mW steps, 10 ms (0.38 s) | 68.59 mW | 141.4 mA | +| 10 mW | jump from 0, later the same night, 4 runs | 9.30 / 9.31 / 9.30 / 9.31 mW | 77.6-78.0 mA | Open loop the same day: 110 mA -> 39.43 mW, 130 mA -> 57.71 mW. @@ -137,12 +139,25 @@ direction (`LD_Open` error 2, or "load device failed" in Kinesis, until then); and the max-current potentiometer position does not survive a power cycle (`initialize` re-programs it every time). -Measured power is requested x 0.97 - ~0.3 mW over 1-40 mW (the loop holds the +Measured power is requested x 0.97 - ~0.3 mW over 1-70 mW (the loop holds the photocurrent exactly; the meter reads a little under 224.2 W/A's prediction). -Closed loop is therefore usable over the rig's range; `properties.max_power` -can be set to what the rig needs (70 mW has not been re-verified since 2024, -but nothing changed between 20 and 40 mW). Why a jump locks the loop is not -known; the ramp is empirical, verified at 10, 20 and 40 mW. +Closed loop is therefore verified over the rig's range, and +`properties.max_power = 70.0` is supported. Note that 70 mW needs ~141 mA, +above the 132 mA the rig had used as its working ceiling; the clamp at +`max_current` (160 mA) is the hard limit. + +**The lock is intermittent, and its cause is not known.** The jump from 0 to +10 mW failed 3 times out of 3 on 09-28/29 (before and after the gain +re-optimisation, before and after a power cycle), then, later on 09-29 after a +Kinesis CONST P session at 10 mW with the W/A factor persisted, worked 4 times +out of 4, and then also at 40 mW (controller readings identical to the ramped +run) and 70 mW (68.5 mW measured). Ramped runs have never failed (10 of 10, +10-70 mW). **The jump is the shipped default** (one write, ~10 ms) because it +is the fastest and was reliable when last tested; the ramp is kept in the +driver as a recorded, verified fallback: set `RAMP_STEP_mW[] = 3.0` and +`RAMP_STEP_S[] = 0.01` (40 mW in 0.22 s, 70 mW in 0.38 s). If the lock recurs +the symptom is unmistakable, ~90 mA and ~21 mW whatever the request; switch +the ramp on and report the run. Found on the same day, and fixed in the driver: the controller ignores a setpoint sent while its output is off (it then runs on a stale stored setpoint, diff --git a/src/hardware_implementations/tcube_laser/interface_methods.jl b/src/hardware_implementations/tcube_laser/interface_methods.jl index 6dba760..3504dd2 100644 --- a/src/hardware_implementations/tcube_laser/interface_methods.jl +++ b/src/hardware_implementations/tcube_laser/interface_methods.jl @@ -485,27 +485,27 @@ end """ RAMP_STEP_mW, RAMP_STEP_S -In closed loop a large upward setpoint step from a low level locks the loop: -hardware-verified on the 642 nm rig's TLD001 (2026-09-29), a jump from 0 to -10 mW settled at ~90 mA / 21 mW (the photodiode reading pinned at 98 µA) -whatever the request, while the same target reached in steps settled at -78 mA / 9.3 mW, exactly as the Kinesis application does. So -[`send_setpoint_ramped`](@ref) walks any upward closed-loop step in -`RAMP_STEP_mW` increments `RAMP_STEP_S` apart. Downward steps and open loop -are sent directly. - -The defaults, 3 mW every 10 ms, were chosen on the rig: 1 mW steps worked at -2 s, 50 ms, 20 ms, 10 ms and 5 ms spacing (9.29-9.31 mW measured for 10 mW -each time); below ~10 ms the USB round trip per write (~15 ms) sets the pace, -so 5 ms was no faster than 10 ms; and 3 mW steps at 10 ms reached 40 mW in -0.22 s at 38.74 mW measured, the same as 1 mW steps. 0 -> 5 mW in one jump -worked once and failed once, so the step is kept at 3 mW. Both are `Ref`s so -the test suite can zero the pause and a rig can tune the step. Whether the -mechanism is a loop transient or something in the controller's firmware is -not known; the ramp is an empirical fix, verified at 10, 20 and 40 mW against -three failures out of three for the jump. -""" -const RAMP_STEP_mW = Ref(3.0) +An optional ramp for upward closed-loop setpoint steps, **off by default** +(`RAMP_STEP_mW[] = Inf`: every setpoint is a single write, ~10 ms). Set +`RAMP_STEP_mW[]` to a finite value in mW and [`send_setpoint_ramped`](@ref) +walks any larger upward step in increments of that size, `RAMP_STEP_S[]` +apart. Downward steps and open loop are always sent directly. + +Why it exists, for the record (642 nm rig's TLD001, 2026-09-28/29): on one +night a setpoint jumped from 0 to 10 mW locked the loop at ~90 mA / ~21 mW +whatever the request, 3 times out of 3, while the same target reached in steps +settled at 78 mA / 9.3 mW, as the Kinesis application does. Later the same +night, after a Kinesis CONST P session, the jump worked 4 times out of 4 +(9.30-9.31 mW). Ramped runs never failed (10 of 10, 10-70 mW). The cause is +not known. The jump is the default because it is the fastest and was reliable +when last tested; if the lock recurs (the symptom: ~90 mA and ~21 mW whatever +is requested) set `RAMP_STEP_mW[] = 3.0` and `RAMP_STEP_S[] = 0.01`, which +were verified on the rig: 1 mW steps worked at 2 s, 50, 20, 10 and 5 ms +spacing; below ~10 ms the USB round trip per write (~15 ms) sets the pace; +3 mW steps at 10 ms reached 40 mW in 0.22 s (38.74 mW) and 70 mW in 0.38 s +(68.59 mW). +""" +const RAMP_STEP_mW = Ref(Inf) "See [`RAMP_STEP_mW`](@ref)." const RAMP_STEP_S = Ref(0.01) @@ -521,6 +521,7 @@ confirmed by [`send_setpoint`](@ref). send_setpoint_ramped(light::TCubeLaser{ConstantCurrent}, code::UInt16) = send_setpoint(light, code) function send_setpoint_ramped(light::TCubeLaser{ConstantPhotocurrent}, code::UInt16) serialNo, pd = light.serialNo, light.pd + isfinite(RAMP_STEP_mW[]) || return send_setpoint(light, code) # ramp off: one write start = Int(LD_GetLaserSetPoint(serialNo)) step = max(1, round(Int, RAMP_STEP_mW[] / 1000 / pd.wa_calibration / pd.tia_range * light.max_setpoint)) if 0 <= start && Int(code) - start > step diff --git a/test/runtests.jl b/test/runtests.jl index f939d46..0c64749 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -607,24 +607,37 @@ include("tcube_fake_sdk.jl") @test laser.pd.output_power_requested == 50.0 empty!(FakeKinesis.calls) light_on(laser) - # From 0 to 50 mW the setpoint is RAMPED in ~3 mW steps (the rig's loop - # locks on a big jump): enable, read the held setpoint, ~16 intermediate - # sends, then the final one, confirmed. - @test FakeKinesis.calls[1:2] == ["LD_EnableOutput", "LD_GetLaserSetPoint"] - @test FakeKinesis.calls[end] == "LD_GetLaserSetPoint" - @test all(==("LD_SetLaserSetPoint"), FakeKinesis.calls[3:end-1]) + # The ramp is off by default (jumps were reliable on the rig when last + # tested): enable, read the held setpoint, one write, confirmed. + @test isinf(TCube.RAMP_STEP_mW[]) + @test FakeKinesis.calls == ["LD_EnableOutput", "LD_SetLaserSetPoint", "LD_GetLaserSetPoint"] code = FakeKinesis.setpoints[end] @test code == UInt16(floor(i_pd / 1e-3 * 32767)) @test FakeKinesis.setpoint_held[] == code - step = round(Int, TCube.RAMP_STEP_mW[] / 1000 / 224.2 / 1e-3 * 32767) # 3 mW steps - @test 15 <= length(FakeKinesis.setpoints) <= 18 - @test issorted(FakeKinesis.setpoints) - @test all(d -> 0 < d <= step, diff(Int.(FakeKinesis.setpoints))) + # With the ramp enabled (the safeguard verified on the rig), the same + # 0 -> 50 mW is walked in ~3 mW steps: ~16 intermediate sends, then + # the final one, confirmed. + light_off(laser) + TCube.RAMP_STEP_mW[] = 3.0 + try + empty!(FakeKinesis.calls); empty!(FakeKinesis.setpoints) + light_on(laser) + @test FakeKinesis.calls[1:2] == ["LD_EnableOutput", "LD_GetLaserSetPoint"] + @test FakeKinesis.calls[end] == "LD_GetLaserSetPoint" + @test all(==("LD_SetLaserSetPoint"), FakeKinesis.calls[3:end-1]) + @test FakeKinesis.setpoints[end] == code + step = round(Int, 3.0 / 1000 / 224.2 / 1e-3 * 32767) + @test 15 <= length(FakeKinesis.setpoints) <= 18 + @test issorted(FakeKinesis.setpoints) + @test all(d -> 0 < d <= step, diff(Int.(FakeKinesis.setpoints))) + finally + TCube.RAMP_STEP_mW[] = Inf + end # With the output on, a new power is sent and confirmed at once (after # checking the photodiode is not reading 0x8000, over range). empty!(FakeKinesis.calls) setoutputpower!(laser, 50.0) - @test FakeKinesis.calls == ["LD_GetStatusBits", "LD_GetPhotoCurrentReading", "LD_GetLaserSetPoint", "LD_SetLaserSetPoint", "LD_GetLaserSetPoint"] + @test FakeKinesis.calls == ["LD_GetStatusBits", "LD_GetPhotoCurrentReading", "LD_SetLaserSetPoint", "LD_GetLaserSetPoint"] # Downward steps are sent directly, no ramp. empty!(FakeKinesis.calls); empty!(FakeKinesis.setpoints) setoutputpower!(laser, 10.0) From 7c93add923d2f968fda66218d0d4dbf928175367 Mon Sep 17 00:00:00 2001 From: Ali Kazemi Date: Tue, 29 Sep 2026 01:45:27 -0600 Subject: [PATCH 07/37] 642 nm rig ceiling: HL6366DG rating, path transmission 0.93, 70 mW / 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 --- .../tcube_laser/CALIBRATION.md | 46 ++++++++++++++++++- .../tcube_laser/types.jl | 2 +- 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/src/hardware_implementations/tcube_laser/CALIBRATION.md b/src/hardware_implementations/tcube_laser/CALIBRATION.md index db84e2b..c6a4852 100644 --- a/src/hardware_implementations/tcube_laser/CALIBRATION.md +++ b/src/hardware_implementations/tcube_laser/CALIBRATION.md @@ -63,7 +63,7 @@ laser = TCubeLaser("64849775"; tia_range = 1e-3, # A, the rear-panel DIP switch tec_stabilised = missing, # true / false once known; `missing` is honest until then threshold_current = 65.0, # mA - max_current = 160.0, # mA: programmed into the controller as the loop's clamp + max_current = 150.0, # mA: programmed into the controller as the loop's clamp (see "The 642 nm rig's ceiling") properties = LightSourceProperties("mW", 0.0, false, 1.0, 70.0)) # [1 mW, 70 mW] initialize(laser) # checks key, interlock and the DIP switch; programs the clamp; never emits @@ -144,7 +144,7 @@ photocurrent exactly; the meter reads a little under 224.2 W/A's prediction). Closed loop is therefore verified over the rig's range, and `properties.max_power = 70.0` is supported. Note that 70 mW needs ~141 mA, above the 132 mA the rig had used as its working ceiling; the clamp at -`max_current` (160 mA) is the hard limit. +`max_current` is the hard limit (150 mA on the rig, see "The 642 nm rig's ceiling" below). **The lock is intermittent, and its cause is not known.** The jump from 0 to 10 mW failed 3 times out of 3 on 09-28/29 (before and after the gain @@ -226,6 +226,48 @@ gain was re-optimised and persisted. It did not change the light (110 mA gave 39.43 mW before and after); the ~98 µA "ceiling" seen that day turned out to be the setpoint-jump lock described above, not the photodiode channel. +## The 642 nm rig's ceiling: the diode's rating and the measurement plane + +The diode is an **Ushio HL6366DG** (642 nm): absolute maximum optical output +90 mW, rated 80 mW CW, threshold 80 mA typical / 95 max, operating current +155 mA typical at 80 mW, monitor current 0.1-0.3 mA at 80 mW (data sheet +HL6366DG/67DG Rev 0, 2014-10-28). This diode: threshold ~68 mA, 88 mW at the +160 mA limit at the usual meter position. + +All powers in this document and in the driver are **at the usual meter +position, before the fibre coupler but after a filter, two mirrors and two +dichroics**. Measured 2026-09-29 at 90 mA: 22.23 mW at the laser head (before +the filter) vs 20.67 mW at the usual position, so the path passes +**T = 0.93**, and power at the diode = reading / 0.93: + +| usual position | at the diode | +|---:|---:| +| 60 mW | 64.5 mW | +| 68.5 mW (the 70 mW run) | 73.7 mW | +| 74.4 mW | 80 mW, the rating | +| 83.7 mW | 90 mW, the absolute maximum | + +For the record: on 2026-09-29 the diode also ran for about 13 s at the 160 mA +limit (a test script that sent its setpoint before enabling the output, which +the controller ignores), roughly 93 mW at the diode, just over the absolute +maximum; and for about 100 s in total at 141 mA (74 mW at the diode) during +the 70 mW tests. + +Measured directly at the head in closed loop the same day: 70 mW requested -> +73.5 mW at the diode (141.6 mA), 72 mW requested -> 75.9 mW at the diode +(143.9 mA), consistent with T = 0.93. + +Rig settings chosen from this (the construction example above uses them): +`properties.max_power = 70.0` mW at the usual position, which is 73.5 mW +measured at the head and about 75 mW at the facet behind the collimating lens +(an AR-coated lens loses ~1-2 %; this one is not measurable), about 94 % of +the rating; and `max_current = 150.0` mA. The clamp is the loop's protection, +not its operating point: 70 mW needs ~142 mA, the extra ~8 mA is headroom for +the loop as the diode warms, and it stays under the 155 mA typical operating +current. A runaway with a blocked photodiode stops at the clamp, ~92 mW at the +diode, instead of the ~96 mW the previous 160 mA clamp allowed. Redo the head +measurement if anything in the path before the usual meter position changes. + ## When to recalibrate The numbers belong to **this photodiode, this TIA range and this measurement diff --git a/src/hardware_implementations/tcube_laser/types.jl b/src/hardware_implementations/tcube_laser/types.jl index bf560b9..183450d 100644 --- a/src/hardware_implementations/tcube_laser/types.jl +++ b/src/hardware_implementations/tcube_laser/types.jl @@ -146,7 +146,7 @@ laser = TCubeLaser("64849775"; tia_range = 1e-3, # A: the rear-panel DIP switch, as set tec_stabilised = missing, # honest until checked threshold_current = 65.0, # mA, from a bench sweep - max_current = 160.0, # mA: programmed as the loop's clamp + max_current = 150.0, # mA: programmed as the loop's clamp properties = LightSourceProperties("mW", 0.0, false, 2.0, 80.0)) # The same diode in open loop. From 10f1abaeac4030ff3270e796da0356d544bea3d5 Mon Sep 17 00:00:00 2001 From: Ali Kazemi Date: Tue, 29 Sep 2026 01:58:06 -0600 Subject: [PATCH 08/37] CLAUDE.md: DiodeLaser, the two regulation modes, SimDiodeLaser and the fake SDK Co-Authored-By: Claude Fable 5.1 --- CLAUDE.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ed538b9..21ff3b0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,7 +49,7 @@ What CI actually runs, deliberately thin (`.github/workflows/CI.yml`): Batch fixups into one push rather than pushing each review round separately; every push to an open pull request starts a fresh run. -Tests use simulated devices only (`SimCamera`, `SimStage3d`/`SimStage2d`/`SimStage1d`, `SimLight`) - no hardware required. GLMakie needs a display: run under `xvfb-run -a` on a headless Linux box (CI does this). Test sets: "Simulated Camera", "Simulated Stage", "Simulated Light Source", "Export State". +Tests use simulated devices only (`SimCamera`, `SimStage3d`/`SimStage2d`/`SimStage1d`, `SimLight`, `SimDiodeLaser`) plus a fake Kinesis SDK (`test/tcube_fake_sdk.jl`) that the real `TCubeLaser` driver runs against - no hardware required. GLMakie needs a display: run under `xvfb-run -a` on a headless Linux box (CI does this). Test sets: "Simulated Camera", "Simulated Stage", "Simulated Light Source", "Export State". ## Architecture @@ -64,7 +64,8 @@ MicroscopeControl.jl uses a **three-layer architecture** leveraging Julia's mult │ Abstract types + contracts │ Concrete device drivers │ │ - CameraInterface │ - SimulatedCamera, DCAM4 │ │ - StageInterface │ - SimulatedStage, PI, MCL │ -│ - LightSourceInterface │ - SimulatedLight, TCube │ +│ - LightSourceInterface │ - SimulatedLight, TCube, │ +│ (+ DiodeLaser) │ SimDiodeLaser │ │ - DAQInterface │ - NIDAQcard │ │ - SLMInterface │ - OK_XEM (FPGA) │ │ - AttenuatorInterface │ - LCC1620 │ @@ -101,6 +102,8 @@ Interfaces define method signatures with throwing `error(" not implemented **LightSource**: `setpower`, `light_on`, `light_off` +**DiodeLaser** (`<: LightSource`; `TCubeLaser{M}`, `SimDiodeLaser{M}` with `M` = `ConstantCurrent` or `ConstantPhotocurrent`, fixed at construction, `mode=` required): `setcurrent!` (mA, `ConstantCurrent` only), `setoutputpower!` (mW at the laser output, `ConstantPhotocurrent` only), `setlevel!` (0..1 of the declared range, both), `measured_current`, `measured_photocurrent`, `indicated_output_power`, `loop_status`, `regulation_mode`, `supported_modes`. `setpower` throws on a `DiodeLaser`. Mode-shared methods are written against the bare `TCubeLaser`, mode-specific ones against `TCubeLaser{ConstantCurrent}` / `{ConstantPhotocurrent}`; never a `where M` method on a generic `test/contract.jl` checks. `subtypes` is one level deep, so the contract test and API map walk to the leaves (`device_types`). Closed-loop calibration and the 642 nm rig's measured facts: `src/hardware_implementations/tcube_laser/CALIBRATION.md`. + **DAQ**: `showdevices`, `showchannels`, `createtask`, `setvoltage`, `readvoltage`, `deletetask` **Attenuator**: `setdrivevoltage`, `getdrivevoltage`, `settransmission`, `gettransmission`, `set_calibration!` From 1c240d5972e9442f7b4d763d6c33149b86344378 Mon Sep 17 00:00:00 2001 From: Ali Kazemi Date: Tue, 29 Sep 2026 02:16:47 -0600 Subject: [PATCH 09/37] PI N-472: NUL-terminated connect string, checked connect, retryable object, working stop initialize handed PI_ConnectUSB a description with every 0x00 stripped and no terminator, so the DLL read past the vector until it met a zero by chance; it also set connectionstatus before the connect was checked, so a -1 return was logged as "Stage initialized" and a retry was refused. shutdown never cleared the flag. stopmotion passed a Vector{String} where the DLL wants one string. Verified on the rig (C-885 SN 124014300): init, stop (returns 1), shutdown, re-init on the same object, and the held-controller refusal. No motion. Adds a "PI N472 (no hardware)" testset that skips its DLL path whenever a controller enumerates, so the suite never commands attached hardware. Bumps to 0.2.4; documents the ccall-boundary rules in CLAUDE.md and the skills. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 29 ++++++++++ CLAUDE.md | 15 +++++ Project.toml | 4 +- README.md | 4 +- skills/mc-extend/references/rig-causes.md | 1 + skills/mc-system-design/SKILL.md | 2 +- .../references/driver-caveats.md | 9 +++ .../pi_n472/interface_methods.jl | 58 ++++++++++++++----- test/runtests.jl | 48 +++++++++++++++ 9 files changed, 150 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d4a789..a7edcf2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,35 @@ and `y` is the non-breaking one (every merge to `main` is tagged). ## [Unreleased] +### Fixed + +PI N-472 actuator driver (`PI_N472`), initialize/shutdown/stop. Same family +as the v0.1.1 C-867 fix: memory-layout accidents at the GCS2 boundary that +work most of the time. **Hardware verification: NOT DONE** on the actuator +itself; the no-controller path is exercised by the new "PI N472 (no +hardware)" testset against the installed DLL, and the rest is traced from the +source. + +- **`initialize` handed `PI_ConnectUSB` a description with no NUL + terminator.** The enumeration buffer was stripped of every `0x00` byte and + passed as a bare `Vector{UInt8}`, so the DLL read past its end until it met + a zero by chance. That explains an initialize that "usually works". It now + passes the first enumerated line as a Julia `String`, which is always + NUL-terminated at the `Ptr{Cchar}` boundary. The buffer grew from 128 to + 1024 bytes to match the C-867 driver. +- **A failed connect was silent and poisoned the object.** `connectionstatus` + was set before `PI_ConnectUSB` was called and its `-1` return never checked, + so every later GCS call failed quietly, "Stage initialized" was still logged, + and a retry was refused as "already initialized". The flag is now set only + after a successful connect; a failure logs the description and + `PI_GetInitError()` and leaves the object retryable. +- **`shutdown` never cleared `connectionstatus`**, so re-initializing the same + object in one session was always refused. It now clears the flag. +- **`stopmotion` could not reach the controller.** It passed `stage.axes`, a + `Vector{String}`, where the DLL wants one space-separated `Ptr{Cchar}` + string; the pointer handed over pointed at string references, not + characters. It now joins the axes like every other call in the driver. + ### Fixed (documentation) - **Depending on this package needs more than pinning the tag, and the docs did not say so.** MicroscopeControl depends on the unregistered `DAQmx.jl` and diff --git a/CLAUDE.md b/CLAUDE.md index ed538b9..1b36d1e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -119,6 +119,21 @@ Hardware implementations use `ccall` for vendor SDKs: - `mcl_stage/*.jl` - Mad City Labs NanoDrive - Serial devices (CrystaLaser, Vortran, Triggerscope) use `LibSerialPort` +Rules at the `ccall` boundary, each learned from a bug that "usually worked" +(C-867 servo, v0.1.1; N-472 initialize, v0.2.4): +- A `Ptr{Cchar}` argument (GCS2 axes lists, USB descriptions) gets a Julia + `String`, which is always NUL-terminated. Never a `Vector{UInt8}` with the + zeros filtered out, and never a `Vector{String}`; join axes with a space first. +- A `BOOL*` argument is 32-bit (`Cuint`/`Cint`), one element per axis, never `UInt8`. +- Set a driver's `connectionstatus` only after the connect call's return value + is checked, and clear it in `shutdown`, so a failed or closed object can be + initialized again. + +The test suite must never command attached hardware. Driver tests that touch a +vendor DLL run only its failure paths, and skip when the device enumerates +(see the "PI N472 (no hardware)" testset: on the rig, the C-885 is plugged in +and `initialize` would turn its servos on). + ### Camera Image Data Convention **Convention:** Image data is stored and displayed as column-major `(H, W, N)` arrays where `data[row, col]` = `data[y, x]`. diff --git a/Project.toml b/Project.toml index 87019fa..1f56ba2 100644 --- a/Project.toml +++ b/Project.toml @@ -1,6 +1,6 @@ name = "MicroscopeControl" uuid = "aa70d9ae-4a1e-49fd-870a-8ccfd99f4c3e" -version = "0.2.3" +version = "0.2.4" authors = ["klidke@unm.edu"] [deps] @@ -23,7 +23,7 @@ Statistics = "10745b16-79ce-11e8-11f9-7d13ad32a3b2" TOML = "fa267f1f-6049-4f14-aa54-33bafae1ed76" [sources] -DAQmx = {url = "https://github.com/LidkeLab/DAQmx.jl.git"} +DAQmx = {rev = "main", url = "https://github.com/LidkeLab/DAQmx.jl.git"} [compat] CEnum = "0.5.0" diff --git a/README.md b/README.md index a8376d5..f1c63fe 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ Since this package is under active development and not yet registered, install i ```julia using Pkg -Pkg.add(url="https://github.com/LidkeLab/MicroscopeControl.jl.git", rev="v0.2.3") +Pkg.add(url="https://github.com/LidkeLab/MicroscopeControl.jl.git", rev="v0.2.4") ``` **You must also declare this package's unregistered dependency in your own @@ -88,7 +88,7 @@ writes both entries for you: ```julia using Pkg Pkg.add(url="https://github.com/LidkeLab/DAQmx.jl.git") -Pkg.add(url="https://github.com/LidkeLab/MicroscopeControl.jl.git", rev="v0.2.3") +Pkg.add(url="https://github.com/LidkeLab/MicroscopeControl.jl.git", rev="v0.2.4") ``` If you write the TOML by hand, `[sources]` alone is **not** enough — Julia diff --git a/skills/mc-extend/references/rig-causes.md b/skills/mc-extend/references/rig-causes.md index aeb0552..5e2c176 100644 --- a/skills/mc-extend/references/rig-causes.md +++ b/skills/mc-extend/references/rig-causes.md @@ -10,6 +10,7 @@ and each looks like a broken `ccall`. | Observed symptom | Possible causes | How to tell | |---|---|---| | `initialize(stage::PIStage)` logs `@error` ("No PI C-867 found ..." or "PI_ConnectUSB failed ...") and returns; `stage.connectionstatus` stays `false`. It does **not** throw. | Controller absent or unpowered; USB enumeration; **or** held by another process (a second Julia session with an initialized stage, PIMikroMove, an open COM port). None of these is established by the error alone. | Check `stage.connectionstatus` after every `initialize`. Close PIMikroMove and every other Julia; check Task Manager for stray `julia.exe`. If it then connects, it was contention, not the driver. | +| `initialize(stage::N472)` logs `@error` ("No PI C-885 found ..." or "PI_ConnectUSB failed ...") and returns; `stage.connectionstatus` stays `false`. It does **not** throw. **[fixed in v0.2.4]** Before that release the flag was set *before* the connect was checked, so a failed connect logged "Stage initialized", left every later GCS call failing silently, and a retry was refused as "already initialized"; and the description handed to `PI_ConnectUSB` had no NUL terminator, so a connect could fail by chance from one run to the next. | Same causes as the C-867 row: controller absent or unpowered, or held by another process. On a pre-0.2.4 copy, add: the driver itself. | As for the C-867 row. On a pre-0.2.4 copy, an `initialize` that "usually works" is the driver, not the rig; rebuild the `N472()` object before retrying, since `shutdown` did not clear the flag either. | | `MLSLM` SDK calls do nothing useful | The board is claimed by another process (vendor GUI or a dead Julia), or the SDK never found it. | `MLSLM()` is pure: its `n_boards_found` field is a **default of 0** and is never updated, so it diagnoses nothing. The only board count is what `initializesdk()` prints to stdout from `Create_SDK`. Read that output; if it reports 0 boards with the board powered, close the vendor GUI and every other Julia, then reboot if a dead process still holds it. | | Serial device times out, returns garbage, or "port busy" | Wrong COM assignment (Windows renumbers COM ports when USB topology changes), or the port is open elsewhere. Applies only to the **Triggerscope** (`Triggerscope4`, default `portname="COM3"`) and, through it, `LCC1620`. | Device Manager: match the port to the device. From Julia, `MicroscopeControl.HardwareImplementations.Triggerscope.LibSerialPort.list_ports()`. One `Triggerscope4` per physical port, shared by its dependents (`mc-system-design`). | | A DAQ-backed light (`CrystaLaser`, `VortranLaser`, `DaqTrLight`) `@warn`s at construction ("No NI-DAQ devices found ..." or "Failed to initialize NI-DAQ ..."), then `initialize` returns normally (Vortran's may `@warn` "insufficient DO channels for initialization") and `setpower`/`light_on` `@warn` about missing channels and do nothing, or only some calls work | These devices are **not** serial. They drive NI-DAQ AO/DO channels through an `NIdaq` built in their constructor, which picks `devs[1]` (Crysta, Vortran) or `devs[device_index]` (DaqTrLight, default 2). Discovery runs AO then DO inside one `try`, so it can **partially** succeed: AO channels kept, DO empty. Causes: wrong device index, missing NI-DAQmx runtime, card not enumerated, or a DO-less card. | Inspect `light.channelsAO` and `light.channelsDO` right after construction; `NIDAQcard.showdevices(NIdaq())` from Julia; compare with NI MAX. Fix the index or the runtime, not the driver. | diff --git a/skills/mc-system-design/SKILL.md b/skills/mc-system-design/SKILL.md index 8eb558d..2e0112b 100644 --- a/skills/mc-system-design/SKILL.md +++ b/skills/mc-system-design/SKILL.md @@ -34,7 +34,7 @@ MicroscopeControl and Pkg writes both entries for you: ```julia Pkg.add(url="https://github.com/LidkeLab/DAQmx.jl.git") -Pkg.add(url="https://github.com/LidkeLab/MicroscopeControl.jl.git", rev="v0.2.3") +Pkg.add(url="https://github.com/LidkeLab/MicroscopeControl.jl.git", rev="v0.2.4") ``` Writing the TOML by hand needs **both** a `[deps]` and a `[sources]` entry — diff --git a/skills/mc-system-design/references/driver-caveats.md b/skills/mc-system-design/references/driver-caveats.md index 1560c3a..89a4d4e 100644 --- a/skills/mc-system-design/references/driver-caveats.md +++ b/skills/mc-system-design/references/driver-caveats.md @@ -93,6 +93,15 @@ Resolving to a device-specific method is not the same as the operation working: - `getposition(::N472)` returns the SDK success code and stores positions in `stage.pos`; `move(::N472, pos::Vector{Float64})` takes a vector, not `x, y, z` (traced). +- `stopmotion(::N472)` **[fixed in v0.2.4]**: before that release it passed + `stage.axes` (a `Vector{String}`) where the DLL wants one space-separated + string, so the halt never reached the controller (traced). +- `initialize(::N472)` / `shutdown(::N472)` **[fixed in v0.2.4]**: `initialize` + now sets `connectionstatus` only after `PI_ConnectUSB` succeeds and leaves + the object retryable on failure; `shutdown` clears the flag so the same + object can be initialized again. Before 0.2.4 a failed connect was reported + as "Stage initialized" and both a retry and a re-initialize after `shutdown` + were refused (verified on the rig: init, stop, shutdown, init on one object). - `capture` returns the `SINGLE_FRAME` enum on `SimCamera` and the frame on the hardware cameras, and `getdata` after `capture` is valid **only** on `SimCamera` (DCAM4 and DCX release their buffers before `capture` returns). diff --git a/src/hardware_implementations/pi_n472/interface_methods.jl b/src/hardware_implementations/pi_n472/interface_methods.jl index 7ea9d25..8c33c48 100644 --- a/src/hardware_implementations/pi_n472/interface_methods.jl +++ b/src/hardware_implementations/pi_n472/interface_methods.jl @@ -1,3 +1,12 @@ +""" + _cstring(buf) -> String + +The bytes of `buf` up to its first NUL, as a `String`. +""" +function _cstring(buf::Vector{UInt8}) + i = findfirst(==(0x00), buf) + return String(buf[1:(i === nothing ? end : i - 1)]) +end function initialize(stage::N472) if stage.connectionstatus == true @@ -5,28 +14,40 @@ function initialize(stage::N472) return end - # Create a buffer string - buffersize = 128 - devstring = zeros(UInt8, buffersize) + # Enumerate the C-885 controllers the GCS2 DLL can see. The DLL lists only + # controllers nobody has open: one that Device Manager still shows but is + # missing here is held by another process (a second Julia, PIMikroMove, an + # open COM port). + buffersize = 1024 + buffer = zeros(UInt8, buffersize) controllername = "C-885" - numdevice = PI_EnumerateUSB(devstring, buffersize, controllername) - devstring = filter(x -> x != 0x00, devstring) - - - - #Set connection status to true - if numdevice > 0 - stage.connectionstatus = true - else - @error "No devices connected" + numdevice = PI_EnumerateUSB(buffer, buffersize, controllername) + if numdevice <= 0 + @error "No PI C-885 found by the GCS2 library — controller absent, or held by another process" stage.connectionstatus = false return end + # The buffer holds one NUL-terminated description per line. Pass the first + # line as a `String`: Julia strings are always NUL-terminated for + # `Ptr{Cchar}`, whereas the old code stripped every 0x00 byte and handed + # the DLL a bare `Vector{UInt8}`, terminated only by whatever happened to + # follow it in memory (usually a zero, so it usually worked). + devstring = String(first(split(_cstring(buffer), '\n'))) + @info "PI device: " * devstring + #Connect to usb device stage.id = PI_ConnectUSB(devstring) - @info "PI device: " * String(devstring) @info "Device ID: " * string(stage.id) + if stage.id < 0 + # The connect itself failed (id -1): typically another process already + # holds the controller. Leave the flag cleared so `initialize` can be + # retried on the same object. + stage.connectionstatus = false + @error "PI_ConnectUSB failed for \"$devstring\" (init error $(PI_GetInitError())) — the controller is probably held by another process" + return + end + stage.connectionstatus = true #Query the unit of the physical position axes = join(stage.axes, " ") @@ -67,6 +88,9 @@ function shutdown(stage::N472) else @info "Stage not connected" end + # Clear the flag so the same object can be initialized again; before this + # a second `initialize` after `shutdown` was refused as "already initialized". + stage.connectionstatus = false return end @@ -95,7 +119,11 @@ function StageInterface.home(stage::N472) end function StageInterface.stopmotion(stage::N472) - success = PI_HLT(stage.id, stage.axes) + # Every GCS2 axes argument is one space-separated string. `stage.axes` is a + # Vector{String}; passing it as Ptr{Cchar} handed the DLL a pointer to + # string references, not characters, so the halt never reached the axes. + axes = join(stage.axes, " ") + success = PI_HLT(stage.id, axes) return success end diff --git a/test/runtests.jl b/test/runtests.jl index 5faf0e5..633461a 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -702,6 +702,54 @@ include("tcube_fake_sdk.jl") end end + @testset "PI N472 (no hardware)" begin + N = MicroscopeControl.HardwareImplementations.PI_N472 + + @testset "_cstring stops at the first NUL" begin + buf = zeros(UInt8, 32) + buf[1:14] .= codeunits("PI C-885 SN 42") + buf[20] = UInt8('x') # stale bytes past the terminator are ignored + @test N._cstring(buf) == "PI C-885 SN 42" + @test N._cstring(codeunits("abc") |> collect) == "abc" + @test N._cstring(UInt8[0x00]) == "" + end + + @testset "shutdown clears connectionstatus" begin + stage = N472() + stage.connectionstatus = true + stage.id = Cint(-1) # never connected; PI_IsConnected(-1) is FALSE + if isfile(N.PI_GCS2) + @test_logs (:info, "Stage not connected") shutdown(stage) + @test stage.connectionstatus == false + else + @test_skip "PI GCS2 DLL not installed" + end + end + + @testset "initialize without a controller leaves the object retryable" begin + # Never command a real controller from the suite: on a rig with a + # C-885 attached, `initialize` connects, zeroes the origin and turns + # the servos on. Run this branch only when enumeration finds nothing. + present = isfile(N.PI_GCS2) && + N.PI_EnumerateUSB(zeros(UInt8, 1024), 1024, "C-885") > 0 + if present + @test_skip "PI C-885 attached; not commanding real hardware from the suite" + elseif isfile(N.PI_GCS2) + stage = N472() + # No C-885 is plugged into a build box: enumeration finds nothing, + # initialize must say so and leave the flag cleared (before this + # the flag was set before the connect was checked). + @test_logs (:error, r"No PI C-885 found") initialize(stage) + @test stage.connectionstatus == false + @test stage.id == 0 + # and a second call is not refused as "already initialized" + @test_logs (:error, r"No PI C-885 found") initialize(stage) + else + @test_skip "PI GCS2 DLL not installed" + end + end + end + include("contract.jl") include("skills.jl") include("gui.jl") From 7bc9436096efde0d604a48ebe3e043ed0ed32880 Mon Sep 17 00:00:00 2001 From: kalidke Date: Tue, 29 Sep 2026 09:58:49 -0600 Subject: [PATCH 10/37] Move PR #67 to 0.3rc1 and correct its release notes Per the ruling that this work collects on 0.3rc1: Project.toml, the README and mc-system-design's install pins return to the 0.3rc1 values (no 0.2.4 bump), and DAQmx's [sources] loses the unrelated rev = "main". Markers read v0.3.0. CHANGELOG: one hardware statement (not verified in this repository; the author's no-motion exercise on a C-885 described as such); the connect string is described as relying on filter's implementation rather than missing a terminator, and not as the cause of the intermittent initialize; the id/shutdown fix and the behaviour changes a pinned rig will see (throw on setup failure, re-zeroing re-initialize, first enumerated controller, default id, setvel's return) are listed individually. CLAUDE.md: provenance without a version, BOOL* rule scoped to GCS2, shutdown clears the id too, driver tests use recorded fakes. rig-causes merges the C-867 and C-885 rows; driver-caveats drops the rig claim. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 64 ++++++++++++++----- CLAUDE.md | 17 ++--- Project.toml | 4 +- README.md | 4 +- skills/mc-extend/references/rig-causes.md | 3 +- skills/mc-system-design/SKILL.md | 2 +- .../references/driver-caveats.md | 15 +++-- 7 files changed, 72 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a7edcf2..f525133 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,32 +11,66 @@ and `y` is the non-breaking one (every merge to `main` is tagged). ### Fixed -PI N-472 actuator driver (`PI_N472`), initialize/shutdown/stop. Same family -as the v0.1.1 C-867 fix: memory-layout accidents at the GCS2 boundary that -work most of the time. **Hardware verification: NOT DONE** on the actuator -itself; the no-controller path is exercised by the new "PI N472 (no -hardware)" testset against the installed DLL, and the rest is traced from the -source. - -- **`initialize` handed `PI_ConnectUSB` a description with no NUL - terminator.** The enumeration buffer was stripped of every `0x00` byte and - passed as a bare `Vector{UInt8}`, so the DLL read past its end until it met - a zero by chance. That explains an initialize that "usually works". It now - passes the first enumerated line as a Julia `String`, which is always - NUL-terminated at the `Ptr{Cchar}` boundary. The buffer grew from 128 to - 1024 bytes to match the C-867 driver. +PI N-472 actuator driver (`PI_N472`): initialize, shutdown and stop. The +lifecycle is covered by fake-GCS2 tests that run on every machine +(`test/pi_n472_fake_sdk.jl`). Hardware: not verified in this repository. The +author reports exercising initialize, `stopmotion` with no motion in +progress, shutdown, re-initialize and a refused second object on a C-885 +(SN 124014300), with no motion commanded; stop during motion and the +connect-failure branch were not exercised on hardware. + - **A failed connect was silent and poisoned the object.** `connectionstatus` was set before `PI_ConnectUSB` was called and its `-1` return never checked, so every later GCS call failed quietly, "Stage initialized" was still logged, and a retry was refused as "already initialized". The flag is now set only after a successful connect; a failure logs the description and - `PI_GetInitError()` and leaves the object retryable. + `PI_GetInitError()` and leaves the object retryable. The intermittent + initialize seen on the rig is more likely another process holding the + controller, made sticky by this bug, than anything in the connect string; + that is not established, so do not treat it as fixed until the rig says so. - **`shutdown` never cleared `connectionstatus`**, so re-initializing the same object in one session was always refused. It now clears the flag. +- **`shutdown` could close another object's connection.** `id` defaulted to + `0`, a valid GCS ID, and `shutdown` never reset it, so a second `shutdown` on + a closed object, or a `shutdown` on one never initialized, closed whichever + controller then held ID 0. `id` now defaults to `-1` and `shutdown` resets it. - **`stopmotion` could not reach the controller.** It passed `stage.axes`, a `Vector{String}`, where the DLL wants one space-separated `Ptr{Cchar}` string; the pointer handed over pointed at string references, not characters. It now joins the axes like every other call in the driver. +- **The connect string relied on an implementation detail.** `initialize` + filtered every `0x00` out of the enumeration buffer and passed the bare + `Vector{UInt8}`. On current Julia that happens to leave a zero just past the + shrunk vector, so the DLL did see a terminated string, but by accident of + `filter`'s implementation, and carrying the enumeration's trailing newline + (and every further description when several controllers are attached). It + now passes the first description, whitespace-stripped, as a `String`, which + Julia always NUL-terminates at a `Ptr{Cchar}` boundary. The buffer grew from + 128 to 1024 bytes to match the C-867 driver. + +### Changed + +Behaviour a rig pinned to an earlier tag will see from the PI N-472 driver, +each on its own: + +- **`initialize(::N472)` now throws when a setup step fails.** After the + connect, every step (reference mode, `PI_POS`, servos, travel range, + velocity) is checked; a failure closes the connection, clears + `connectionstatus` and `id`, and throws with the step and its GCS error + code. Before, failures were ignored and "Stage initialized" was logged on a + half-initialized stage. Enumeration and connect failures still log `@error` + and return, as `initialize(::PIStage)` does. **Breaking** under this + package's versioning rule: what the call throws changed. +- **Re-initializing after `shutdown` now re-zeroes the frame.** A second + `initialize` on the same object was refused and did nothing; it now runs the + full sequence: reference mode off, `PI_POS` redefining the current position + as `homepos`, servos on. A script that used shutdown-then-initialize as a + reconnect now resets its coordinates to wherever the actuator sits. +- **With several C-885s attached, `initialize` connects to the first + enumerated one.** There is no selection by serial number. +- **`N472()` defaults `id` to `-1`**, not `0`. +- **`setvel(::N472)` returns `FALSE` when `PI_VEL` fails.** It used to return + only the status of the `PI_qVEL` read-back. ### Fixed (documentation) - **Depending on this package needs more than pinning the tag, and the docs did diff --git a/CLAUDE.md b/CLAUDE.md index 40e4e28..45d9dc6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -120,19 +120,20 @@ Hardware implementations use `ccall` for vendor SDKs: - Serial devices (CrystaLaser, Vortran, Triggerscope) use `LibSerialPort` Rules at the `ccall` boundary, each learned from a bug that "usually worked" -(C-867 servo, v0.1.1; N-472 initialize, v0.2.4): +(C-867 servo, v0.1.1; N-472 stopmotion): - A `Ptr{Cchar}` argument (GCS2 axes lists, USB descriptions) gets a Julia `String`, which is always NUL-terminated. Never a `Vector{UInt8}` with the zeros filtered out, and never a `Vector{String}`; join axes with a space first. -- A `BOOL*` argument is 32-bit (`Cuint`/`Cint`), one element per axis, never `UInt8`. +- A GCS2 `BOOL*` argument is 32-bit (`Cuint`/`Cint`), one element per axis, + never `UInt8`. - Set a driver's `connectionstatus` only after the connect call's return value - is checked, and clear it in `shutdown`, so a failed or closed object can be - initialized again. + is checked, and clear it and the device id in `shutdown`, so a failed or + closed object can be initialized again and a stale id cannot close another + object's connection. -The test suite must never command attached hardware. Driver tests that touch a -vendor DLL run only its failure paths, and skip when the device enumerates -(see the "PI N472 (no hardware)" testset: on the rig, the C-885 is plugged in -and `initialize` would turn its servos on). +The test suite must never command attached hardware. Driver tests replace the +vendor wrappers with a recorder (`test/tcube_fake_sdk.jl`, +`test/pi_n472_fake_sdk.jl`), so they run on every machine and never reach a DLL. ### Camera Image Data Convention diff --git a/Project.toml b/Project.toml index 1f56ba2..87019fa 100644 --- a/Project.toml +++ b/Project.toml @@ -1,6 +1,6 @@ name = "MicroscopeControl" uuid = "aa70d9ae-4a1e-49fd-870a-8ccfd99f4c3e" -version = "0.2.4" +version = "0.2.3" authors = ["klidke@unm.edu"] [deps] @@ -23,7 +23,7 @@ Statistics = "10745b16-79ce-11e8-11f9-7d13ad32a3b2" TOML = "fa267f1f-6049-4f14-aa54-33bafae1ed76" [sources] -DAQmx = {rev = "main", url = "https://github.com/LidkeLab/DAQmx.jl.git"} +DAQmx = {url = "https://github.com/LidkeLab/DAQmx.jl.git"} [compat] CEnum = "0.5.0" diff --git a/README.md b/README.md index f1c63fe..a8376d5 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ Since this package is under active development and not yet registered, install i ```julia using Pkg -Pkg.add(url="https://github.com/LidkeLab/MicroscopeControl.jl.git", rev="v0.2.4") +Pkg.add(url="https://github.com/LidkeLab/MicroscopeControl.jl.git", rev="v0.2.3") ``` **You must also declare this package's unregistered dependency in your own @@ -88,7 +88,7 @@ writes both entries for you: ```julia using Pkg Pkg.add(url="https://github.com/LidkeLab/DAQmx.jl.git") -Pkg.add(url="https://github.com/LidkeLab/MicroscopeControl.jl.git", rev="v0.2.4") +Pkg.add(url="https://github.com/LidkeLab/MicroscopeControl.jl.git", rev="v0.2.3") ``` If you write the TOML by hand, `[sources]` alone is **not** enough — Julia diff --git a/skills/mc-extend/references/rig-causes.md b/skills/mc-extend/references/rig-causes.md index 5e2c176..cc7e6f1 100644 --- a/skills/mc-extend/references/rig-causes.md +++ b/skills/mc-extend/references/rig-causes.md @@ -9,8 +9,7 @@ and each looks like a broken `ccall`. | Observed symptom | Possible causes | How to tell | |---|---|---| -| `initialize(stage::PIStage)` logs `@error` ("No PI C-867 found ..." or "PI_ConnectUSB failed ...") and returns; `stage.connectionstatus` stays `false`. It does **not** throw. | Controller absent or unpowered; USB enumeration; **or** held by another process (a second Julia session with an initialized stage, PIMikroMove, an open COM port). None of these is established by the error alone. | Check `stage.connectionstatus` after every `initialize`. Close PIMikroMove and every other Julia; check Task Manager for stray `julia.exe`. If it then connects, it was contention, not the driver. | -| `initialize(stage::N472)` logs `@error` ("No PI C-885 found ..." or "PI_ConnectUSB failed ...") and returns; `stage.connectionstatus` stays `false`. It does **not** throw. **[fixed in v0.2.4]** Before that release the flag was set *before* the connect was checked, so a failed connect logged "Stage initialized", left every later GCS call failing silently, and a retry was refused as "already initialized"; and the description handed to `PI_ConnectUSB` had no NUL terminator, so a connect could fail by chance from one run to the next. | Same causes as the C-867 row: controller absent or unpowered, or held by another process. On a pre-0.2.4 copy, add: the driver itself. | As for the C-867 row. On a pre-0.2.4 copy, an `initialize` that "usually works" is the driver, not the rig; rebuild the `N472()` object before retrying, since `shutdown` did not clear the flag either. | +| `initialize` on a PI C-867 (`PIStage`) or C-885 (`N472`) logs `@error` ("No PI C-867 found ...", "No PI C-885 found ..." or "PI_ConnectUSB failed ...") and returns; `stage.connectionstatus` stays `false`. It does **not** throw; a failure *after* the connect does (a refused reference move on the C-867, any setup step on the C-885). **[N472 fixed in v0.3.0]** Before that release the N472 set the flag before checking the connect, so a failed connect logged "Stage initialized" and a retry was refused as "already initialized". | Controller absent or unpowered; USB enumeration; **or** held by another process (a second Julia session with an initialized stage, PIMikroMove, an open COM port). None of these is established by the error alone. | Check `stage.connectionstatus` after every `initialize`. Close PIMikroMove and every other Julia; check Task Manager for stray `julia.exe`. If it then connects, it was contention, not the driver. On an N472 before 0.3.0, build a fresh `N472()` before retrying: neither a failed connect nor `shutdown` cleared the flag. | | `MLSLM` SDK calls do nothing useful | The board is claimed by another process (vendor GUI or a dead Julia), or the SDK never found it. | `MLSLM()` is pure: its `n_boards_found` field is a **default of 0** and is never updated, so it diagnoses nothing. The only board count is what `initializesdk()` prints to stdout from `Create_SDK`. Read that output; if it reports 0 boards with the board powered, close the vendor GUI and every other Julia, then reboot if a dead process still holds it. | | Serial device times out, returns garbage, or "port busy" | Wrong COM assignment (Windows renumbers COM ports when USB topology changes), or the port is open elsewhere. Applies only to the **Triggerscope** (`Triggerscope4`, default `portname="COM3"`) and, through it, `LCC1620`. | Device Manager: match the port to the device. From Julia, `MicroscopeControl.HardwareImplementations.Triggerscope.LibSerialPort.list_ports()`. One `Triggerscope4` per physical port, shared by its dependents (`mc-system-design`). | | A DAQ-backed light (`CrystaLaser`, `VortranLaser`, `DaqTrLight`) `@warn`s at construction ("No NI-DAQ devices found ..." or "Failed to initialize NI-DAQ ..."), then `initialize` returns normally (Vortran's may `@warn` "insufficient DO channels for initialization") and `setpower`/`light_on` `@warn` about missing channels and do nothing, or only some calls work | These devices are **not** serial. They drive NI-DAQ AO/DO channels through an `NIdaq` built in their constructor, which picks `devs[1]` (Crysta, Vortran) or `devs[device_index]` (DaqTrLight, default 2). Discovery runs AO then DO inside one `try`, so it can **partially** succeed: AO channels kept, DO empty. Causes: wrong device index, missing NI-DAQmx runtime, card not enumerated, or a DO-less card. | Inspect `light.channelsAO` and `light.channelsDO` right after construction; `NIDAQcard.showdevices(NIdaq())` from Julia; compare with NI MAX. Fix the index or the runtime, not the driver. | diff --git a/skills/mc-system-design/SKILL.md b/skills/mc-system-design/SKILL.md index 2e0112b..8eb558d 100644 --- a/skills/mc-system-design/SKILL.md +++ b/skills/mc-system-design/SKILL.md @@ -34,7 +34,7 @@ MicroscopeControl and Pkg writes both entries for you: ```julia Pkg.add(url="https://github.com/LidkeLab/DAQmx.jl.git") -Pkg.add(url="https://github.com/LidkeLab/MicroscopeControl.jl.git", rev="v0.2.4") +Pkg.add(url="https://github.com/LidkeLab/MicroscopeControl.jl.git", rev="v0.2.3") ``` Writing the TOML by hand needs **both** a `[deps]` and a `[sources]` entry — diff --git a/skills/mc-system-design/references/driver-caveats.md b/skills/mc-system-design/references/driver-caveats.md index 89a4d4e..d741069 100644 --- a/skills/mc-system-design/references/driver-caveats.md +++ b/skills/mc-system-design/references/driver-caveats.md @@ -93,15 +93,16 @@ Resolving to a device-specific method is not the same as the operation working: - `getposition(::N472)` returns the SDK success code and stores positions in `stage.pos`; `move(::N472, pos::Vector{Float64})` takes a vector, not `x, y, z` (traced). -- `stopmotion(::N472)` **[fixed in v0.2.4]**: before that release it passed +- `stopmotion(::N472)` **[fixed in v0.3.0]**: before that release it passed `stage.axes` (a `Vector{String}`) where the DLL wants one space-separated string, so the halt never reached the controller (traced). -- `initialize(::N472)` / `shutdown(::N472)` **[fixed in v0.2.4]**: `initialize` - now sets `connectionstatus` only after `PI_ConnectUSB` succeeds and leaves - the object retryable on failure; `shutdown` clears the flag so the same - object can be initialized again. Before 0.2.4 a failed connect was reported - as "Stage initialized" and both a retry and a re-initialize after `shutdown` - were refused (verified on the rig: init, stop, shutdown, init on one object). +- `initialize(::N472)` / `shutdown(::N472)` **[fixed in v0.3.0]**: `initialize` + sets `connectionstatus` only after `PI_ConnectUSB` succeeds and leaves the + object retryable on failure, and throws (after closing the connection) when + a setup step fails; `shutdown` clears the flag and resets `id` to `-1`. + Before 0.3.0 a failed connect was reported as "Stage initialized", both a + retry and a re-initialize after `shutdown` were refused, and a stale `id` of + `0` could close another object's controller (fake-SDK tests). - `capture` returns the `SINGLE_FRAME` enum on `SimCamera` and the frame on the hardware cameras, and `getdata` after `capture` is valid **only** on `SimCamera` (DCAM4 and DCX release their buffers before `capture` returns). From 7237b2fe902cb7c50537339f0a1de1fa5c50af1e Mon Sep 17 00:00:00 2001 From: kalidke Date: Tue, 29 Sep 2026 10:05:33 -0600 Subject: [PATCH 11/37] Check every N472 initialize step and close on failure; fake-GCS2 tests Co-Authored-By: Claude Sonnet 5.5 --- .../pi_n472/helper.jl | 8 +- .../pi_n472/interface_methods.jl | 77 ++++++------ src/hardware_implementations/pi_n472/types.jl | 2 +- test/pi_n472.jl | 112 ++++++++++++++++++ test/pi_n472_fake_sdk.jl | 109 +++++++++++++++++ test/runtests.jl | 52 +------- 6 files changed, 266 insertions(+), 94 deletions(-) create mode 100644 test/pi_n472.jl create mode 100644 test/pi_n472_fake_sdk.jl diff --git a/src/hardware_implementations/pi_n472/helper.jl b/src/hardware_implementations/pi_n472/helper.jl index 2ee4e7e..1baadea 100644 --- a/src/hardware_implementations/pi_n472/helper.jl +++ b/src/hardware_implementations/pi_n472/helper.jl @@ -71,9 +71,9 @@ function setvel(stage::N472,vel::Vector{Float64}) @error "Failed to set velocity" end - success = PI_qVEL(stage.id, axes, stage.velocity) - if success == FALSE + qsuccess = PI_qVEL(stage.id, axes, stage.velocity) + if qsuccess == FALSE @error "Failed to query velocity" end - return success -end \ No newline at end of file + return (success == FALSE || qsuccess == FALSE) ? FALSE : qsuccess +end diff --git a/src/hardware_implementations/pi_n472/interface_methods.jl b/src/hardware_implementations/pi_n472/interface_methods.jl index 8c33c48..ca33411 100644 --- a/src/hardware_implementations/pi_n472/interface_methods.jl +++ b/src/hardware_implementations/pi_n472/interface_methods.jl @@ -14,67 +14,62 @@ function initialize(stage::N472) return end - # Enumerate the C-885 controllers the GCS2 DLL can see. The DLL lists only - # controllers nobody has open: one that Device Manager still shows but is - # missing here is held by another process (a second Julia, PIMikroMove, an - # open COM port). + # An absent, unpowered or held controller fails here or at the connect below. buffersize = 1024 buffer = zeros(UInt8, buffersize) controllername = "C-885" numdevice = PI_EnumerateUSB(buffer, buffersize, controllername) if numdevice <= 0 - @error "No PI C-885 found by the GCS2 library — controller absent, or held by another process" + @error "No PI C-885 found by the GCS2 library (absent, unpowered, or held by another process)" stage.connectionstatus = false return end - # The buffer holds one NUL-terminated description per line. Pass the first - # line as a `String`: Julia strings are always NUL-terminated for - # `Ptr{Cchar}`, whereas the old code stripped every 0x00 byte and handed - # the DLL a bare `Vector{UInt8}`, terminated only by whatever happened to - # follow it in memory (usually a zero, so it usually worked). - devstring = String(first(split(_cstring(buffer), '\n'))) + # Descriptions are '\n'-separated with one NUL at the end; pass the first as a String (NUL-terminated for Ptr{Cchar}). + devstring = String(strip(first(split(_cstring(buffer), '\n')))) @info "PI device: " * devstring #Connect to usb device stage.id = PI_ConnectUSB(devstring) @info "Device ID: " * string(stage.id) if stage.id < 0 - # The connect itself failed (id -1): typically another process already - # holds the controller. Leave the flag cleared so `initialize` can be - # retried on the same object. + # Connect failed (id -1): leave the flag cleared so initialize can be retried. stage.connectionstatus = false - @error "PI_ConnectUSB failed for \"$devstring\" (init error $(PI_GetInitError())) — the controller is probably held by another process" + @error "PI_ConnectUSB failed for \"$devstring\" (init error $(PI_GetInitError())); the controller may be held by another process" return end stage.connectionstatus = true - #Query the unit of the physical position - axes = join(stage.axes, " ") - #unitstring = zeros(UInt8, buffersize) - #success = PI_qPUN(stage.id, axes, unitstring, buffersize) - #stage.units = String(unitstring) + # Every step from here is checked; on failure close the connection so a retry starts clean. + try + axes = join(stage.axes, " ") + failed(step) = error("N472 initialize: $step failed (GCS error $(PI_GetError(stage.id)))") - #query reference mode - refmode = zeros(BOOL, 3) - - success = PI_RON(stage.id, axes, refmode) - success = PI_qRON(stage.id, axes, refmode) - @info "Reference mode: " * string(refmode) + #query reference mode + refmode = zeros(BOOL, 3) - # set the current position as the reference position - success = set_refpos(stage) + PI_RON(stage.id, axes, refmode) == FALSE && failed("PI_RON") + PI_qRON(stage.id, axes, refmode) == FALSE && failed("PI_qRON") + @info "Reference mode: " * string(refmode) - # turn on servo - for i in eachindex(stage.axes) - servo(stage, i, TRUE) - end - #Query the travel range - success = PI_qTMN(stage.id, axes, stage.minpos) - success = PI_qTMX(stage.id, axes, stage.maxpos) + # set the current position as the reference position + set_refpos(stage) == FALSE && failed("set_refpos (PI_POS)") - #set velocity - success = setvel(stage, stage.velocity) + # turn on servo + for i in eachindex(stage.axes) + servo(stage, i, TRUE) == FALSE && failed("servo axis $i") + end + + #Query the travel range + PI_qTMN(stage.id, axes, stage.minpos) == FALSE && failed("PI_qTMN") + PI_qTMX(stage.id, axes, stage.maxpos) == FALSE && failed("PI_qTMX") + + #set velocity + setvel(stage, stage.velocity) == FALSE && failed("setvel") + catch + shutdown(stage) + rethrow() + end @info "Stage initialized" return @@ -88,9 +83,9 @@ function shutdown(stage::N472) else @info "Stage not connected" end - # Clear the flag so the same object can be initialized again; before this - # a second `initialize` after `shutdown` was refused as "already initialized". + # Clearing both lets the object be re-initialized and stops a stale id closing another object's connection. stage.connectionstatus = false + stage.id = Cint(-1) return end @@ -119,9 +114,7 @@ function StageInterface.home(stage::N472) end function StageInterface.stopmotion(stage::N472) - # Every GCS2 axes argument is one space-separated string. `stage.axes` is a - # Vector{String}; passing it as Ptr{Cchar} handed the DLL a pointer to - # string references, not characters, so the halt never reached the axes. + # GCS2 axes arguments are one space-separated string. axes = join(stage.axes, " ") success = PI_HLT(stage.id, axes) return success diff --git a/src/hardware_implementations/pi_n472/types.jl b/src/hardware_implementations/pi_n472/types.jl index 50976a8..fbee13f 100644 --- a/src/hardware_implementations/pi_n472/types.jl +++ b/src/hardware_implementations/pi_n472/types.jl @@ -27,7 +27,7 @@ function N472(; dimensions::Int=1, axes::Vector{String}=["1", "3", "5"], connectionstatus::Bool=false, - id::Cint=Cint(0), + id::Cint=Cint(-1), pos::Vector{Float64}=[0.0, 0.0, 0.0], minpos::Vector{Float64}=[0.0, 0.0, 0.0], maxpos::Vector{Float64}=[7.0, 7.0, 7.0], diff --git a/test/pi_n472.jl b/test/pi_n472.jl new file mode 100644 index 0000000..45f14a1 --- /dev/null +++ b/test/pi_n472.jl @@ -0,0 +1,112 @@ +@testset "PI N472 (no hardware)" begin + N = MicroscopeControl.HardwareImplementations.PI_N472 + F = Main.FakeGCS2 + quiet(f) = Base.CoreLogging.with_logger(f, Base.CoreLogging.NullLogger()) + setup_ops = ["PI_RON", "PI_qRON", "PI_POS", "PI_SVO", "PI_qSVO", "PI_qTMN", "PI_qTMX", "PI_VEL", "PI_qVEL"] + + @testset "_cstring stops at the first NUL" begin + buf = zeros(UInt8, 32) + buf[1:14] .= codeunits("PI C-885 SN 42") + buf[20] = UInt8('x') # stale bytes past the terminator are ignored + @test N._cstring(buf) == "PI C-885 SN 42" + @test N._cstring(codeunits("abc") |> collect) == "abc" + @test N._cstring(UInt8[0x00]) == "" + end + + @testset "first description is passed as a String" begin + F.reset!() + F.enum_bytes[] = UInt8[codeunits("d1\nd2")..., 0x00, codeunits("junk")...] + F.enum_count[] = 2 + stage = N472() + quiet(() -> initialize(stage)) + @test F.lastarg["PI_ConnectUSB"] isa String + @test F.lastarg["PI_ConnectUSB"] == "d1" + @test stage.connectionstatus == true + @test stage.id == 0 + end + + @testset "description is stripped" begin + F.reset!() + F.enum_bytes[] = UInt8[codeunits(" d1\r\n")..., 0x00] + quiet(() -> initialize(N472())) + @test F.lastarg["PI_ConnectUSB"] == "d1" + end + + @testset "failed connect leaves the object retryable" begin + F.reset!() + append!(F.connect_ids, [-1, 0]) + stage = N472() + @test_logs (:error, r"PI_ConnectUSB failed") match_mode=:any initialize(stage) + @test stage.connectionstatus == false + @test stage.id == -1 + @test !any(op -> op in setup_ops, F.calls) + quiet(() -> initialize(stage)) + @test stage.connectionstatus == true + @test stage.id == 0 + end + + @testset "no controller found" begin + F.reset!() + F.enum_count[] = 0 + stage = N472() + @test_logs (:error, r"No PI C-885 found") match_mode=:any initialize(stage) + @test stage.connectionstatus == false + @test !("PI_ConnectUSB" in F.calls) + @test_logs (:error, r"No PI C-885 found") match_mode=:any initialize(stage) + end + + @testset "stopmotion sends one axes string" begin + F.reset!() + stage = N472() + quiet(() -> initialize(stage)) + quiet(() -> stopmotion(stage)) + @test F.lastarg["PI_HLT"] == "1 3 5" + end + + @testset "shutdown closes only its own connection" begin + F.reset!() + A = N472(); B = N472() + quiet(() -> initialize(A)) + quiet(() -> shutdown(A)) + append!(F.connect_ids, [0, 0]) + quiet(() -> initialize(B)) + @test 0 in F.open_ids + quiet(() -> shutdown(A)) + @test 0 in F.open_ids + @test count(==("PI_CloseConnection"), F.calls) == 1 + @test A.id == -1 + end + + @testset "a fresh object holds no connection" begin + F.reset!() + @test N472().id == -1 + B = N472() + quiet(() -> initialize(B)) + @test 0 in F.open_ids + quiet(() -> shutdown(N472())) + @test 0 in F.open_ids + @test !("PI_CloseConnection" in F.calls) + end + + @testset "a failing setup step closes the connection: $op" for op in setup_ops + F.reset!() + F.error_code[] = 7 + push!(F.failing, op) + stage = N472() + err = try + quiet(() -> initialize(stage)) + nothing + catch e + e + end + @test err isa ErrorException + @test occursin("GCS error 7", err.msg) + @test !(0 in F.open_ids) + @test stage.connectionstatus == false + @test stage.id == -1 + empty!(F.failing) + F.error_code[] = 0 + quiet(() -> initialize(stage)) + @test stage.connectionstatus == true + end +end diff --git a/test/pi_n472_fake_sdk.jl b/test/pi_n472_fake_sdk.jl new file mode 100644 index 0000000..1a53adc --- /dev/null +++ b/test/pi_n472_fake_sdk.jl @@ -0,0 +1,109 @@ +# A fake PI GCS2 library for the N-472 driver. +# +# Replaces the GCS2 wrappers in `functions_GCS2.jl` (each a single `ccall` into +# a Windows DLL that is not on any build machine) with methods that record +# into `Main.FakeGCS2`, so the driver's own `initialize`/`shutdown`/`stopmotion` +# run unmodified and no test can command a rig's controller. Same seam, and +# same caveats, as `tcube_fake_sdk.jl`: the replacements are global and +# permanent for the process, and this file must be included at top level, +# early, into `Main`. See that file for the full list. + +""" + FakeGCS2 + +Recorder standing in for the PI GCS2 library: what the driver called, in what +order, with which description/axes string, and what each call reports back. +""" +module FakeGCS2 + +"Operation names in the order the driver called them, since the last `reset!`." +const calls = String[] + +"The `szDescription`/`szAxes` argument of the last call per operation, as passed." +const lastarg = Dict{String,Any}() + +"Bytes `PI_EnumerateUSB` writes into the caller's buffer." +const enum_bytes = Ref(UInt8[]) + +"Count `PI_EnumerateUSB` returns." +const enum_count = Ref(1) + +"Ids `PI_ConnectUSB` returns, in order; when empty it returns 0." +const connect_ids = Int[] + +"Operations that return FALSE." +const failing = Set{String}() + +"Ids connected and not yet closed; one id space shared by every object." +const open_ids = Set{Int}() + +"Returned by `PI_GetError` and `PI_GetInitError`." +const error_code = Ref(0) + +function reset!() + empty!(calls) + empty!(lastarg) + enum_bytes[] = UInt8[codeunits("d1")..., 0x00] + enum_count[] = 1 + empty!(connect_ids) + empty!(failing) + empty!(open_ids) + error_code[] = 0 + return nothing +end + +function record!(op, arg=nothing) + push!(calls, op) + arg === nothing || (lastarg[op] = arg) + return nothing +end + +"Record `op` and report FALSE if it is in `failing`, else TRUE." +function status!(op, arg) + record!(op, arg) + return op in failing ? Cuint(0) : Cuint(1) +end + +end # module FakeGCS2 + +FakeGCS2.reset!() + +@eval MicroscopeControl.HardwareImplementations.PI_N472 begin + function PI_EnumerateUSB(szBuffer, iBufferSize, szFilter) + Main.FakeGCS2.record!("PI_EnumerateUSB") + bytes = Main.FakeGCS2.enum_bytes[] + n = min(length(bytes), Int(iBufferSize)) + for i in 1:n + szBuffer[i] = bytes[i] + end + return Cint(Main.FakeGCS2.enum_count[]) + end + function PI_ConnectUSB(szDescription) + Main.FakeGCS2.record!("PI_ConnectUSB", szDescription) + ids = Main.FakeGCS2.connect_ids + id = isempty(ids) ? 0 : popfirst!(ids) + id >= 0 && push!(Main.FakeGCS2.open_ids, id) + return Cint(id) + end + function PI_IsConnected(ID) + Main.FakeGCS2.record!("PI_IsConnected") + return Int(ID) in Main.FakeGCS2.open_ids ? TRUE : FALSE + end + function PI_CloseConnection(ID) + Main.FakeGCS2.record!("PI_CloseConnection") + delete!(Main.FakeGCS2.open_ids, Int(ID)) + return nothing + end + PI_GetError(ID) = (Main.FakeGCS2.record!("PI_GetError"); Cint(Main.FakeGCS2.error_code[])) + PI_GetInitError() = (Main.FakeGCS2.record!("PI_GetInitError"); Cint(Main.FakeGCS2.error_code[])) + PI_HLT(ID, szAxes) = Main.FakeGCS2.status!("PI_HLT", szAxes) + PI_RON(ID, szAxes, pbValueArray) = Main.FakeGCS2.status!("PI_RON", szAxes) + PI_qRON(ID, szAxes, pbValueArray) = Main.FakeGCS2.status!("PI_qRON", szAxes) + PI_POS(ID, szAxes, pdValueArray) = Main.FakeGCS2.status!("PI_POS", szAxes) + PI_SVO(ID, szAxes, pbValueArray) = Main.FakeGCS2.status!("PI_SVO", szAxes) + PI_qSVO(ID, szAxes, pbValueArray) = Main.FakeGCS2.status!("PI_qSVO", szAxes) + PI_qTMN(ID, szAxes, pdValueArray) = Main.FakeGCS2.status!("PI_qTMN", szAxes) + PI_qTMX(ID, szAxes, pdValueArray) = Main.FakeGCS2.status!("PI_qTMX", szAxes) + PI_VEL(ID, szAxes, pdValueArray) = Main.FakeGCS2.status!("PI_VEL", szAxes) + PI_qVEL(ID, szAxes, pdValueArray) = Main.FakeGCS2.status!("PI_qVEL", szAxes) +end diff --git a/test/runtests.jl b/test/runtests.jl index 633461a..5a196c4 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -8,6 +8,10 @@ const HDF5 = MicroscopeControl.HDF5 # be included at top level, before the testsets. See the file for the seam. include("tcube_fake_sdk.jl") +# Likewise for the PI N-472's GCS2 wrappers, so `initialize`/`shutdown` run +# against a recorder and never a rig's controller. Top level, before the testsets. +include("pi_n472_fake_sdk.jl") + @testset "MicroscopeControl.jl" begin @testset "Simulated Camera" begin cam = SimCamera(exposure_time=0.01) @@ -702,53 +706,7 @@ include("tcube_fake_sdk.jl") end end - @testset "PI N472 (no hardware)" begin - N = MicroscopeControl.HardwareImplementations.PI_N472 - - @testset "_cstring stops at the first NUL" begin - buf = zeros(UInt8, 32) - buf[1:14] .= codeunits("PI C-885 SN 42") - buf[20] = UInt8('x') # stale bytes past the terminator are ignored - @test N._cstring(buf) == "PI C-885 SN 42" - @test N._cstring(codeunits("abc") |> collect) == "abc" - @test N._cstring(UInt8[0x00]) == "" - end - - @testset "shutdown clears connectionstatus" begin - stage = N472() - stage.connectionstatus = true - stage.id = Cint(-1) # never connected; PI_IsConnected(-1) is FALSE - if isfile(N.PI_GCS2) - @test_logs (:info, "Stage not connected") shutdown(stage) - @test stage.connectionstatus == false - else - @test_skip "PI GCS2 DLL not installed" - end - end - - @testset "initialize without a controller leaves the object retryable" begin - # Never command a real controller from the suite: on a rig with a - # C-885 attached, `initialize` connects, zeroes the origin and turns - # the servos on. Run this branch only when enumeration finds nothing. - present = isfile(N.PI_GCS2) && - N.PI_EnumerateUSB(zeros(UInt8, 1024), 1024, "C-885") > 0 - if present - @test_skip "PI C-885 attached; not commanding real hardware from the suite" - elseif isfile(N.PI_GCS2) - stage = N472() - # No C-885 is plugged into a build box: enumeration finds nothing, - # initialize must say so and leave the flag cleared (before this - # the flag was set before the connect was checked). - @test_logs (:error, r"No PI C-885 found") initialize(stage) - @test stage.connectionstatus == false - @test stage.id == 0 - # and a second call is not refused as "already initialized" - @test_logs (:error, r"No PI C-885 found") initialize(stage) - else - @test_skip "PI GCS2 DLL not installed" - end - end - end + include("pi_n472.jl") include("contract.jl") include("skills.jl") From 28c381825cd1ddaa65cca79b9ee5bcb946c3c9a6 Mon Sep 17 00:00:00 2001 From: kalidke Date: Tue, 29 Sep 2026 10:27:51 -0600 Subject: [PATCH 12/37] Review nits: CLAUDE.md provenance, simpler setvel return stopmotion never worked; the connect string is what worked by accident. setvel's return is unchanged in value. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 5 +++-- src/hardware_implementations/pi_n472/helper.jl | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 45d9dc6..04df747 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -119,8 +119,9 @@ Hardware implementations use `ccall` for vendor SDKs: - `mcl_stage/*.jl` - Mad City Labs NanoDrive - Serial devices (CrystaLaser, Vortran, Triggerscope) use `LibSerialPort` -Rules at the `ccall` boundary, each learned from a bug that "usually worked" -(C-867 servo, v0.1.1; N-472 stopmotion): +Rules at the `ccall` boundary, learned from the C-867 servo bug (v0.1.1), the +N-472 connect string that worked only by accident of `filter`, and the N-472 +`stopmotion` that never worked: - A `Ptr{Cchar}` argument (GCS2 axes lists, USB descriptions) gets a Julia `String`, which is always NUL-terminated. Never a `Vector{UInt8}` with the zeros filtered out, and never a `Vector{String}`; join axes with a space first. diff --git a/src/hardware_implementations/pi_n472/helper.jl b/src/hardware_implementations/pi_n472/helper.jl index 1baadea..bbcc4c3 100644 --- a/src/hardware_implementations/pi_n472/helper.jl +++ b/src/hardware_implementations/pi_n472/helper.jl @@ -75,5 +75,5 @@ function setvel(stage::N472,vel::Vector{Float64}) if qsuccess == FALSE @error "Failed to query velocity" end - return (success == FALSE || qsuccess == FALSE) ? FALSE : qsuccess + return success == FALSE ? FALSE : qsuccess end From e07069faee8a5da16da8ae7462cda50510520504 Mon Sep 17 00:00:00 2001 From: kalidke Date: Tue, 29 Sep 2026 11:24:36 -0600 Subject: [PATCH 13/37] TCubeLaser: max_current has no default in closed loop; test light_on's failed-disable path Co-Authored-By: Claude Sonnet 5.5 --- .../tcube_laser/types.jl | 21 ++++++++++------ test/runtests.jl | 25 ++++++++++++++++++- 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/src/hardware_implementations/tcube_laser/types.jl b/src/hardware_implementations/tcube_laser/types.jl index a216410..632fb47 100644 --- a/src/hardware_implementations/tcube_laser/types.jl +++ b/src/hardware_implementations/tcube_laser/types.jl @@ -168,14 +168,18 @@ laser = TCubeLaser("64849775"; laser = TCubeLaser("64849775"; mode = ConstantCurrent(), min_current = 70.0, max_current = 160.0) ``` -In `ConstantPhotocurrent` mode `wa_calibration`, `tia_range`, `tec_stabilised` -and `properties` (whose `min_power`/`max_power` become the enforced mW bounds) +In `ConstantPhotocurrent` mode `wa_calibration`, `tia_range`, `tec_stabilised`, +`max_current` and `properties` (whose `min_power`/`max_power` become the enforced mW bounds) are required: a rig cannot reach power mode without a measured calibration, a -stated amplifier range and an answer to the temperature question. In +stated amplifier range, an answer to the temperature question and an explicit +clamp. In `ConstantCurrent` mode passing any of the first three throws, and `properties` defaults to `LightSourceProperties("mA", 0.0, false, min_current, max_current)`. -- `max_current` is **your** ceiling for this diode and is never overwritten; +- `max_current` is **your** ceiling for this diode and is never overwritten. In + `ConstantCurrent` mode it defaults to `160.0`; in `ConstantPhotocurrent` mode + it has no default, because it is the clamp `initialize` programs and the only + real protection while the loop raises the current by itself; `initialize` records the controller's own limit separately in `controller_max_current`, and `setcurrent!` enforces the smaller of the two. - `min_current` defaults to `0.0`. A non-zero default would reject safe small @@ -187,7 +191,7 @@ function TCubeLaser(serialNo::String; properties::Union{Nothing,LightSourceProperties}=nothing, laser_color::String="red", min_current::Float64=0.0, - max_current::Float64=160.0, #220.0 is the max of the TCube + max_current::Union{Nothing,Float64}=nothing, # ConstantCurrent: 160.0; 220.0 is the max of the TCube controller_max_current::Float64=NaN, max_setcurrent::Float64=220.0, max_setpoint::Float64=32767.0, @@ -208,11 +212,13 @@ function TCubeLaser(serialNo::String; "There is no default, so no construction line changes meaning when one is chosen.")) pd = if mode isa ConstantPhotocurrent absent = [kw for (kw, v) in (:wa_calibration => wa_calibration, :tia_range => tia_range, - :tec_stabilised => tec_stabilised, :properties => properties) if v === nothing] + :tec_stabilised => tec_stabilised, :properties => properties, + :max_current => max_current) if v === nothing] isempty(absent) || throw(ArgumentError( "$name: a ConstantPhotocurrent laser needs these keywords, none of which has a default: $(join(absent, ", ")). " * "wa_calibration is W/A measured with a power meter at the laser output; tia_range is the rear-panel DIP switch in A; " * - "tec_stabilised is true, false or missing; properties carries the enforced min_power/max_power in mW.")) + "tec_stabilised is true, false or missing; properties carries the enforced min_power/max_power in mW; " * + "max_current is the clamp initialize programs into the controller, the only real protection in closed loop.")) PhotodiodeLoop(; wa_calibration=wa_calibration, tia_range=tia_range, tec_stabilised=tec_stabilised) else given = [kw for (kw, v) in (:wa_calibration => wa_calibration, :tia_range => tia_range, @@ -222,6 +228,7 @@ function TCubeLaser(serialNo::String; "this one is $(nameof(typeof(mode)))")) nothing end + max_current = something(max_current, 160.0) props = something(properties, LightSourceProperties("mA", 0.0, false, min_current, max_current)) TCubeLaser{typeof(mode)}(unique_id, props, laser_color, min_current, max_current, max_setcurrent, max_setpoint, serialNo, task_mod, daq, diff --git a/test/runtests.jl b/test/runtests.jl index 27bc247..7d1e79d 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -127,9 +127,20 @@ lab_summary("Core") do cc(; kw...) = TCubeLaser("00000000"; mode=ConstantCurrent(), kw...) cp_props() = LightSourceProperties("mW", 0.0, false, 1.0, 70.0) cp(; kw...) = TCubeLaser("00000000"; mode=ConstantPhotocurrent(), wa_calibration=224.2, - tia_range=1e-3, tec_stabilised=missing, properties=cp_props(), kw...) + tia_range=1e-3, tec_stabilised=missing, properties=cp_props(), max_current=160.0, kw...) @testset "Constructor defaults" begin + # Closed loop has no default clamp: it is the only real protection. + cperr = try + TCubeLaser("00000000"; mode=ConstantPhotocurrent(), wa_calibration=224.2, + tia_range=1e-3, tec_stabilised=missing, properties=cp_props()) + nothing + catch e + e + end + @test cperr isa ArgumentError + @test occursin("max_current", cperr.msg) + # `mode` is required and has no default, so no construction line # changes meaning when a default is eventually chosen. err = try @@ -730,6 +741,18 @@ lab_summary("Core") do @test FakeKinesis.bits[] & FakeKinesis.ENABLED == 0 @test laser.properties.is_on == false FakeKinesis.setpoint_readback[] = nothing + + # ... and if the disable fails too, the output may still be on, so + # is_on stays true and the error says so. + FakeKinesis.reset!() + pl = cp() + initialize(pl) + setoutputpower!(pl, 20.0) + FakeKinesis.setpoint_readback[] = UInt16(3) + FakeKinesis.fail!("LD_DisableOutput") + @test_logs (:error, r"may still be ON") match_mode = :any @test_throws ErrorException light_on(pl) + @test pl.properties.is_on == true + FakeKinesis.reset!() end @testset "readbacks (fake SDK)" begin From f89e5f72ca3bcfe23d9c506f73e65ed110621630 Mon Sep 17 00:00:00 2001 From: kalidke Date: Tue, 29 Sep 2026 11:26:16 -0600 Subject: [PATCH 14/37] Keep 0.2.4 working: mode defaults to ConstantCurrent, setpower forwards in open loop, old export_state keys and properties.power kept (decision 0035) Co-Authored-By: Claude Sonnet 5.5 --- .../tcube_laser/interface_methods.jl | 86 ++++++++++++++++++- .../tcube_laser/types.jl | 34 ++++---- .../interface_functions.jl | 18 ++-- .../lightsource_interface/interface_types.jl | 8 +- test/contract.jl | 9 +- test/runtests.jl | 70 ++++++++------- 6 files changed, 163 insertions(+), 62 deletions(-) diff --git a/src/hardware_implementations/tcube_laser/interface_methods.jl b/src/hardware_implementations/tcube_laser/interface_methods.jl index 6763a87..493af9b 100644 --- a/src/hardware_implementations/tcube_laser/interface_methods.jl +++ b/src/hardware_implementations/tcube_laser/interface_methods.jl @@ -54,6 +54,39 @@ request and is the fallback. """ const POLL_INTERVAL_MS = 50 +""" + legacy_power(light::TCubeLaser, current::Float64) + +The value `properties.power` has held since before v0.2.3: the requested +current scaled linearly onto `properties.max_power`. **Deprecated, and +scheduled for removal at the next breaking release** -- read `light.drive_current` instead. + +It is a guess, not a measurement. The controller reports no optical power in +the open-loop mode this driver uses, and the bench table preserved in +`TCubeLaserControl.jl` shows the real curve is not this line. It is reproduced +here only so that a rig reading `properties.power` across an upgrade reads the +same number it read before. + +Reproducing it exactly needs one step the old expression did not: it divided by +`light.max_current`, and `initialize` used to overwrite that field with the +controller's limit. `max_current` is now the caller's and stays so, so the +divisor is `controller_max_current` once `initialize` has read it and +`max_current` before that -- which is the same quantity the old field held at +each of those two moments. + +**One case where this deliberately does not reproduce 0.2.2.** If a caller +assigns `max_current` *after* `initialize`, 0.2.2 divided by the newly assigned +value; this divides by the controller's limit still. With `max_current = 80.0` +and a 40 mA request after a controller limit of 23830, 0.2.2 gave `50.0` and +this gives `25.000572235150496`. Reproducing it would mean intercepting writes +to the field, which is not worth doing for a number the driver invents and +which the next breaking release removes. Read `drive_current` instead; it is exact and has no +lifecycle. """ +function legacy_power(light::TCubeLaser, current::Float64) + divisor = isnan(light.controller_max_current) ? light.max_current : light.controller_max_current + return current * light.properties.max_power / divisor +end + """ effective_max_current(light::TCubeLaser) @@ -791,7 +824,9 @@ Rounding such a request up would command more current than was asked for, which is the rule this driver will not break -- but `drive_current` records the **requested** current, not the zero that was commanded. -On success, `light.drive_current` holds the accepted current, in mA. +On success, `light.drive_current` holds the accepted current, in mA, and the +deprecated `properties.power` holds [`legacy_power`](@ref)'s figure, as it did +in 0.2.4. """ function LightSourceInterface.setcurrent!(light::TCubeLaser{ConstantCurrent}, current::Float64) check_current(light, current) @@ -799,6 +834,7 @@ function LightSourceInterface.setcurrent!(light::TCubeLaser{ConstantCurrent}, cu on = output_enabled(light.serialNo) on && send_setpoint(light, code) light.drive_current = current + light.properties.power = legacy_power(light, current) # deprecated; see legacy_power println("Laser current set to $current mA", on ? "" : " (output off: applied at light_on)") return nothing end @@ -1092,8 +1128,11 @@ the identifiers and DAQ names. `tia_range_A`, `tec_stabilised` (`"true"`/`"false"`/`"unknown"`), `max_current_clamp_mA`, `output_power_requested_mW`, `photocurrent_requested_A`. -`power` and `power_unit` are no longer written: they held an uncalibrated -linear guess under a `"mW"` label. +0.2.4's keys are kept in both modes, with 0.2.4's values: `min_current`, +`max_current` (the fields, in mA), `power_unit`, `power`, `min_power` and +`max_power` (from `properties`). `power` is the deprecated uncalibrated linear +guess described in [`legacy_power`](@ref), written only by open-loop +`setcurrent!`; read `drive_current` instead. """ function export_state(light::TCubeLaser) mode = regulation_mode(light) @@ -1109,6 +1148,10 @@ function export_state(light::TCubeLaser) "daq_device" => something(light.daq_device, ""), "ao_channel" => something(light.ao_channel, ""), "drive_current" => light.drive_current, "is_on" => light.properties.is_on, + # 0.2.4's keys, kept next to the new ones. + "min_current" => light.min_current, "max_current" => light.max_current, + "power_unit" => light.properties.power_unit, "power" => light.properties.power, + "min_power" => light.properties.min_power, "max_power" => light.properties.max_power, ) if mode isa ConstantPhotocurrent pd = light.pd @@ -1133,6 +1176,43 @@ function export_state(light::TCubeLaser) return attributes, data, children end +""" + EXPORT_STATE_2ARG_WARNED + +Whether the deprecated 2-argument [`export_state`](@ref) has already attempted +its warning in this session. Not `@warn`'s `maxlog=1`, so that the warning is +testable more than once per process; `Threads.Atomic` rather than a plain `Ref` +because a check followed by a store lets two concurrent callers both warn and +is a data race besides. Reset it with `[] = false` in a test. +""" +const EXPORT_STATE_2ARG_WARNED = Threads.Atomic{Bool}(false) + +""" + export_state(light::TCubeLaser, ignored) + +Deprecated forwarder to [`export_state(::TCubeLaser)`](@ref). Warns once per +session and ignores its second argument, which was never read. + +It is kept because removing it would be a break for no benefit. The bug was the +*absence* of the 1-argument method -- `export_state(laser)` matched nothing on +this type and fell through to the throwing instrument-level stub -- so adding +that method is the whole fix, and a caller that had to pass a second argument +to get anything at all keeps working. The forwarder is scheduled for removal in +0.3.0. +""" +function export_state(light::TCubeLaser, ignored) + # Test-and-set in one atomic step: a plain `Ref` check followed by a store + # lets two concurrent callers both observe `false` and both warn, and is a + # data race besides. Note this is "attempt to warn once", not "display + # once": a first call under a logger that swallows warnings still spends + # the allowance. + if !Threads.atomic_cas!(EXPORT_STATE_2ARG_WARNED, false, true) + @warn "export_state(::TCubeLaser, x): the second argument is ignored and this method is deprecated; " * + "call export_state(laser). The 2-argument form is scheduled for removal at the next breaking release." ignored_argument = ignored + end + return export_state(light) +end + """ tcube_refresh(light::TCubeLaser) diff --git a/src/hardware_implementations/tcube_laser/types.jl b/src/hardware_implementations/tcube_laser/types.jl index 632fb47..aded32c 100644 --- a/src/hardware_implementations/tcube_laser/types.jl +++ b/src/hardware_implementations/tcube_laser/types.jl @@ -12,17 +12,23 @@ modes fixed at construction: at the laser output, converted through `pd.wa_calibration`. See `CALIBRATION.md` beside this file for how that number is measured. -`setpower` is not defined for this type (it throws): it took mA while its -name and `properties.power_unit` said mW, and flipping the mode would have -turned an 80 mA call into an 80 mW one without a word. +`setpower(laser, mA)` still works on a `ConstantCurrent` laser: it forwards to +`setcurrent!` and is deprecated. On a `ConstantPhotocurrent` laser it throws, +so that an 80 mA call can never become an 80 mW one. # Fields - `unique_id::String`: A unique identifier for the light source. - `properties::LightSourceProperties`: `is_on` is the requested output state. In `ConstantPhotocurrent` mode `min_power`/`max_power` are the ENFORCED bounds of `setoutputpower!`, in mW at the laser output, and the endpoints of - `setlevel!`. In `ConstantCurrent` mode they are not read; `power` and - `power_unit` are not written by this driver in either mode. + `setlevel!`. In `ConstantCurrent` mode they are not read. `power` is + deprecated: an open-loop `setcurrent!` writes it as 0.2.4's figure + `drive_current * properties.max_power / `, an uncalibrated + guess that is NOT in `power_unit`'s unit (the `"mA"` label covers only + `min_power`/`max_power` in open loop). With the default `properties`, whose + `max_power` is now `max_current`, the figure differs from 0.2.4's + default-properties value; `drive_current` is the number to read. A + `ConstantPhotocurrent` laser never writes `power`. - `laser_color::String`: The color of the laser. - `min_current::Float64`: The lowest drive current this rig will command, in mA: `setcurrent!`'s floor and `setlevel!`'s zero. Defaults to `0.0`. It is a @@ -141,17 +147,16 @@ LightSourceInterface.supported_modes(::Type{<:TCubeLaser}) = (ConstantCurrent, C LightSourceInterface.regulation_mode(::Type{TCubeLaser{M}}) where {M} = M() """ - TCubeLaser(serialNo::String; mode, kwargs...) + TCubeLaser(serialNo::String; kwargs...) Construct a `TCubeLaser` for the Kinesis device `serialNo`. Pure: it opens nothing, so every keyword is a declaration that `initialize` and `setupIO` later act on. The keywords match the field names documented on [`TCubeLaser`](@ref). -`mode` is **required**, with no default: `ConstantCurrent()` or -`ConstantPhotocurrent()`. It is required rather than defaulted until closed loop -has been verified on hardware, so that no existing construction line changes -meaning silently. +`mode` defaults to `ConstantCurrent()`, which is exactly the 0.2.x behaviour, so +no existing construction line changes meaning. Closed loop is chosen explicitly +with `mode = ConstantPhotocurrent()`. The default will not change. ```julia # 642 nm rig, closed loop. Calibration: see CALIBRATION.md beside this file. @@ -165,7 +170,8 @@ laser = TCubeLaser("64849775"; properties = LightSourceProperties("mW", 0.0, false, 2.0, 80.0)) # The same diode in open loop. -laser = TCubeLaser("64849775"; mode = ConstantCurrent(), min_current = 70.0, max_current = 160.0) +laser = TCubeLaser("64849775"; mode = ConstantCurrent(), # mode is optional here: the default + min_current = 70.0, max_current = 160.0) ``` In `ConstantPhotocurrent` mode `wa_calibration`, `tia_range`, `tec_stabilised`, @@ -186,7 +192,7 @@ defaults to `LightSourceProperties("mA", 0.0, false, min_current, max_current)`. currents without protecting against large ones. """ function TCubeLaser(serialNo::String; - mode::Union{Nothing,RegulationMode}=nothing, + mode::RegulationMode=ConstantCurrent(), unique_id::String="TCubeLaser", properties::Union{Nothing,LightSourceProperties}=nothing, laser_color::String="red", @@ -206,10 +212,6 @@ function TCubeLaser(serialNo::String; tec_stabilised::Union{Nothing,Bool,Missing}=nothing, ) name = "TCubeLaser $serialNo" - mode === nothing && throw(ArgumentError( - "$name: the `mode` keyword is required: ConstantCurrent() (open loop, setcurrent! in mA) or " * - "ConstantPhotocurrent() (closed loop, setoutputpower! in mW, needs wa_calibration, tia_range, tec_stabilised and properties). " * - "There is no default, so no construction line changes meaning when one is chosen.")) pd = if mode isa ConstantPhotocurrent absent = [kw for (kw, v) in (:wa_calibration => wa_calibration, :tia_range => tia_range, :tec_stabilised => tec_stabilised, :properties => properties, diff --git a/src/hardware_interfaces/lightsource_interface/interface_functions.jl b/src/hardware_interfaces/lightsource_interface/interface_functions.jl index 86f6361..2b1d8bc 100644 --- a/src/hardware_interfaces/lightsource_interface/interface_functions.jl +++ b/src/hardware_interfaces/lightsource_interface/interface_functions.jl @@ -47,15 +47,21 @@ end """ setpower(laser::DiodeLaser, x::Float64) -Always throws, naming the replacement. `setpower` meant mA on a TCube while its -name and `power_unit` said mW, so with a power mode available the same call -could mean either; it is not defined for any `DiodeLaser`, deliberately with no -forwarder, so that no existing call silently changes unit. +Deprecated for a [`ConstantCurrent`](@ref) laser, where it forwards to +[`setcurrent!`](@ref) (`x` in mA, as it always was on a TCube) and warns. The +mode is a type parameter fixed at construction, so a forwarded call can never +change unit. It throws for a [`ConstantPhotocurrent`](@ref) laser: an mA call +must not become an mW one, so use [`setoutputpower!`](@ref) there, or the +unit-free [`setlevel!`](@ref) on either. """ function setpower(laser::DiodeLaser, x::Float64) + if regulation_mode(laser) isa ConstantCurrent + Base.depwarn("setpower(laser, mA) on a $(nameof(typeof(laser))) is deprecated; use setcurrent!(laser, mA), or setlevel!(laser, frac)", :setpower) + return setcurrent!(laser, x) + end error("setpower is not defined for $(typeof(laser)): it took mA on this driver while its name said mW. " * - "Use setcurrent!(laser, mA) on a ConstantCurrent laser, setoutputpower!(laser, mW) on a ConstantPhotocurrent " * - "laser, or the unit-free setlevel!(laser, frac) on either. This laser is $(nameof(typeof(regulation_mode(laser)))).") + "Use setoutputpower!(laser, mW) on a ConstantPhotocurrent laser, or the unit-free setlevel!(laser, frac) " * + "on either. This laser is $(nameof(typeof(regulation_mode(laser)))).") end """ diff --git a/src/hardware_interfaces/lightsource_interface/interface_types.jl b/src/hardware_interfaces/lightsource_interface/interface_types.jl index 9f01dee..20901ac 100644 --- a/src/hardware_interfaces/lightsource_interface/interface_types.jl +++ b/src/hardware_interfaces/lightsource_interface/interface_types.jl @@ -38,9 +38,11 @@ A `DiodeLaser` carries, by name, the fields the shared panels and [`setlevel!`](@ref) read: `unique_id`, `properties`, `min_current`, `max_current`, `threshold_current`, `drive_current` and `pd`. -`[guarantee]` `setpower` is not defined for any `DiodeLaser`: its unit changed -with the regulation mode, so it is replaced by [`setcurrent!`](@ref), -[`setoutputpower!`](@ref) and the unit-free [`setlevel!`](@ref). +`[guarantee]` `setpower(laser, mA)` on a `ConstantCurrent` `DiodeLaser` forwards +to [`setcurrent!`](@ref) with a deprecation warning, and means what it always +did; the mode is a type parameter fixed at construction, so a forwarded call can +never change unit. On a `ConstantPhotocurrent` `DiodeLaser` it throws, and +[`setoutputpower!`](@ref) and the unit-free [`setlevel!`](@ref) replace it. `[limitation]` `InteractiveUtils.subtypes` is one level deep, so `subtypes(LightSource)` lists `DiodeLaser` and not the drivers beneath it. Walk diff --git a/test/contract.jl b/test/contract.jl index 64dbaee..7db0d56 100644 --- a/test/contract.jl +++ b/test/contract.jl @@ -108,7 +108,7 @@ end # (throwing) instrument-level stub. As of 0.2.3 the 1-arg method exists # and the exclusion is gone with it, which is what makes the generic # assertion below cover this type. The 2-arg form was a deprecated - # forwarder until 0.3.0 removed it. + # forwarder, kept until the next breaking release. no_core_methods = Set([:MLSLM]) no_initialize = Set([:ThorCamCSCCamera]) no_shutdown = Set{Symbol}() @@ -257,7 +257,8 @@ end @test !has_specific_method(MC.setcurrent!, TM, Float64) end # `setlevel!` is the shared DiodeLaser method, never the - # LightSource stub; `setpower` is the DiodeLaser refusal, + # LightSource stub; `setpower` is the DiodeLaser method + # (a deprecated forwarder in open loop, a refusal in closed), # never a driver method. @test which(MC.setlevel!, Tuple{TM,Float64}).sig.parameters[2] === MC.DiodeLaser @test which(MC.setpower, Tuple{TM,Float64}).sig.parameters[2] === MC.DiodeLaser @@ -270,8 +271,8 @@ end end end @test has_specific_method(MC.export_state, MC.TCubeLaser) - # The 2-arg deprecated forwarder was removed in 0.3.0. - @test !hasmethod(MC.export_state, Tuple{MC.TCubeLaser,Any}) + # The 2-arg deprecated forwarder is kept until the next breaking release. + @test hasmethod(MC.export_state, Tuple{MC.TCubeLaser,Any}) end @testset "Stub throws" begin diff --git a/test/runtests.jl b/test/runtests.jl index 7d1e79d..df26d08 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -141,16 +141,9 @@ lab_summary("Core") do @test cperr isa ArgumentError @test occursin("max_current", cperr.msg) - # `mode` is required and has no default, so no construction line - # changes meaning when a default is eventually chosen. - err = try - TCubeLaser("00000000") - nothing - catch e - e - end - @test err isa ArgumentError - @test occursin("mode", err.msg) + # `mode` defaults to ConstantCurrent(), the 0.2.x behaviour, so no + # existing construction line changes meaning. + @test TCubeLaser("00000000") isa TCubeLaser{ConstantCurrent} laser = cc() @test laser isa TCubeLaser{ConstantCurrent} @@ -317,23 +310,26 @@ lab_summary("Core") do @test isempty(FakeKinesis.calls) end - @testset "setpower is gone, and says what replaced it" begin - # `setpower` took mA while its name and power_unit said mW. With a - # power mode available the same call could mean either, so it is - # not defined -- deliberately with no forwarder -- and the error - # names the replacements. It reaches nothing. + @testset "setpower forwards in open loop (deprecated), throws in closed loop" begin + # `setpower` took mA. On a ConstantCurrent laser the mode is a type + # parameter, so forwarding to setcurrent! can never change unit; + # on a ConstantPhotocurrent laser it would, so it throws. FakeKinesis.reset!() - for laser in (cc(), cp()) - err = try - setpower(laser, 80.0) - nothing - catch e - e - end - @test err isa ErrorException - @test occursin("setcurrent!", err.msg) && occursin("setoutputpower!", err.msg) - @test occursin(string(nameof(typeof(regulation_mode(laser)))), err.msg) + c = cc() + initialize(c) + @test_deprecated setpower(c, 80.0) + @test c.drive_current == 80.0 + FakeKinesis.reset!() + laser = cp() + err = try + setpower(laser, 80.0) + nothing + catch e + e end + @test err isa ErrorException + @test occursin("setoutputpower!", err.msg) && occursin("setlevel!", err.msg) + @test occursin("ConstantPhotocurrent", err.msg) @test isempty(FakeKinesis.calls) # ... and each unit-true setter exists only in its own mode. @test_throws "not implemented" setoutputpower!(cc(), 10.0) @@ -973,14 +969,26 @@ lab_summary("Core") do @test isnan(attrs["drive_current"]) # nothing commanded yet @test attrs["daq_device"] == "Dev2" @test attrs["ao_channel"] == "Dev2/ao1" - # The deprecated linear guess under a "mW" label is gone. - @test !haskey(attrs, "power") && !haskey(attrs, "power_unit") + # 0.2.4's keys are kept next to the new ones. + for k in ("min_current", "max_current", "power_unit", "power", "min_power", "max_power") + @test haskey(attrs, k) + end + @test attrs["min_current"] == 0.0 && attrs["max_current"] == 160.0 + @test attrs["power_unit"] == "mA" + @test attrs["max_power"] == 160.0 + # setcurrent! writes 0.2.4's deprecated figure to properties.power. + FakeKinesis.reset!() + lp = cc() + initialize(lp) + setcurrent!(lp, 80.0) + @test lp.properties.power == TCube.legacy_power(lp, 80.0) + @test export_state(lp)[1]["power"] == lp.properties.power @test !haskey(attrs, "wa_calibration_W_per_A") # open loop has no calibration @test data === nothing @test haskey(children, "daq") @test export_state(cc())[1]["daq_device"] == "" - # The 2-argument forwarder was removed in 0.3.0. - @test_throws MethodError export_state(laser, nothing) + # The deprecated 2-argument forwarder still works. + @test export_state(laser, nothing)[1]["serialNo"] == "00000000" FakeKinesis.reset!() pl = cp(; threshold_current=65.0) @@ -1065,7 +1073,9 @@ lab_summary("Core") do initialize(c); initialize(p) @test_throws "not implemented" setcurrent!(p, 80.0) @test_throws "not implemented" setoutputpower!(c, 10.0) - @test_throws "setcurrent!" setpower(c, 80.0) + @test_throws "setoutputpower!" setpower(p, 80.0) + @test_deprecated setpower(c, 80.0) + @test c.drive_current == 80.0 end @testset "5. commands before initialize and after shutdown throw, state unchanged" begin From d6344b8658c947b20ba268829c3be8fbcd3e4edf Mon Sep 17 00:00:00 2001 From: kalidke Date: Tue, 29 Sep 2026 11:27:40 -0600 Subject: [PATCH 15/37] Fold #66's changelog into Unreleased as a non-breaking 0.2.5 change; sweep 0.3.0 references Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 77 +++++++++++-------- skills/mc-api-map/SKILL.md | 35 +++++---- skills/mc-api-map/references/gui-fields.md | 4 +- skills/mc-extend/SKILL.md | 20 ++--- .../mc-extend/references/driver-scaffold.md | 4 +- .../references/interface-scaffold.md | 2 +- skills/mc-extend/references/rig-causes.md | 10 +-- skills/mc-system-design/SKILL.md | 20 ++--- .../references/driver-caveats.md | 24 +++--- skills/mc-testing/SKILL.md | 16 ++-- .../SimulatedDiodeLaser.jl | 2 +- .../tcube_laser/interface_methods.jl | 4 +- .../tcube_laser/types.jl | 4 +- .../interface_functions.jl | 2 +- test/contract.jl | 2 +- test/runtests.jl | 4 +- test/tcube_fake_sdk.jl | 4 +- 17 files changed, 125 insertions(+), 109 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 30b3d81..0aff259 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,9 @@ the next version with `-DEV`). `initialize` starts clean. `referencemove`'s signature and return are unchanged. Reported by the MicroscopeAdapt rig; not yet run on hardware (#64). +- **`TCubeLaser`'s default `properties.power_unit` is now `"mA"` in open loop.** + It said `"mW"` while the values were always mA. Rigs that read `power_unit` + should check it; both known rigs already pass `"mA"`. ### Changed (release process) - **`main` is the development branch**, carrying the next version with @@ -29,7 +32,7 @@ the next version with `-DEV`). version check runs on every pull request again. A release branch is cut only for a safety backport (CLAUDE.md "Versioning"). -## [0.3.0] - 2026-09-28 +### Laser diodes in two regulation modes (#66) Laser diodes in two regulation modes, and closed loop (photodiode feedback, "constant power") for the TCube. Implements the design plan @@ -63,8 +66,10 @@ Laser diodes in two regulation modes, and closed loop (photodiode feedback, pause are needed); and the header's potentiometer-to-mA scale is wrong for this unit (so the clamp is the controller's own reported limit). -Per the plan's contingency (§8.1), `mode` is a **required keyword with no -default**: closed loop is not yet verified across the rig's working range. +Per decision 0035 this is a **non-breaking** change: `mode` defaults to +`ConstantCurrent()`, so every existing construction line and `setpower` call +keeps meaning what it meant in 0.2.4, and closed loop is additive and opt-in +(`mode = ConstantPhotocurrent()`); the default will not change. Not verified: the meaning of `LD_EnableMaxCurrentAdjust`'s second flag (always passed `false`); and how the Kinesis application itself brings the loop up (the C API offers only `LD_SetLaserSetPoint`; the protocol document could not @@ -75,22 +80,31 @@ that 0.2 s. Also observed: the controller must be power-cycled when switching between the Kinesis application and this driver, in either direction, or the next open fails (error code 2 / "load device failed"). -**Breaking.** Every downstream rig using a `TCubeLaser` must change its -construction line and its `setpower` calls; nothing it can write silently -changes meaning, because the old calls now throw. - -### Migration +### Deprecated +Each of these still works and keeps its 0.2.4 meaning; each is removed at the +next breaking release. +- `setpower(laser, mA)` on a `ConstantCurrent` `DiodeLaser` forwards to + `setcurrent!` and warns. The mode is fixed at construction, so a forwarded + call can never change unit. On a `ConstantPhotocurrent` laser it throws. +- `properties.power` on a `TCubeLaser`: the uncalibrated `legacy_power` figure, + written by an open-loop `setcurrent!` as 0.2.4's `setpower` wrote it, and + still exported. Read `drive_current` instead. +- The 2-argument `export_state(::TCubeLaser, x)`, which forwards to the 1-argument + method. + +Recommended new forms (the `mode = ConstantCurrent()` line is optional, it is the +default): ```julia -# before +# 0.2.4 line, still works laser = TCubeLaser("64849775"; max_current = 160.0) -setpower(laser, 80.0) # this was mA, despite the name +setpower(laser, 80.0) # mA, deprecated -# after, open loop: the same behaviour, with the unit in the name -laser = TCubeLaser("64849775"; mode = ConstantCurrent(), max_current = 160.0) +# open loop, with the unit in the name +laser = TCubeLaser("64849775"; mode = ConstantCurrent(), max_current = 160.0) # mode optional setcurrent!(laser, 80.0) # mA -# after, closed loop (calibration: src/hardware_implementations/tcube_laser/CALIBRATION.md) +# closed loop (calibration: src/hardware_implementations/tcube_laser/CALIBRATION.md) laser = TCubeLaser("64849775"; mode = ConstantPhotocurrent(), wa_calibration = 224.2, tia_range = 1e-3, tec_stabilised = missing, threshold_current = 65.0, max_current = 160.0, @@ -118,6 +132,9 @@ setlevel!(laser, 0.4) `properties.min_power`/`max_power` become the enforced mW bounds. A range the amplifier cannot reach, a TIA range the TLD001 does not have, or a ceiling below the potentiometer's lowest clamp is refused at construction. + `max_current` has no default in closed loop: it is the clamp `initialize` + programs and the only real protection there, so omitting it throws an + `ArgumentError`. In open loop it still defaults to `160.0`. - **Closed-loop `initialize`**, a protected sequence: require the key switch and interlock; require the controller's TIA range to match `tia_range` (a moved DIP switch is a silent factor-of-ten error otherwise); disable the output (the @@ -162,18 +179,17 @@ setlevel!(laser, 0.4) range, measured before the fibre, with its closed-loop verification table). ### Changed -- `setpower` is not defined for any `DiodeLaser`; it throws, naming the - replacements. There is no forwarder, on purpose: with a power mode available - a forwarded `setpower(laser, 80.0)` could have turned 80 mA into 80 mW. -- `TCubeLaser`'s `properties.power_unit` defaults to `"mA"` in open loop, and - `properties.power` is no longer written. -- `export_state(::TCubeLaser)` attributes: `regulation_mode`, `setpoint_unit`, - `min_current_mA`, `max_current_mA`, `threshold_current_mA` (were - `min_current`, `max_current`); closed loop adds `power_reference`, - `min_output_power_mW`, `max_output_power_mW`, `wa_calibration_W_per_A`, - `tia_range_A`, `tec_stabilised`, `max_current_clamp_mA`, - `output_power_requested_mW`, `photocurrent_requested_A`. `power` and - `power_unit` are gone. +- `setpower(laser, mA)` on a `DiodeLaser`: on a `ConstantCurrent` laser it + forwards to `setcurrent!` with a deprecation warning; on a + `ConstantPhotocurrent` laser it throws, naming `setoutputpower!` and + `setlevel!`, so that an mA call can never become an mW one. +- `export_state(::TCubeLaser)` ADDS the keys `regulation_mode`, `setpoint_unit`, + `min_current_mA`, `max_current_mA`, `threshold_current_mA`; closed loop adds + `power_reference`, `min_output_power_mW`, `max_output_power_mW`, + `wa_calibration_W_per_A`, `tia_range_A`, `tec_stabilised`, + `max_current_clamp_mA`, `output_power_requested_mW`, + `photocurrent_requested_A`. The 0.2.4 keys (`min_current`, `max_current`, + `power_unit`, `power`, `min_power`, `max_power`) are kept, in both modes. - `LD_GetLaserDiodeCurrentReading` is bound as signed (`Cshort`): a negative reading used to decode to about 440 mA. Kinesis booleans are split by role: arguments as zero-extended `Cuint` (`KBOOL_ARG`), returns as one byte @@ -185,17 +201,14 @@ setlevel!(laser, 0.4) `TCubeLaser` and `SimDiodeLaser`.** ### Removed -- The deprecated `legacy_power` figure (`properties.power` on `TCubeLaser`). -- The deprecated 2-argument `export_state(::TCubeLaser, x)`. - The 2-argument interface stub `light_on(::LightSource, ipower::Float64)`, - which no driver implemented; the stub is now `light_on(::LightSource)`. + which no driver implemented and which only ever threw; the stub is now + `light_on(::LightSource)`. ### Deferred -- Renaming `properties.is_on` to `is_on_requested` across all lights, queued in - the plan for this release: it touches every light driver, which the same plan +- Renaming `properties.is_on` to `is_on_requested` across all lights: deferred + to a breaking release. It touches every light driver, which the same plan otherwise leaves untouched, and is independent of the laser modes. -- Defaulting `mode` to `ConstantPhotocurrent()`: after closed loop has run on the - rig. ## [0.2.4] - 2026-09-29 diff --git a/skills/mc-api-map/SKILL.md b/skills/mc-api-map/SKILL.md index 9670725..ec546c2 100644 --- a/skills/mc-api-map/SKILL.md +++ b/skills/mc-api-map/SKILL.md @@ -97,13 +97,13 @@ sth)`, the map listed that and nothing else, and the 1-arg `export_state(laser)` lifecycle loop calls fell through to the throwing `AbstractInstrument` stub. **[fixed in v0.2.3]** — `export_state(::TCubeLaser)` exists and `which(export_state, Tuple{TCubeLaser})` lands on it. v0.2.3 kept the 2-arg method -as a deprecated forwarder; **[guarantee]** 0.3.0 removed it, so -`hasmethod(export_state, Tuple{TCubeLaser,Any})` is false and an installed map at -0.3.0 lists only the 1-arg signature. The blind spot is a property of the generator +as a deprecated forwarder; **[guarantee]** 0.2.5 keeps it (deprecated, removed +at the next breaking release), so `hasmethod(export_state, Tuple{TCubeLaser,Any})` +is still true and an installed map lists both signatures. The blind spot is a property of the generator rather than of that driver, and an installed map generated against an older pinned tag still shows the old signature. A current example: the generator's inherited check asks about the 1-arg tuple only, so a 2-arg generic whose only method is on an -abstract intermediate (`setpower(::DiodeLaser, ::Float64)`, the throwing refusal, and +abstract intermediate (`setpower(::DiodeLaser, ::Float64)`, the deprecated forwarder in open loop and refusal in closed loop, and `setlevel!(::DiodeLaser, ::Float64)`, the shared implementation) does not appear in a `TCubeLaser` or `SimDiodeLaser` section at all. When the listed signature is not the one you are calling, check the exact tuple with `hasmethod`/`which`. @@ -144,13 +144,13 @@ where dispatch lands and whether that is the concrete type: ```julia using MicroscopeControl -# false from 0.3.0: the 2-arg light_on stub was removed (up to v0.2.x it was true +# false from 0.2.5: the 2-arg light_on stub was removed (up to v0.2.x it was true # for every LightSource and landed on a throwing stub) hasmethod(light_on, Tuple{TCubeLaser,Float64}) # -> false -# resolves, but lands on the DiodeLaser-level refusal, which throws and names -# setcurrent! / setoutputpower! / setlevel! +# resolves, but lands on the DiodeLaser-level method: on ConstantCurrent a deprecated +# forwarder to setcurrent!, on ConstantPhotocurrent a refusal naming setoutputpower! / setlevel! which(setpower, Tuple{TCubeLaser{ConstantCurrent},Float64}).sig # -> Tuple{typeof(setpower), DiodeLaser, Float64} @@ -163,8 +163,8 @@ which(setcurrent!, Tuple{TCubeLaser{ConstantCurrent},Float64}).sig which(export_state, Tuple{TCubeLaser}).sig # -> Tuple{typeof(export_state), TCubeLaser} (real driver code from v0.2.3; # up to v0.2.2, the throwing stub. -# Tuple{TCubeLaser,Any} no longer -# resolves: forwarder removed in 0.3.0) +# Tuple{TCubeLaser,Any} is the +# deprecated forwarder, kept in 0.2.5) which(getdata, Tuple{SimCamera}).sig # -> Tuple{typeof(getdata), SimCamera} (real driver code) @@ -184,12 +184,12 @@ has_specific(f, T, args...) = has_specific(initialize, SimCamera) # true has_specific(initialize, ThorCamCSCCamera) # false has_specific(setpower, SimLight, Float64) # true -has_specific(setpower, TCubeLaser, Float64) # false: lands on the DiodeLaser refusal +has_specific(setpower, TCubeLaser, Float64) # false: lands on the DiodeLaser method has_specific(light_on, TCubeLaser) # true: mode-shared, written on the bare type has_specific(setcurrent!, TCubeLaser{ConstantCurrent}, Float64) # true: mode-specific ``` -The `unwrap_unionall` matters from 0.3.0: a `where`-method has a `UnionAll` +The `unwrap_unionall` matters from 0.2.5: a `where`-method has a `UnionAll` signature whose `.parameters` throws. Pass the bare `TCubeLaser` for a mode-shared method and the instantiation for a mode-specific one; `has_specific(setcurrent!, TCubeLaser, Float64)` is false because no method is written on the bare type. @@ -209,14 +209,15 @@ per device. ## Arity traps the map makes visible -- **[guarantee]** `light_on` takes the light only. Up to v0.2.x the interface +- **[guarantee]** `light_on` takes the light only. Up to v0.2.4 the interface declared `light_on(::LightSource, ipower::Float64)`, which no driver implemented - and which threw for every light; 0.3.0 replaced it with a 1-arg stub, so the 2-arg + and which threw for every light; 0.2.5 replaced it with a 1-arg stub, so the 2-arg call is now a plain `MethodError`. Set the level first, then `light_on`. -- **[guarantee]** `setpower` is not defined for any `DiodeLaser` (`TCubeLaser`, - `SimDiodeLaser`): it resolves to a refusal that throws and names `setcurrent!` - (mA, `ConstantCurrent` only), `setoutputpower!` (mW at the laser output, - `ConstantPhotocurrent` only) and `setlevel!` (unit-free `0..1`). It is unchanged on +- **[guarantee]** `setpower` on a `DiodeLaser` (`TCubeLaser`, `SimDiodeLaser`) + resolves to the `DiodeLaser` method: on a `ConstantCurrent` laser it forwards to + `setcurrent!` (mA) with a deprecation warning; on a `ConstantPhotocurrent` laser it + throws and names `setoutputpower!` (mW at the laser output) and `setlevel!` + (unit-free `0..1`). It is unchanged on the other lights. None of this shows in the map (the 2-arg blind spot above). - `move` takes `Float64` positions. `move(stage, 1, 2, 3)` with integers is a `MethodError`, not a stub error. diff --git a/skills/mc-api-map/references/gui-fields.md b/skills/mc-api-map/references/gui-fields.md index 65a3e43..d6e117c 100644 --- a/skills/mc-api-map/references/gui-fields.md +++ b/skills/mc-api-map/references/gui-fields.md @@ -75,11 +75,11 @@ panel is observably read-only. The fix is scoped to the shared light panel; it is not a guarantee about the others, and `gui(::DAQ)` still calls `showdevices`/`showchannels` at construction. -From 0.3.0 this panel serves only the lights that are not a `DiodeLaser` +From 0.2.5 this panel serves only the lights that are not a `DiodeLaser` (`CrystaLaser`, `VortranLaser`, `DaqTrLight`, `SimLight`); a `DiodeLaser` has its own, below. -## `gui(::DiodeLaser)` (0.3.0) +## `gui(::DiodeLaser)` (0.2.5) Dispatches on `regulation_mode(laser)` to `current_panel` (`ConstantCurrent`: slider in mA over `min_current .. effective_max_current(laser)`, calling diff --git a/skills/mc-extend/SKILL.md b/skills/mc-extend/SKILL.md index c331b30..8692579 100644 --- a/skills/mc-extend/SKILL.md +++ b/skills/mc-extend/SKILL.md @@ -33,8 +33,8 @@ your own type, extend freely (steps 3 and 4); an MC type, report and work around from outside (step 2). **[limitation]** The collision is worse than "diverge silently", and this has -happened (historical: the case below is v0.2.1 to v0.2.3; at 0.3.0 the 1-arg -method is upstream and the 2-arg form it replaced is gone, so a rig still carrying +happened (historical: the case below is v0.2.1 to v0.2.3; at 0.2.5 the 1-arg +method is upstream and the 2-arg form it replaced is a deprecated forwarder, so a rig still carrying a shim or a 2-arg call from that period should delete it; the pattern, and the guard, apply to any future gap). A rig repo defined `export_state(::TCubeLaser)` itself, because v0.2.1 shipped only a 2-argument form and the 1-argument call fell through to @@ -90,7 +90,7 @@ defects; a report needs the rig, in a fresh session with nothing else attached. ## 2. Report against the pinned tag, and work around from outside -The rig pins a tag (`Pkg.add(url=..., rev="v0.3.0")`) and the fix ships as a new +The rig pins a tag (`Pkg.add(url=..., rev="v0.2.5")`) and the fix ships as a new tag, so the report must say which tag, from the environment: `pkgversion(MicroscopeControl)`, `VERSION`, `Sys.KERNEL`. Template: @@ -152,8 +152,8 @@ the link only if it is not already open; `shutdown` that closes it only if `owns_port`; interface operations that range-check and cache the *requested* value; a 1-arg `export_state` whose attribute names say which kind of state they hold (`power_requested`). **[guarantee]** the interface declares -`light_on(::LightSource)` only; implement that. (Up to v0.2.x it declared a 2-arg -`light_on(::LightSource, ipower::Float64)` that no driver implemented; 0.3.0 +`light_on(::LightSource)` only; implement that. (Up to v0.2.4 it declared a 2-arg +`light_on(::LightSource, ipower::Float64)` that no driver implemented; 0.2.5 removed it, so a 2-arg call is a `MethodError`.) ### Binding identity: import to extend, never rely on `using` @@ -224,7 +224,7 @@ carry: The slider and toggle are now wired with `on`, which fires only on a later change, and initialised from the device's current `properties.power`/ `properties.is_on`. -- **DiodeLaser** (0.3.0; a laser on a controller that regulates drive current or +- **DiodeLaser** (0.2.5; a laser on a controller that regulates drive current or monitor photocurrent; a light that is only voltage-modulated stays a plain `LightSource`): **[guarantee]** the shared `current_panel`/`power_panel` and `setlevel!` read `unique_id`, `properties` (`is_on`; `min_power`/`max_power` as @@ -238,7 +238,7 @@ carry: expands the device through it) and `regulation_mode(::Type{MyLaser{M}}) where {M} = M()`; its inner constructor rejects a mode not in `supported_modes` and calls `LightSourceInterface.check_diode_config`. Do not define `setpower`: the - `DiodeLaser` refusal must answer. `TCubeLaser` and `SimDiodeLaser` are the two + `DiodeLaser` method (a deprecated forwarder in open loop, a refusal in closed loop) must answer. `TCubeLaser` and `SimDiodeLaser` are the two templates. **[guarantee]** Mode-shared methods (`initialize`, `shutdown`, `export_state`, @@ -272,7 +272,7 @@ shows the failure mode as a **[limitation]** (traced unless marked). | Calibration | table in the device (`AttenuatorProperties.cal_*`); values from the rig | `LCC1620.settransmission` `@error`s and returns when uncalibrated | | Cached versus measured | name it (`power_requested`); update `real_*` only from a readback | Sim stages copy `targ_*` into `real_*`; `getposition(::SimStage3d)` returns a scalar, not the documented tuple (executed) | | Image axis convention | `(H, W)` per frame, `(H, W, N)` per stack; at a row-major SDK boundary `permutedims(reshape(buf, (W, H)), (2, 1))` | DCAM4 does this in `dcambuf.jl`; `SimCamera` returns `(roi.height, roi.width[, N])` (executed); `save_h5` stamps `dimension_order` assuming it | -| `export_state` | 1-arg, `(Dict{String,Any}, data_or_nothing, Dict{String,Any})`; HDF5-safe values (`collect` tuples, `string` enums, `copy` vectors); children are named tuples | `Triggerscope4` has none (`MethodError`). `TCubeLaser` had only a 2-arg method until **v0.2.3**, so the contract call threw; the 1-arg method was added there, the 2-arg one kept as a deprecated forwarder, and **0.3.0 removed the forwarder** (contract test asserts `!hasmethod(export_state, Tuple{TCubeLaser,Any})`) | +| `export_state` | 1-arg, `(Dict{String,Any}, data_or_nothing, Dict{String,Any})`; HDF5-safe values (`collect` tuples, `string` enums, `copy` vectors); children are named tuples | `Triggerscope4` has none (`MethodError`). `TCubeLaser` had only a 2-arg method until **v0.2.3**, so the contract call threw; the 1-arg method was added there, the 2-arg one kept as a deprecated forwarder, and **0.2.5 removed the forwarder** (contract test asserts `!hasmethod(export_state, Tuple{TCubeLaser,Any})`) | | Unsupported operations | do not define the method; let the throwing stub answer | `stopmotion(::MCLStage)` is a concrete method whose body is `@error "STOP MOTION NOT IMPLEMENTED"` (executed), so `hasmethod` and the API map count it as implemented | ## 4. Define a new interface (rare, consequential) @@ -304,7 +304,7 @@ completion, required versus optional) are in `references/interface-scaffold.md`. **[policy]** the simulated implementation is mandatory and ships with the interface: the stubs all throw (write them so), so nothing exercises the contract without it; -upstream's contract test iterates the interface's non-abstract leaves (from 0.3.0 +upstream's contract test iterates the interface's non-abstract leaves (from 0.2.5 `device_types` walks through abstract intermediates such as `DiodeLaser`; **[limitation]** a plain `subtypes(iface)` is one level deep and misses them); downstream tests put it in the interface-typed field; a shared panel is developed against it. Make it honest @@ -363,7 +363,7 @@ has `light_on`/`light_off` on its own type and **no** 2-arg `light_on` (the `@test_broken` of v0.2.x is gone with the stub); a `DiodeLaser` must have a non-empty `supported_modes`, per mode a `regulation_mode` of that mode, the mode-specific setter on the instantiation and not the other one, `setlevel!` and -`setpower` landing on the `DiodeLaser` methods, and `measured_current`, +`setpower` landing on the `DiodeLaser` method, and `measured_current`, `measured_photocurrent`, `loop_status` on its own type; any other light must have its own `setpower`. Item 4 is **not** run per subtype: the only stub-throws checks are three assertions on two test fixture types (`_ContractDummyStage`, diff --git a/skills/mc-extend/references/driver-scaffold.md b/skills/mc-extend/references/driver-scaffold.md index 6958afc..91dc468 100644 --- a/skills/mc-extend/references/driver-scaffold.md +++ b/skills/mc-extend/references/driver-scaffold.md @@ -94,7 +94,7 @@ What the run showed: The interface stub signature is the contract you are satisfying **[guarantee]**: `setpower(::LightSource, ::Float64)`, `light_on(::LightSource)` and -`light_off(::LightSource)`. From 0.3.0 the `light_on` stub is 1-arg, matching every +`light_off(::LightSource)`. From 0.2.5 the `light_on` stub is 1-arg, matching every driver and the shared panels. (Up to v0.2.x the interface declared only a 2-arg `light_on(::LightSource, ipower::Float64)` that no driver implemented, tracked upstream as `@test_broken`, and a bare `LightSource` subtype got a `MethodError` @@ -131,7 +131,7 @@ has_specific(f, T, args...) = hasmethod(f, Tuple{T,args...}) && which(f, Tuple{T end # 3. inherited gui is the interface panel, not the AbstractInstrument stub @test which(MC.gui, Tuple{T}).sig.parameters[2] === LightSource - # 4. unsupported operations fail loudly (from 0.3.0 no 2-arg light_on exists, so + # 4. unsupported operations fail loudly (from 0.2.5 no 2-arg light_on exists, so # this is a MethodError; on v0.2.x it hit the throwing stub, an ErrorException) @test_throws MethodError MC.light_on(SerialLED(), 1.0) # 5. lifecycle and behaviour through the fake transport diff --git a/skills/mc-extend/references/interface-scaffold.md b/skills/mc-extend/references/interface-scaffold.md index 888bcf5..185fb20 100644 --- a/skills/mc-extend/references/interface-scaffold.md +++ b/skills/mc-extend/references/interface-scaffold.md @@ -115,7 +115,7 @@ What the run showed: | `initialize(SimShutter()); open_shutter!(sh); is_open(sh)` | `true`; `export_state(sh)[1]["is_open"] == true` | | `has_specific` for `initialize, shutdown, export_state, open_shutter!, close_shutter!, is_open` on `SimShutter` | all `true` | | `SimulatedShutter.open_shutter! === ShutterInterface.open_shutter!`, `SimulatedShutter.export_state === MicroscopeControl.export_state` | `true`, `true`: no shadow bindings | -| `subtypes(Shutter)` | `[NoMethodsShutter, SimShutter]`: the contract test and the API map find your devices from this, walking on through any abstract intermediate to the non-abstract leaves (from 0.3.0; `subtypes` alone is one level deep) | +| `subtypes(Shutter)` | `[NoMethodsShutter, SimShutter]`: the contract test and the API map find your devices from this, walking on through any abstract intermediate to the non-abstract leaves (from 0.2.5; `subtypes` alone is one level deep) | ## Writing the contract, not just the stubs diff --git a/skills/mc-extend/references/rig-causes.md b/skills/mc-extend/references/rig-causes.md index ccd5c1c..7c313d1 100644 --- a/skills/mc-extend/references/rig-causes.md +++ b/skills/mc-extend/references/rig-causes.md @@ -14,15 +14,15 @@ and each looks like a broken `ccall`. | Serial device times out, returns garbage, or "port busy" | Wrong COM assignment (Windows renumbers COM ports when USB topology changes), or the port is open elsewhere. Applies only to the **Triggerscope** (`Triggerscope4`, default `portname="COM3"`) and, through it, `LCC1620`. | Device Manager: match the port to the device. From Julia, `MicroscopeControl.HardwareImplementations.Triggerscope.LibSerialPort.list_ports()`. One `Triggerscope4` per physical port, shared by its dependents (`mc-system-design`). | | A DAQ-backed light (`CrystaLaser`, `VortranLaser`, `DaqTrLight`) `@warn`s at construction ("No NI-DAQ devices found ..." or "Failed to initialize NI-DAQ ..."), then `initialize` returns normally (Vortran's may `@warn` "insufficient DO channels for initialization") and `setpower`/`light_on` `@warn` about missing channels and do nothing, or only some calls work | These devices are **not** serial. They drive NI-DAQ AO/DO channels through an `NIdaq` built in their constructor, which picks `devs[1]` (Crysta, Vortran) or `devs[device_index]` (DaqTrLight, default 2). Discovery runs AO then DO inside one `try`, so it can **partially** succeed: AO channels kept, DO empty. Causes: wrong device index, missing NI-DAQmx runtime, card not enumerated, or a DO-less card. | Inspect `light.channelsAO` and `light.channelsDO` right after construction; `NIDAQcard.showdevices(NIdaq())` from Julia; compare with NI MAX. Fix the index or the runtime, not the driver. | | `TCubeLaser` does not respond | Kinesis serial number wrong or the device is open in the Kinesis GUI. It is addressed by `serialNo` (Thorlabs Kinesis), plus an `NIdaq` AO channel for modulation. Before **v0.2.3** every Kinesis status code was assigned to an unread `err`, so a failed `LD_Open` looked exactly like a successful one; from v0.2.3 each one throws naming the call and the code. | Match `serialNo` to the Kinesis GUI's device list; close that GUI. | -| `setpower(::TCubeLaser, x)` throws "setpower is not defined for TCubeLaser{...}" after moving the pin to 0.3.0 | **[guarantee]** Not a fault: from 0.3.0 `setpower` is a deliberate refusal on every `DiodeLaser`. It took mA while its name said mW, and with a power mode available the same call could mean either, so there is no forwarder. `TCubeLaser(serialNo; ...)` also now **requires** `mode=` (no default; `ArgumentError` without it). | Construct with `mode = ConstantCurrent()` and replace `setpower(laser, mA)` with `setcurrent!(laser, mA)`; or `mode = ConstantPhotocurrent()` and `setoutputpower!(laser, mW)`; or `setlevel!(laser, frac)` (unit-free `0..1`) for code that must work in either mode. | -| `setcurrent!` (formerly `setpower`) on a `TCubeLaser` drives more current than asked for, or rejects a small current | **[fixed in v0.2.3]** the range check only logged `@error` and then sent the setpoint anyway, so an out-of-range request reached the diode; and `min_current` defaulted to **60.0 mA**, a lower bound that rejected safe small currents while protecting nothing. From v0.2.3 the check throws an `ArgumentError` before any setpoint is computed, and `min_current` defaults to `0.0`. | On 0.3.0, `setcurrent!(laser, 500.0)` throws (on v0.2.3-v0.2.x, `setpower`). On an older pinned tag, do the range check in your own system code before calling. | -| A `TCubeLaser` rig ceiling is ignored after `initialize` | **[fixed in v0.2.3]** `initialize` overwrote `light.max_current` with the controller's own limit (160-220 mA), so a caller's `max_current=80.0` was gone by the time `setpower` validated against it. From v0.2.3 the controller's value goes to the new `controller_max_current` field and the current setter (`setcurrent!` from 0.3.0) enforces the smallest of `max_current`, `controller_max_current` and `max_setcurrent`. | Print `laser.max_current` after `initialize`: on an older tag it equals the controller's limit, not yours. | -| The laser's power does not match a power meter | Up to v0.2.x, `properties.power` on a `TCubeLaser` was an uncalibrated linear guess under a `"mW"` label; **[guarantee]** 0.3.0 no longer writes `properties.power`/`power_unit` or exports them. In `ConstantCurrent` mode the driver knows only current (`drive_current`, requested; `measured_current`, read back); no optical number exists. In `ConstantPhotocurrent` mode the power is `setoutputpower!`'s mW **at the laser output**, converted through `wa_calibration`: wrong if the calibration is stale, the TIA DIP switch moved, or you meter at the sample (the fibre and optics are not in it). **[limitation]** without a TEC-stabilised mount the photodiode's responsivity drifts, so the delivered power drifts while the loop holds photocurrent and `indicated_output_power` stays flat. | Meter at the plane the calibration was measured at (before the fibre on the 642 nm rig). Recalibrate per `src/hardware_implementations/tcube_laser/CALIBRATION.md` in the package source. Read `indicated_output_power(laser)` (a conversion, not a measurement) and `measured_current(laser)`: a drive current climbing at a still setpoint is a blocked photodiode or an ageing diode. | +| `setpower(::TCubeLaser, x)` warns "deprecated" after moving the pin to 0.2.5, or throws "setpower is not defined for TCubeLaser{...}" | **[guarantee]** Not a fault. On a `ConstantCurrent` laser (the default `mode`, which is what every 0.2.4 construction line builds) `setpower(laser, mA)` still works: it forwards to `setcurrent!` with a deprecation warning, and the mode is fixed at construction so the unit cannot change. On a `ConstantPhotocurrent` laser it throws and names `setoutputpower!`, because an mA call must never become an mW one. | Replace `setpower(laser, mA)` with `setcurrent!(laser, mA)`; on a `mode = ConstantPhotocurrent()` laser use `setoutputpower!(laser, mW)`; or `setlevel!(laser, frac)` (unit-free `0..1`) for code that must work in either mode. | +| `setcurrent!` (formerly `setpower`) on a `TCubeLaser` drives more current than asked for, or rejects a small current | **[fixed in v0.2.3]** the range check only logged `@error` and then sent the setpoint anyway, so an out-of-range request reached the diode; and `min_current` defaulted to **60.0 mA**, a lower bound that rejected safe small currents while protecting nothing. From v0.2.3 the check throws an `ArgumentError` before any setpoint is computed, and `min_current` defaults to `0.0`. | On 0.2.5, `setcurrent!(laser, 500.0)` throws (on v0.2.3-v0.2.x, `setpower`). On an older pinned tag, do the range check in your own system code before calling. | +| A `TCubeLaser` rig ceiling is ignored after `initialize` | **[fixed in v0.2.3]** `initialize` overwrote `light.max_current` with the controller's own limit (160-220 mA), so a caller's `max_current=80.0` was gone by the time `setpower` validated against it. From v0.2.3 the controller's value goes to the new `controller_max_current` field and the current setter (`setcurrent!` from 0.2.5) enforces the smallest of `max_current`, `controller_max_current` and `max_setcurrent`. | Print `laser.max_current` after `initialize`: on an older tag it equals the controller's limit, not yours. | +| The laser's power does not match a power meter | `properties.power` on a `TCubeLaser` is an uncalibrated linear guess, not in `power_unit`'s unit; **[guarantee]** 0.2.5 deprecates it (an open-loop `setcurrent!` still writes it as 0.2.4 did, a `ConstantPhotocurrent` laser never does; `export_state` keeps it) and the default `power_unit` is now `"mA"`. In `ConstantCurrent` mode the driver knows only current (`drive_current`, requested; `measured_current`, read back); no optical number exists. In `ConstantPhotocurrent` mode the power is `setoutputpower!`'s mW **at the laser output**, converted through `wa_calibration`: wrong if the calibration is stale, the TIA DIP switch moved, or you meter at the sample (the fibre and optics are not in it). **[limitation]** without a TEC-stabilised mount the photodiode's responsivity drifts, so the delivered power drifts while the loop holds photocurrent and `indicated_output_power` stays flat. | Meter at the plane the calibration was measured at (before the fibre on the 642 nm rig). Recalibrate per `src/hardware_implementations/tcube_laser/CALIBRATION.md` in the package source. Read `indicated_output_power(laser)` (a conversion, not a measurement) and `measured_current(laser)`: a drive current climbing at a still setpoint is a blocked photodiode or an ageing diode. | | `initialize(::TCubeLaser{ConstantPhotocurrent})` throws "refusing closed loop: key switch (0x2) and/or interlock (0x8) not set" | Key switch off or interlock open on the TLD001. **[guarantee]** power-mode `initialize` requires both before it touches the loop. | Turn the key, close the interlock, re-run `initialize`. | | `initialize` in power mode throws "the controller's photodiode range is X A but tia_range states Y A" or "reports no single photodiode range" | The rear-panel TIA DIP switch does not match the `tia_range` keyword. **[guarantee]** refused because the W/A calibration is valid only on the range it was measured on. | Set the switch to the calibrated range (`10e-6`, `100e-6`, `1e-3` or `10e-3` A); do not just edit `tia_range` unless you recalibrate on the new range. | | `initialize` in power mode throws about the max-current potentiometer or clamp | **[guarantee]** `initialize` leaves the max-current potentiometer at the highest position whose limit, **as the controller reports it**, is `<= max_current`, and records that reported limit in `pd.max_current_clamp`. Hardware-verified on the 642 nm rig (2026-09-28): a plain pot write is ignored, adjust mode is required with a pause before leaving it, and the header's position-to-mA scale is wrong for that unit, so no clamp value is computed from a position. The pot position does not survive a controller power cycle. | Report it with the message, the pinned tag and the controller firmware; until then run `ConstantCurrent`. `light_on`/`setoutputpower!` refuse while `laser.pd.max_current_clamp` is `NaN`. | | `light_on(::TCubeLaser)` drives more current than `setpower` asked for, up to the controller's limit | **[fixed in v0.2.4]** The TLD001 ignores a setpoint sent while its output is off and, at the next enable, runs on the setpoint it had stored; before v0.2.4 `setpower` followed by `light_on` ran at that stale value (seen on a rig at the ~160 mA limit). From v0.2.4 `light_on` sends the setpoint right after enabling and `light_off`/`shutdown` zero it before disabling. A separate cause with the same symptom: a control source that includes the front-panel potentiometer (`LD_GetControlSource` reading 5) makes the diode follow the knob whatever the setpoint. | On an older tag, call `light_on` before `setpower`, and `setpower(l, 0.0)` before `light_off`. If the current still ignores the setpoint while the output is on, turn the front-panel knob fully down: that is the potentiometer, not the driver. | -| Power mode holds about 21 mW (~90 mA, `loop_status().photocurrent_A` pinned near 98 µA) whatever is requested | The TLD001 locks its loop when the setpoint is JUMPED up from a low value; observed 3 of 3 times at 10 mW on the 642 nm rig (2026-09-28/29), while the same target reached in steps regulated exactly (9.30 mW measured, as Kinesis). **[limitation]** the lock is intermittent (later the same night the jump worked at 10 mW 4 of 4 times, and at 40 and 70 mW) and its mechanism unknown. **[guarantee]** since 0.3.0 the driver sends a single write by default and carries an optional ramp of upward closed-loop steps, verified at 10-70 mW (10 of 10 ramped runs): `RAMP_STEP_mW[] = 3.0`, `RAMP_STEP_S[] = 0.01` (40 mW in ~0.2 s). | If it recurs, turn the ramp on (see the driver docstring) and report the run with the pinned tag. | +| Power mode holds about 21 mW (~90 mA, `loop_status().photocurrent_A` pinned near 98 µA) whatever is requested | The TLD001 locks its loop when the setpoint is JUMPED up from a low value; observed 3 of 3 times at 10 mW on the 642 nm rig (2026-09-28/29), while the same target reached in steps regulated exactly (9.30 mW measured, as Kinesis). **[limitation]** the lock is intermittent (later the same night the jump worked at 10 mW 4 of 4 times, and at 40 and 70 mW) and its mechanism unknown. **[guarantee]** since 0.2.5 the driver sends a single write by default and carries an optional ramp of upward closed-loop steps, verified at 10-70 mW (10 of 10 ramped runs): `RAMP_STEP_mW[] = 3.0`, `RAMP_STEP_S[] = 0.01` (40 mW in ~0.2 s). | If it recurs, turn the ramp on (see the driver docstring) and report the run with the pinned tag. | | `loop_status(laser).saturated` is `true` in power mode | The drive current is at the clamp, so the power is not being held: a blocked or misaligned monitor photodiode, or a requested power the diode cannot reach under the clamp. **[policy]** treat a sustained `saturated` as a fault in unattended runs. | Check `measured_current` against `pd.max_current_clamp`; unblock the photodiode, lower the request, or (if the rig allows) raise `max_current`. `below_threshold === true` is a different fault: the diode is not lasing. | | `initialize(::TCubeLaser)` fails at `LD_Open` with Thorlabs error code 2 right after the Kinesis application used the controller (or Kinesis reports "load device failed" right after this driver did) | **[limitation]** observed on the 642 nm rig (2026-09-28/29): the TLD001 is not handed over cleanly between the Kinesis application and this DLL in either direction, even with the other side closed and no process holding it. | Power-cycle the TLD001 (off, 10 s, on) before switching between Kinesis and MicroscopeControl. | | `initialize(::TCubeLaser)` fails once, then every retry fails at `LD_Open` | **[fixed in v0.2.3]** a failure after a successful `LD_Open` — a mode change, a readings request — threw without closing, and a controller left open refuses the next `LD_Open`, so the first failure poisoned the retry. From v0.2.3 the handle is closed on the way out and the original error is the one raised. | On an older pinned tag, call `TCubeLaserControl.LD_Close(laser.serialNo)` before retrying, or power-cycle the cube. | diff --git a/skills/mc-system-design/SKILL.md b/skills/mc-system-design/SKILL.md index 939ae0b..04690c0 100644 --- a/skills/mc-system-design/SKILL.md +++ b/skills/mc-system-design/SKILL.md @@ -76,7 +76,7 @@ gives the assembled instrument its meaning. Neither can do the other's job. | **State** | the device's own fields (`exposure_time`, `roi`, `targ_x`, `properties.power`) are the configuration the driver pushes to hardware; **[guarantee]** shared code reads those fields by name (see Principle 2). | the instrument-level configuration (`AbstractSystemState`), when it is captured, and which fields are requested values versus measured ones (see "Three kinds of state"). | | **Failure recovery** | **[guarantee]** since v0.1.0 the stubs of the five `AbstractInstrument` interfaces (`Camera`, `Stage`, `LightSource`, `DAQ`, `Attenuator`) and the `AbstractInstrument` lifecycle stubs throw `ErrorException("... not implemented for T")` instead of returning `nothing`. **[limitation]** `SLM`'s `displayimage(::SLM)` has an empty body and returns `nothing`, and `SLM`/`TRIG` devices get `MethodError`, not the stub, for lifecycle calls (`references/driver-caveats.md`). Drivers mostly `@warn`/`@error` and return on hardware errors. **[limitation]** the `AbstractSystem` fallbacks still `@error` and return `nothing`. | **[policy]** rollback on partial initialization, a shutdown that reports what it could not close, and a saved record that says what is missing. All three are in the worked example below; none is provided by MC. | | **Units, axes, conventions** | its own: PI in millimetres, MCL in micrometres, Sim stages unitless (0..100); cameras return `(H, W)` / `(H, W, N)` arrays **[guarantee]** for Sim and DCAM4 (executed / traced). | **[policy]** one normalisation layer in the system (a function per device, not a conversion at every call site). | -| **Optical power at the sample** | **[guarantee]** (0.3.0, traced) a `DiodeLaser` in `ConstantPhotocurrent` mode commands and reports mW **at the laser output**, the plane its `wa_calibration` was measured at (`setoutputpower!`, `indicated_output_power`); no driver holds a field describing splitters, attenuators, fibres or objectives. In `ConstantCurrent` mode the driver has no optical number at all. | **[policy]** the path transmission from laser output to sample, its measurement and its drift belong to the system; convert in one place, and label saved values with the plane they refer to (the TCube export already writes `power_reference = "laser output"`). | +| **Optical power at the sample** | **[guarantee]** (0.2.5, traced) a `DiodeLaser` in `ConstantPhotocurrent` mode commands and reports mW **at the laser output**, the plane its `wa_calibration` was measured at (`setoutputpower!`, `indicated_output_power`); no driver holds a field describing splitters, attenuators, fibres or objectives. In `ConstantCurrent` mode the driver has no optical number at all. | **[policy]** the path transmission from laser output to sample, its measurement and its drift belong to the system; convert in one place, and label saved values with the plane they refer to (the TCube export already writes `power_reference = "laser output"`). | | **Persistence** | a one-argument `export_state` returning `(attributes, data, children)` **[guarantee]** for every device except those in the caveats file **[limitation]**. | assembling the tree, choosing the child names, deciding when to snapshot, and recording incomplete exports (see Principle 5 and the worked example). | ## The boundary test: upstream or downstream? @@ -131,7 +131,7 @@ call, because a shared interface is what gives you the Sim substitution and the GUI for free. Define a new one only when the nearest interface would lie about what the device does. -Within lights **[guarantee]** (0.3.0): a laser on a controller that *regulates* +Within lights **[guarantee]** (0.2.5): a laser on a controller that *regulates* something (drive current, or monitor photocurrent under photodiode feedback) is a `DiodeLaser <: LightSource`, parametric in a `RegulationMode` fixed at construction (`TCubeLaser{ConstantCurrent}`, `TCubeLaser{ConstantPhotocurrent}`, @@ -139,7 +139,7 @@ and the simulated `SimDiodeLaser{M}`); a light that is only on/off or modulated a voltage (`CrystaLaser`, `VortranLaser`, `DaqTrLight`, `SimLight`) is a plain `LightSource`. The two take different setters: `setpower` on a plain light; `setcurrent!` (mA), `setoutputpower!` (mW at the laser output) or the unit-free -`setlevel!` on a `DiodeLaser`, where `setpower` throws. **[policy]** type a system +`setlevel!` on a `DiodeLaser`, where `setpower` is a deprecated mA forwarder in open loop and throws in closed loop. **[policy]** type a system field `::DiodeLaser` when the system needs readback (`loop_status`, `measured_current`) and `::LightSource` when on/off is all it uses; test `isa DiodeLaser`, not a driver name, to choose the setter. @@ -164,7 +164,7 @@ Each is stated as what the code does today; the label says how far to trust it. `targ_x/y/z`, `real_x/y/z`, `range_x/y/z`, `stagelabel`; `gui(::Camera)` (`camera_interface/gui.jl`) reads `unique_id`, `exposure_time`, `roi`, `capture_mode`, `trigger_mode`, `sequence_length`, `is_running`; the light - and attenuator panels read `unique_id` and `properties`; from 0.3.0 the + and attenuator panels read `unique_id` and `properties`; from 0.2.5 the `DiodeLaser` panels and `setlevel!` also read `min_current`, `max_current`, `threshold_current`, `drive_current` and `pd`. A device that satisfies the method contract but lacks a field cannot use the inherited @@ -221,7 +221,7 @@ Each is stated as what the code does today; the label says how far to trust it. | Kind | Lives in | Example | Trust | |---|---|---|---| -| **Requested configuration** | device fields the driver pushes to hardware, and your `AbstractSystemState` | `cam.exposure_time = 0.02`; `stage.targ_z`; `light.properties.power` | what you asked for. **[limitation]** `properties.power` is only updated by `setpower` on `SimLight`; `CrystaLaser`, `VortranLaser` and `DaqTrLight` write the voltage to the DAQ and leave the field at its constructor value (traced). **[guarantee]** (0.3.0) a `DiodeLaser` never writes `properties.power`/`power_unit` (up to v0.2.x `TCubeLaser` wrote an uncalibrated linear guess there under a `"mW"` label; removed). Its requested state splits by mode: in `ConstantCurrent`, `laser.drive_current` is the mA `setcurrent!` last accepted (`NaN` before the first); in `ConstantPhotocurrent`, `drive_current` stays `NaN` for the life of the laser (the loop owns the current) and the request lives in `laser.pd.output_power_requested` (mW at the laser output) and `laser.pd.photocurrent_requested` (A, the decoded setpoint the loop was given). What the controller reports is measured state, next row. Where the driver does not track it, the system must record the requested power itself: `Bench` below retains the last `BenchState` in `sys.requested`, `get_state` returns that, and `measured_power` reads the field (executed against a DAQ-like fake: requested `3.5`, measured `0.0`). | +| **Requested configuration** | device fields the driver pushes to hardware, and your `AbstractSystemState` | `cam.exposure_time = 0.02`; `stage.targ_z`; `light.properties.power` | what you asked for. **[limitation]** `properties.power` is only updated by `setpower` on `SimLight`; `CrystaLaser`, `VortranLaser` and `DaqTrLight` write the voltage to the DAQ and leave the field at its constructor value (traced). **[guarantee]** (0.2.5) `properties.power` is deprecated on a `DiodeLaser`: `SimDiodeLaser` and any `ConstantPhotocurrent` laser never write it, and an open-loop `TCubeLaser` still writes 0.2.4's uncalibrated linear guess (not in `power_unit`'s unit; read `drive_current` instead). Its requested state splits by mode: in `ConstantCurrent`, `laser.drive_current` is the mA `setcurrent!` last accepted (`NaN` before the first); in `ConstantPhotocurrent`, `drive_current` stays `NaN` for the life of the laser (the loop owns the current) and the request lives in `laser.pd.output_power_requested` (mW at the laser output) and `laser.pd.photocurrent_requested` (A, the decoded setpoint the loop was given). What the controller reports is measured state, next row. Where the driver does not track it, the system must record the requested power itself: `Bench` below retains the last `BenchState` in `sys.requested`, `get_state` returns that, and `measured_power` reads the field (executed against a DAQ-like fake: requested `3.5`, measured `0.0`). | | **Measured hardware state** | whatever the driver reads back | `stage.real_x` after `getposition`; a frame; `connectionstatus` | what the hardware said, at the moment you asked. **[limitation]** the Sim stages copy `targ_*` into `real_*`, so measured equals requested there by construction. On a `DiodeLaser`: `measured_current` (mA), `measured_photocurrent` (A) and `loop_status` are readings; `indicated_output_power` (mW, `ConstantPhotocurrent` only) is a conversion of the photocurrent reading through a bench calibration, not a measurement of light. **[limitation]** on `TCubeLaser` these read Kinesis caches that `initialize` sets polling (50 ms); that polling refreshes them is not hardware-verified. | | **Saved metadata** | the `export_state` tree in the HDF5 file | `attrs["Main/camera"]["exposure_time"]` | a record of the above at snapshot time, plus whatever the system adds (which is missing, which is requested versus measured). | @@ -423,12 +423,12 @@ end MC.get_state(sys::Bench) = sys.requested === nothing ? error("no configuration has been requested yet") : sys.requested measured_power(sys::Bench) = laser_reading(sys.laser) laser_reading(l::LightSource) = l.properties.power # cached by SimLight only; stale on the DAQ lights -laser_reading(l::DiodeLaser) = # 0.3.0: a DiodeLaser never writes properties.power +laser_reading(l::DiodeLaser) = # 0.2.5: properties.power is deprecated on a DiodeLaser; do not read it regulation_mode(l) isa ConstantPhotocurrent ? indicated_output_power(l) : # mW at the laser OUTPUT: a conversion, not a meter NaN # ConstantCurrent has no optical number; log measured_current(l) (mA) -# The light's setter depends on its kind. On a DiodeLaser `setpower` throws; setlevel! is the +# The light's setter depends on its kind. On a DiodeLaser `setpower` is deprecated (throws in closed loop); setlevel! is the # mode-agnostic call, taking a fraction 0..1 of the device's declared range (0 is not off). set_laser!(l::LightSource, p::Float64) = setpower(l, p) # plain light: the driver's own unit set_laser!(l::DiodeLaser, frac::Float64) = setlevel!(l, frac) # so BenchState.laser_power is a fraction here @@ -494,7 +494,7 @@ end What the run showed (`SimCamera(roi=CameraROI(1,1,64,32), exposure_time=0.01)`, `SimStage3d()`, `SimLight()` unless stated; the `DiodeLaser` methods of -`laser_reading` and `set_laser!` were added for 0.3.0 and are traced from the +`laser_reading` and `set_laser!` were added for 0.2.5 and are traced from the source, not part of this run, which exercised the `LightSource` paths): | Path | Result | @@ -574,9 +574,9 @@ stop; each is false at v0.2.0. argument it was given. `CrystaLaser`, `VortranLaser` and `DaqTrLight` never write the field at all, so the slider can display a stale cached value on those three. This is not a new opening-time write -- it is - about what the widget shows. (Up to v0.2.x `TCubeLaser` also went through this + about what the widget shows. (Up to v0.2.4 `TCubeLaser` also went through this panel, with a `setpower` that took **milliamps** and stored a calculated power - under a `"mW"` label. **[guarantee]** from 0.3.0 a `DiodeLaser` has its own + under a `"mW"` label. **[guarantee]** from 0.2.5 a `DiodeLaser` has its own panel, `current_panel` in mA or `power_panel` in mW at the laser output, chosen by mode; opening it issues no command and no read, the readout is filled only by its Read button or the Poll toggle (off by default), and a refused command is diff --git a/skills/mc-system-design/references/driver-caveats.md b/skills/mc-system-design/references/driver-caveats.md index a9fb191..0ed6d14 100644 --- a/skills/mc-system-design/references/driver-caveats.md +++ b/skills/mc-system-design/references/driver-caveats.md @@ -20,8 +20,8 @@ the connection; the table shows which drivers currently follow it. | `SimCamera()`, `SimStage*()`, `SimLight()` | pure | none | executed | | `PIStage()`, `N472()`, `MCLStage()`, `MCS2Stage()` | pure (`connectionstatus=false`, handle 0) | none until `initialize` | executed (constructors), traced (initialize) | | `ThorcamDCXCamera()` | pure | none until `initialize` | traced | -| `TCubeLaser(serialNo; mode, daq=NIdaq(), daq_device=nothing, ao_channel=nothing, ...)` | pure; stores the Kinesis serial number, a DAQ handle and (from v0.2.3) the DAQ device/AO channel names `setupIO` should use. **From 0.3.0 `mode` is required** (`ConstantCurrent()` or `ConstantPhotocurrent()`; `ArgumentError` without it, so no construction line changes meaning silently) and the result is a `TCubeLaser{M} <: DiodeLaser`. Power mode also requires `wa_calibration` (W/A at the laser output), `tia_range` (A, the TLD001 DIP switch: `10e-6`, `100e-6`, `1e-3`, `10e-3`), `tec_stabilised` (`Bool` or `missing`) and `properties`, whose `min_power`/`max_power` become enforced mW bounds; the constructor validates them (`max_power` must fit `tia_range * wa_calibration`). `threshold_current` (mA, `NaN` unknown) is optional in both modes. The pre-0.3.0 positional arities still construct, as `ConstantCurrent`. | none until `initialize` | traced | -| `SimDiodeLaser(; mode, ...)` | pure; the simulated `DiodeLaser`, same required keywords as `TCubeLaser`; every command and read throws before `initialize` and after `shutdown` | none | traced (0.3.0) | +| `TCubeLaser(serialNo; daq=NIdaq(), daq_device=nothing, ao_channel=nothing, ...)` | pure; stores the Kinesis serial number, a DAQ handle and (from v0.2.3) the DAQ device/AO channel names `setupIO` should use. `mode` defaults to `ConstantCurrent()` (0.2.x behaviour; closed loop is `mode = ConstantPhotocurrent()`) and the result is a `TCubeLaser{M} <: DiodeLaser` (from 0.2.5). Power mode also requires `max_current` (the clamp `initialize` programs; no default there), `wa_calibration` (W/A at the laser output), `tia_range` (A, the TLD001 DIP switch: `10e-6`, `100e-6`, `1e-3`, `10e-3`), `tec_stabilised` (`Bool` or `missing`) and `properties`, whose `min_power`/`max_power` become enforced mW bounds; the constructor validates them (`max_power` must fit `tia_range * wa_calibration`). `threshold_current` (mA, `NaN` unknown) is optional in both modes. The pre-0.2.5 positional arities still construct, as `ConstantCurrent`. | none until `initialize` | traced | +| `SimDiodeLaser(; mode, ...)` | pure; the simulated `DiodeLaser`, `mode` required (unlike `TCubeLaser`), and in `ConstantPhotocurrent` the same further keywords; every command and read throws before `initialize` and after `shutdown` | none | traced (0.2.5) | | `Triggerscope4(; portname="COM3")` | creates a `LibSerialPort.SerialPort` object for the port; does not open it | `shutdown`, if `initialize` opened it | traced | | `LCC1620(; scope=Triggerscope4(), dac_channel=1)` | constructs its own `Triggerscope4` unless you pass `scope`; validates `dac_channel` against `scope.dacoutputs` with `@error` (does not throw) | as for the scope it holds | traced | | **`DCAM4Camera(dev_id=0)`** | **calls `dcamapi_init` and `dcamdev_open`, reads sensor size and exposure.** The camera is claimed before `initialize`; `initialize(::DCAM4Camera)` is then a no-op. **Fixed in 0.2.1:** both failure paths now throw an `ErrorException` naming the device id and the `DCAMERR` code, instead of returning a non-camera value; on each of those two checked paths the constructor calls `dcamapi_uninit()` before it throws. That is not a general guarantee: an exception raised after `dcamdev_open` succeeds has no cleanup guard, and on the two paths that do clean up the cleanup is only *attempted*: `dcamapi_uninit()` checks its own SDK result and logs an `@error` when it fails, but the constructor ignores that return and throws either way, so a failed uninitialize is visible in the log and nowhere else. (Before 0.2.1, neither failure path returned a camera: if `dcamapi_init` failed the constructor called `dcamapi_uninit` itself and returned the `DCAMERR` code; if init succeeded but `dcamdev_open` failed it `@error`d "Could not open camera" and returned `nothing`, **leaving the DCAM API initialized**.) | On success: `shutdown(cam)` (`dcamdev_close` + `dcamapi_uninit`) even if you never called `initialize`. On an installed copy older than 0.2.1, a `nothing` return needs a manual `MicroscopeControl.HardwareImplementations.DCAM4.dcamapi_uninit()` call, or the next `DCAM4Camera()` in the session starts from a half-initialized SDK; check `cam isa DCAM4Camera` before storing it. | traced (hardware verification: NOT DONE for the 0.2.1 fix — no Hamamatsu camera available) | @@ -45,7 +45,7 @@ The runtime constraints come from object ownership: |---|---|---|---| | `LCC1620` | `scope::Triggerscope4` | keyword `scope=`; defaults to a fresh `Triggerscope4()` on `COM3` | construct the scope first if you want to share it. `initialize(att)` calls `initialize(att.scope)` itself when the scope's port is not open, then sets the DAC range and drives to `min_voltage`. `shutdown(att)` drives the channel to `min_voltage` (0 V, which is **full transmission** on the LCC1620) and leaves the port open. | | `XEM` (module `OK_XEM`) | `daq::NIdaq` | keyword `daq=`; defaults to `NIdaq()` | none: `NIdaq` holds no connection (tasks are created and deleted per operation, `initialize`/`shutdown` are no-ops) | -| `TCubeLaser` | `daq::NIdaq` for the modulation channel | keyword `daq=` | none, as above. `TCubeLaserControl.setupIO(laser)` creates the AO task and picks the **second** discovered device and its **second** AO channel unless you pass `daq_device=`/`ao_channel=` (those keywords, and a validating error message in place of a bare `BoundsError`, are v0.2.3). **[guarantee]** (0.3.0, traced) `initialize` in `ConstantPhotocurrent` mode is a protected sequence and never enables the output: it requires key switch and interlock; requires the controller's TIA range bits to match `tia_range`; disables the output (it cannot zero the setpoint: the TLD001 ignores setpoints while the output is off, so `light_on` sends the setpoint right after enabling and `light_off` zeroes it before disabling); leaves the max-current potentiometer at the highest position whose controller-reported limit is `<= max_current` and records that limit in `pd.max_current_clamp`; enters closed loop and verifies the status bit; writes and reads back the W/A factor. Any failed step throws and closes the handle. `light_on` and `setoutputpower!` refuse until the clamp is verified. Both modes start Kinesis polling (50 ms), so the readbacks are cache reads. Hardware-verified on the 642 nm rig (2026-09-28, output off and on): polling refreshes the setpoint cache, the pot needs adjust mode, setpoints only take with the output on. Closed loop verified at 1-70 mW with single-write setpoints; an optional ramp (`RAMP_STEP_mW[]`) is kept because a jump from 0 once locked the controller's loop at ~21 mW, intermittently; see `mc-extend/references/rig-causes.md` | +| `TCubeLaser` | `daq::NIdaq` for the modulation channel | keyword `daq=` | none, as above. `TCubeLaserControl.setupIO(laser)` creates the AO task and picks the **second** discovered device and its **second** AO channel unless you pass `daq_device=`/`ao_channel=` (those keywords, and a validating error message in place of a bare `BoundsError`, are v0.2.3). **[guarantee]** (0.2.5, traced) `initialize` in `ConstantPhotocurrent` mode is a protected sequence and never enables the output: it requires key switch and interlock; requires the controller's TIA range bits to match `tia_range`; disables the output (it cannot zero the setpoint: the TLD001 ignores setpoints while the output is off, so `light_on` sends the setpoint right after enabling and `light_off` zeroes it before disabling); leaves the max-current potentiometer at the highest position whose controller-reported limit is `<= max_current` and records that limit in `pd.max_current_clamp`; enters closed loop and verifies the status bit; writes and reads back the W/A factor. Any failed step throws and closes the handle. `light_on` and `setoutputpower!` refuse until the clamp is verified. Both modes start Kinesis polling (50 ms), so the readbacks are cache reads. Hardware-verified on the 642 nm rig (2026-09-28, output off and on): polling refreshes the setpoint cache, the pot needs adjust mode, setpoints only take with the output on. Closed loop verified at 1-70 mW with single-write setpoints; an optional ramp (`RAMP_STEP_mW[]`) is kept because a jump from 0 once locked the controller's loop at ~21 mW, intermittently; see `mc-extend/references/rig-causes.md` | | `CrystaLaser`, `VortranLaser`, `DaqTrLight` | `daq::NIdaq` | **built internally**; no keyword, cannot be shared | none; each constructor does its own discovery (see side effects) | Because `NIdaq` is a stateless handle, several devices each holding their own @@ -70,9 +70,10 @@ Each is confirmed by the exception sets in upstream `test/contract.jl` `export_state(laser)` fell through to the throwing stub. **v0.2.3** adds the 1-argument method — which is the whole fix — and removes the type from upstream's `no_export_state` exception set. v0.2.3 kept the 2-argument form as a -deprecated forwarder; **[guarantee]** 0.3.0 removed it, so a 2-argument call is -now a `MethodError`. The 0.3.0 export also changed its attribute names: every -mode writes `regulation_mode`, `setpoint_unit` (`"mA"`/`"mW"`), `min_current_mA`, +deprecated forwarder; **[guarantee]** 0.2.5 keeps it, still deprecated and removed +at the next breaking release. The 0.2.5 export ADDS attribute names and keeps +0.2.4's (`min_current`, `max_current`, `power_unit`, `power`, `min_power`, +`max_power`): every mode writes `regulation_mode`, `setpoint_unit` (`"mA"`/`"mW"`), `min_current_mA`, `max_current_mA`, `controller_max_current`, `threshold_current_mA`, `drive_current`, `is_on` and the identifiers and DAQ names; `ConstantPhotocurrent` adds `power_reference` (`"laser output"`), `min_output_power_mW`, @@ -110,13 +111,14 @@ Resolving to a device-specific method is not the same as the operation working: (`snap(::SimCamera)` versus `snap(::Camera)`) rather than a blanket call order. - `light_on(light, power::Float64)` existed up to v0.2.x only as a throwing - interface stub, tracked upstream as `@test_broken`. **[guarantee]** 0.3.0 + interface stub, tracked upstream as `@test_broken`. **[guarantee]** 0.2.5 removed it: the stub is now the 1-arg `light_on(light)` every driver implements, and the 2-arg call is a `MethodError`. - `setpower(::DiodeLaser, ::Float64)` (so on `TCubeLaser` and `SimDiodeLaser`) - resolves but always throws, naming `setcurrent!`, `setoutputpower!` and - `setlevel!` (traced, 0.3.0). It is deliberate: there is no forwarder, so no - existing call silently changes unit. `setlevel!` has methods only for + resolves: on a `ConstantCurrent` laser it forwards to `setcurrent!` with a + deprecation warning, on a `ConstantPhotocurrent` laser it throws, naming + `setoutputpower!` and `setlevel!` (traced, 0.2.5). The mode is fixed at + construction, so a forwarded call never changes unit. `setlevel!` has methods only for `DiodeLaser` **[limitation]**; on the plain lights it is the throwing stub. - `setlevel!(laser, 0.0)` sets the bottom of the declared range, which is not off (in `ConstantCurrent` it is `min_current`, which may be above threshold). @@ -133,7 +135,7 @@ misbehaves on `SimCamera`. `MLSLM` has no `gui`; `gui(::TRIG)` exists for `setpower(light, 0.5)` on open (executed against a fake-transport light); opening **the shared light panel** is now observably read-only. That is the whole scope of the fix -- `gui(::DAQ)` still queries `showdevices` and -`showchannels` at construction. From 0.3.0 a `DiodeLaser` opens its own panel +`showchannels` at construction. From 0.2.5 a `DiodeLaser` opens its own panel (`current_panel` or `power_panel`, by mode), which issues no command and no read on open **[guarantee]**. diff --git a/skills/mc-testing/SKILL.md b/skills/mc-testing/SKILL.md index 46335c1..11345e0 100644 --- a/skills/mc-testing/SKILL.md +++ b/skills/mc-testing/SKILL.md @@ -47,7 +47,7 @@ machine with no hardware or DLLs loads the package fine. | `SimCamera` | keyword, all defaulted | `exposure_time=0.1` (s), `roi=CameraROI(1, 1, 1024, 1024)`, `sequence_length=10`, `capture_mode=LIVE`, `is_running=false` | | `SimStage3d` / `SimStage2d` / `SimStage1d` | `Base.@kwdef` | `dimensions=3/2/1`, `range_*=(0, 100)`, `real_*=0.0`, `targ_*=0.0`, `connectionstatus=false`, `label` | | `SimLight` | keyword | `properties=LightSourceProperties("mW", 0.0, false, 0.0, 100.0)` | -| `SimDiodeLaser{M}` (0.3.0) | keyword; **`mode` required**, and in `ConstantPhotocurrent` also `wa_calibration`, `tia_range`, `tec_stabilised`, `properties`, exactly as `TCubeLaser` | `threshold_current=65.0`, `max_current=160.0` (mA), `min_current=0.0`, `efficiency=1.2` (mW/mA above threshold), `responsivity=1/wa_calibration` (calibration starts true); fault fields `pd_blocked`, `responsivity_drift`, `tia_range_A` (the simulated DIP switch), `tia_over_fault`, `tia_under_fault`, `key`, `interlock`; `log` | +| `SimDiodeLaser{M}` (0.2.5) | keyword; **`mode` required**, and in `ConstantPhotocurrent` also `wa_calibration`, `tia_range`, `tec_stabilised`, `properties`, as `TCubeLaser` (which, unlike this, defaults `mode`) | `threshold_current=65.0`, `max_current=160.0` (mA), `min_current=0.0`, `efficiency=1.2` (mW/mA above threshold), `responsivity=1/wa_calibration` (calibration starts true); fault fields `pd_blocked`, `responsivity_drift`, `tia_range_A` (the simulated DIP switch), `tia_over_fault`, `tia_under_fault`, `key`, `interlock`; `log` | All implement `initialize`, `shutdown`, `export_state` **[guarantee]**. There is no simulated DAQ, attenuator, Triggerscope or SLM; for those, write a fake behind @@ -73,10 +73,10 @@ Honestly, per device (executed): call `println`s a status line. `move` is `Float64`-only. - **SimLight.** `setpower` writes `properties.power` with no clamp; `light_on`/`light_off` toggle `is_on`; `initialize` turns it **on**, - `shutdown` off. 2-arg `light_on(light, power)` is a `MethodError` from 0.3.0 + `shutdown` off. 2-arg `light_on(light, power)` is a `MethodError` from 0.2.5 (up to v0.2.x it hit a throwing interface stub). `SimLight` is a plain `LightSource`, so it is **not** the simulator for a `TCubeLaser`. -- **SimDiodeLaser** (0.3.0; traced from the source and its upstream testset, not +- **SimDiodeLaser** (0.2.5; traced from the source and its upstream testset, not part of the v0.2.0 run). The simulator for any `DiodeLaser` system: same abstract type, same constructor keywords and validation, same `loop_status` layout and `export_state` attribute names as `TCubeLaser`, so a field typed @@ -177,15 +177,15 @@ types; today the same loop fails for `ThorCamCSCCamera` (`initialize`, `export_state`), naming the device that would throw during `shutdown` before it does. `TCubeLaser` failed the `export_state` assertion too until **v0.2.3**, whose `export_state(::TCubeLaser)` is the 1-argument method that was missing; the old -`export_state(::TCubeLaser, sth)` stayed as a deprecated forwarder through -v0.2.x and **0.3.0 removed it** (a 2-arg call is now a `MethodError`). An +`export_state(::TCubeLaser, sth)` stays as a deprecated forwarder in +0.2.5 (removed at the next breaking release). An installed copy of this skill older than v0.2.3 still lists `TCubeLaser` as failing. For a parametric `DiodeLaser` (`TCubeLaser`, `SimDiodeLaser`), pass the **bare** type for the lifecycle and other mode-shared methods, and the instantiation for a mode-specific one: `has_specific(setcurrent!, TCubeLaser{ConstantCurrent}, -Float64)`. **[guarantee]** from 0.3.0 upstream's own check unwraps `UnionAll` +Float64)`. **[guarantee]** from 0.2.5 upstream's own check unwraps `UnionAll` signatures (`Base.unwrap_unionall(m.sig).parameters[2]`); the plain `.sig.parameters[2]` above throws on a `where`-method, so use the unwrapped form if your loop can reach one. **[limitation]** if you build the type list with @@ -253,7 +253,7 @@ test: it snapshots `export_state(dev)` before and after `gui(dev)` for every Sim device, and additionally opens the light panel on a recording light whose `setpower`/`light_on`/`light_off` log every call, asserting the log is empty. The state snapshot alone cannot catch the defect, because the widgets now -initialise from the device's own values.) From 0.3.0 `test/gui.jl` does the same +initialise from the device's own values.) From 0.2.5 `test/gui.jl` does the same for the `DiodeLaser` panels with `SimDiodeLaser` in both modes: `empty!(sim.log)`, open the panel, assert the log is still empty. **[guarantee]** opening `current_panel`/`power_panel` issues no command and **no read** (the readout @@ -286,7 +286,7 @@ Upstream's `.github/workflows/CI.yml` (Ubuntu): Both halves matter: the background `Xvfb :99` plus `DISPLAY` covers precompilation in `julia-buildpkg`, the `xvfb-run -a` prefix covers the test process. Add `RIG_SIMULATE: "true"` to the job `env`. Pin MicroscopeControl.jl -to a tag (`Pkg.add(url=..., rev="v0.3.0")`) and rerun `install_skills()` after +to a tag (`Pkg.add(url=..., rev="v0.2.5")`) and rerun `install_skills()` after moving it so the API map matches what CI tests against. ## What hardware acceptance must still establish diff --git a/src/hardware_implementations/simulated_diode_laser/SimulatedDiodeLaser.jl b/src/hardware_implementations/simulated_diode_laser/SimulatedDiodeLaser.jl index 8a921c1..ee6a30a 100644 --- a/src/hardware_implementations/simulated_diode_laser/SimulatedDiodeLaser.jl +++ b/src/hardware_implementations/simulated_diode_laser/SimulatedDiodeLaser.jl @@ -92,7 +92,7 @@ LightSourceInterface.regulation_mode(::Type{SimDiodeLaser{M}}) where {M} = M() """ SimDiodeLaser(; mode, kwargs...) -`mode` is required, as on `TCubeLaser`. In `ConstantPhotocurrent` mode so are +`mode` is required here (unlike `TCubeLaser`, where it defaults to `ConstantCurrent()`). In `ConstantPhotocurrent` mode so are `wa_calibration`, `tia_range`, `tec_stabilised` and `properties`, exactly as on the hardware driver, so that a system built against the simulation constructs the same way. `threshold_current` defaults to 65 mA and `max_current` to 160 mA diff --git a/src/hardware_implementations/tcube_laser/interface_methods.jl b/src/hardware_implementations/tcube_laser/interface_methods.jl index 493af9b..5de8721 100644 --- a/src/hardware_implementations/tcube_laser/interface_methods.jl +++ b/src/hardware_implementations/tcube_laser/interface_methods.jl @@ -1197,8 +1197,8 @@ It is kept because removing it would be a break for no benefit. The bug was the *absence* of the 1-argument method -- `export_state(laser)` matched nothing on this type and fell through to the throwing instrument-level stub -- so adding that method is the whole fix, and a caller that had to pass a second argument -to get anything at all keeps working. The forwarder is scheduled for removal in -0.3.0. +to get anything at all keeps working. The forwarder is scheduled for removal at the +next breaking release. """ function export_state(light::TCubeLaser, ignored) # Test-and-set in one atomic step: a plain `Ref` check followed by a store diff --git a/src/hardware_implementations/tcube_laser/types.jl b/src/hardware_implementations/tcube_laser/types.jl index aded32c..1b33b48 100644 --- a/src/hardware_implementations/tcube_laser/types.jl +++ b/src/hardware_implementations/tcube_laser/types.jl @@ -66,7 +66,7 @@ so that an 80 mA call can never become an 80 mW one. `nothing` otherwise. The first fourteen fields are in the order they have always been in, and the -two added in 0.3.0 are at the end, so the pre-0.3.0 positional forms still +two added in 0.2.5 are at the end, so the pre-0.2.5 positional forms still construct: they build a `TCubeLaser{ConstantCurrent}` with both defaulted. # The setpoint only takes while the output is on @@ -126,7 +126,7 @@ mutable struct TCubeLaser{M<:RegulationMode} <: DiodeLaser end end -# The pre-0.3.0 positional arities, both building a ConstantCurrent laser: the +# The pre-0.2.5 positional arities, both building a ConstantCurrent laser: the # fields added since sit at the end of the struct precisely so these can # default them. Positional construction cannot build a power-mode laser; that # needs the calibration keywords. diff --git a/src/hardware_interfaces/lightsource_interface/interface_functions.jl b/src/hardware_interfaces/lightsource_interface/interface_functions.jl index 2b1d8bc..f9b06db 100644 --- a/src/hardware_interfaces/lightsource_interface/interface_functions.jl +++ b/src/hardware_interfaces/lightsource_interface/interface_functions.jl @@ -19,7 +19,7 @@ end Turn on the light source. The stub used to take a second `ipower::Float64` argument that no driver -implemented. It was removed in 0.3.0 rather than implemented: "on at a power" +implemented. It was removed in 0.2.5 rather than implemented: "on at a power" has no definable unit across lights, so it is two checkable calls -- set the level, then `light_on`. diff --git a/test/contract.jl b/test/contract.jl index 7db0d56..26950b3 100644 --- a/test/contract.jl +++ b/test/contract.jl @@ -235,7 +235,7 @@ end for T in lights # Mode-shared methods are written against the bare (UnionAll) type. - # The 2-arg `light_on(light, ipower)` stub was removed in 0.3.0: + # The 2-arg `light_on(light, ipower)` stub was removed in 0.2.5: # "on at a power" has no definable unit across lights. @test has_specific_method(MC.light_on, T) @test has_specific_method(MC.light_off, T) diff --git a/test/runtests.jl b/test/runtests.jl index df26d08..e612b47 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -119,7 +119,7 @@ lab_summary("Core") do # unmodified against the recorder installed by `tcube_fake_sdk.jl`, which # is what makes their *own* control flow (not a helper's) a thing the suite # can fail on. What no test here can tell you is how a real controller - # answers: see CHANGELOG 0.2.3 and 0.3.0, hardware verification NOT DONE. + # answers: see CHANGELOG 0.2.3 and 0.2.5, hardware verification NOT DONE. @testset "TCube Laser (no hardware)" begin TCube = MicroscopeControl.HardwareImplementations.TCubeLaserControl # Open-loop and closed-loop lasers as the rigs would build them. The @@ -168,7 +168,7 @@ lab_summary("Core") do @testset "Positional construction, old arity and new" begin # The fields added since 0.2.2 sit at the end of the struct so that - # the ten- and fourteen-argument positional calls a pre-0.3.0 + # the ten- and fourteen-argument positional calls a pre-0.2.5 # caller wrote still construct -- as open-loop lasers, since only # the keyword form can supply a photodiode calibration. props = LightSourceProperties("mW", 0.0, false, 0.0, 100.0) diff --git a/test/tcube_fake_sdk.jl b/test/tcube_fake_sdk.jl index 2990371..b044ff0 100644 --- a/test/tcube_fake_sdk.jl +++ b/test/tcube_fake_sdk.jl @@ -45,7 +45,7 @@ # this file is included. They are expected. # -# Since 0.3.0 the fake also carries the small amount of controller STATE the +# Since 0.2.5 the fake also carries the small amount of controller STATE the # closed-loop path reads back -- status bits, the setpoint, the max-current # potentiometer, the W/A factor, the two readings -- because that path verifies # each of its writes, and a recorder that only returned 0 could not tell a @@ -91,7 +91,7 @@ driver's default scale. """ const diode_limit_raw = Ref{Int}(23830) -"Raw diode-current reading; `nothing` returns `diode_limit_raw`, as before 0.3.0." +"Raw diode-current reading; `nothing` returns `diode_limit_raw`, as before 0.2.5." const current_raw = Ref{Union{Nothing,Int}}(nothing) "Raw photocurrent reading (`LD_GetPhotoCurrentReading`)." From 4a1439e8bf435574d2d48470f12fd67fbb1827a8 Mon Sep 17 00:00:00 2001 From: kalidke Date: Tue, 29 Sep 2026 11:31:49 -0600 Subject: [PATCH 16/37] Pass max_current in the two remaining closed-loop test constructions Co-Authored-By: Claude Sonnet 5.5 --- test/gui.jl | 2 +- test/runtests.jl | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test/gui.jl b/test/gui.jl index 9d217b0..3a43ea3 100644 --- a/test/gui.jl +++ b/test/gui.jl @@ -141,7 +141,7 @@ MicroscopeControl.light_off(light::RecordingLight) = (push!(light.log, :light_of FakeKinesis.reset!() for laser in (TCubeLaser("00000000"; mode=ConstantCurrent()), TCubeLaser("00000000"; mode=ConstantPhotocurrent(), wa_calibration=224.2, - tia_range=1e-3, tec_stabilised=missing, properties=cp_props())) + tia_range=1e-3, tec_stabilised=missing, properties=cp_props(), max_current=160.0)) gui(laser) GLMakie.closeall() end diff --git a/test/runtests.jl b/test/runtests.jl index e612b47..f3704d2 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -1162,7 +1162,7 @@ lab_summary("Core") do @testset "export_state matches the hardware driver's attribute names" begin hw = Set(keys(export_state(TCubeLaser("00000000"; mode=ConstantPhotocurrent(), wa_calibration=224.2, - tia_range=1e-3, tec_stabilised=missing, properties=LightSourceProperties("mW", 0.0, false, 1.0, 70.0)))[1])) + tia_range=1e-3, tec_stabilised=missing, properties=LightSourceProperties("mW", 0.0, false, 1.0, 70.0), max_current=160.0))[1])) simk = Set(keys(export_state(sim_cp())[1])) @test issubset(simk, hw) @test "power_reference" in simk && "wa_calibration_W_per_A" in simk From b4c16daa3c6845b201407477179a34b38a10b3d7 Mon Sep 17 00:00:00 2001 From: kalidke Date: Tue, 29 Sep 2026 11:40:20 -0600 Subject: [PATCH 17/37] TCubeLaser.initialize leaves the output off and lowers an open-loop clamp 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 --- .../tcube_laser/interface_methods.jl | 76 ++++++++++++----- test/runtests.jl | 85 ++++++++++++++----- 2 files changed, 116 insertions(+), 45 deletions(-) diff --git a/src/hardware_implementations/tcube_laser/interface_methods.jl b/src/hardware_implementations/tcube_laser/interface_methods.jl index 5de8721..68ee72a 100644 --- a/src/hardware_implementations/tcube_laser/interface_methods.jl +++ b/src/hardware_implementations/tcube_laser/interface_methods.jl @@ -245,6 +245,23 @@ function record_controller_limit!(light::TCubeLaser, raw) return light.controller_max_current end +""" + lower_open_loop_clamp!(light::TCubeLaser) + +Open loop only, run by `initialize` after the controller's limit is recorded: if +`light.controller_max_current > light.max_current`, lower the potentiometer with +[`program_clamp!`](@ref) and store the limit it returns in +`light.controller_max_current`. If the controller's limit is at or below +`max_current` the potentiometer is not touched: a rig that lowered it by hand +keeps it. Closed loop programs its clamp in `enter_mode!` and this does nothing. +""" +function lower_open_loop_clamp!(light::TCubeLaser{ConstantCurrent}) + light.controller_max_current > light.max_current || return nothing + light.controller_max_current = program_clamp!(light) + return nothing +end +lower_open_loop_clamp!(light::TCubeLaser{ConstantPhotocurrent}) = nothing + # --------------------------------------------------------------------------- # Closed-loop (ConstantPhotocurrent) arithmetic # --------------------------------------------------------------------------- @@ -339,7 +356,7 @@ function set_digpot!(light::TCubeLaser, position::Int) end """ - program_clamp!(light::TCubeLaser{ConstantPhotocurrent}) + program_clamp!(light::TCubeLaser) Leave the controller's diode current limit at the highest potentiometer position whose limit, **as the controller reports it**, does not exceed @@ -351,8 +368,18 @@ on the rig's controller, so moves fall short and approach the ceiling from one side. It never needs more than a few settings, and none if the present position already qualifies. Throws if even the lowest position is above the ceiling, or if it cannot settle. Output must be off (it is, in `initialize`). + +In `ConstantPhotocurrent` mode `initialize` calls it to program the clamp. In +`ConstantCurrent` mode `initialize` calls it through +[`lower_open_loop_clamp!`](@ref) only when the controller's limit is above +`max_current`: the search then starts at a position whose limit is over the +ceiling, so every move stays below that position and it can only LOWER the +clamp. It never raises the potentiometer. + +`[limitation]` lowering the open-loop potentiometer is not validated on hardware +beyond the 642 nm rig's closed-loop sequence; not yet run on hardware in open loop. """ -function program_clamp!(light::TCubeLaser{ConstantPhotocurrent}) +function program_clamp!(light::TCubeLaser) ceiling = light.max_current pos = read_digpot(light.serialNo) limit = read_limit_mA(light) @@ -597,11 +624,20 @@ has_request(light::TCubeLaser{ConstantPhotocurrent}) = !isnan(light.pd.output_po Open the controller, start background polling ([`POLL_INTERVAL_MS`](@ref)), put it in the laser's [`RegulationMode`](@ref), and record the controller's own diode current limit in `light.controller_max_current`. It never enables the -output. Polling starts first because the setpoint read-back that every verified +output, and it leaves it OFF: in both modes, right after polling starts and +before the mode command is sent, it zeroes the setpoint and disables the output +([`zero_then_disable`](@ref)), so the mode command is never sent while the diode +is lit and `properties.is_on` is false afterwards. Polling starts first because the setpoint read-back that every verified write depends on only refreshes through it (see [`SETPOINT_CONFIRM_TIMEOUT_S`](@ref)). -`ConstantCurrent`: `LD_SetOpenLoopMode`, then the limit read. +`ConstantCurrent`: `LD_SetOpenLoopMode`, then the limit read. If the controller's +limit is above `max_current`, the potentiometer is then lowered until it is not +([`lower_open_loop_clamp!`](@ref)) and `controller_max_current` is the limit +that results; if it is at or below `max_current` the potentiometer is never +touched, so a limit a rig set lower by hand stays. `[limitation]` the open-loop +potentiometer lowering is unvalidated on hardware beyond the 642 nm rig's +closed-loop sequence; not yet run on hardware in open loop. `ConstantPhotocurrent` -- the closed-loop entry sequence. Every step is a refusal that cannot be retrofitted after a diode is damaged, so none may be @@ -613,12 +649,7 @@ simplified away: throw; and throw if it disagrees with `pd.tia_range`, naming both and the rear-panel switch. A range moved between sessions is a silent factor-of-ten error in every commanded power. -3. Disable the output. (The setpoint is not zeroed here: the controller - ignores setpoints while the output is off, see - [`SETPOINT_NEEDS_OUTPUT`](@ref). `light_on` sends the intended photocurrent - setpoint immediately after enabling, and the clamp bounds the moment in - between.) -4. Program the clamp, **the only real protection in power mode**, because the +3. Program the clamp, **the only real protection in power mode**, because the loop raises current by itself to hold its setpoint (and a blocked photodiode drives it straight to the clamp): [`program_clamp!`](@ref) leaves the max-current potentiometer at the highest position whose limit, as the @@ -626,8 +657,8 @@ simplified away: `pd.max_current_clamp` records that reported limit, never a request or a value computed from a position (the header's position scale is wrong on the rig's controller; see [`DIGPOT_STEP_ESTIMATE_mA`](@ref)). -5. `LD_SetClosedLoopMode`, then re-read the bits and require `0x4`. -6. `LD_SetWACalibFactor(pd.wa_calibration)` and read it back within `Cfloat` +4. `LD_SetClosedLoopMode`, then re-read the bits and require `0x4`. +5. `LD_SetWACalibFactor(pd.wa_calibration)` and read it back within `Cfloat` tolerance, so the front panel and this driver display the same number. The factor scales the controller's display only; the driver does its own conversion. @@ -635,7 +666,8 @@ simplified away: If any step after `LD_Open` fails, the handle is closed before the error propagates -- a half-open controller refuses the next `LD_Open` and so blocks the retry -- and the original error is the one raised. A power-mode failure -after step 3 leaves the output off. +leaves the output off. It sets `pd.max_current_clamp` to `NaN` first, so a failed +re-initialize cannot leave a stale clamp. It deliberately does **not** touch `light.max_current`: that field is the caller's ceiling for this diode, and overwriting it with the controller's @@ -659,6 +691,9 @@ function initialize(light::TCubeLaser) # A success FLAG, not a status code: `false` is the failure. check_flag(LD_StartPolling(serialNo, POLL_INTERVAL_MS), "LD_StartPolling", serialNo) sleep(REQUEST_WAIT_S[]) + # Whatever else left the output on, it is off before the mode command + # is sent, and `is_on` is false afterwards. + zero_then_disable(light) enter_mode!(regulation_mode(light), light) check_err(LD_RequestReadings(serialNo), "LD_RequestReadings", serialNo) # The diode current limit has its OWN request in the Kinesis API, and @@ -673,6 +708,7 @@ function initialize(light::TCubeLaser) sleep(REQUEST_WAIT_S[]) out = LD_GetLaserDiodeMaxCurrentLimit(serialNo) record_controller_limit!(light, out) + lower_open_loop_clamp!(light) catch # The open succeeded, so this handle is ours to close; a controller # left open refuses the next `LD_Open` and so blocks the retry. The @@ -697,6 +733,7 @@ enter_mode!(::ConstantCurrent, light::TCubeLaser) = function enter_mode!(::ConstantPhotocurrent, light::TCubeLaser) serialNo, pd = light.serialNo, light.pd name = "TCubeLaser $serialNo" + pd.max_current_clamp = NaN # a failed re-initialize must not leave a stale clamp # 1. key switch and interlock bits = read_status_fresh(serialNo) @@ -711,21 +748,16 @@ function enter_mode!(::ConstantPhotocurrent, light::TCubeLaser) "$name: refusing closed loop: the controller's photodiode range is $(reported) A but tia_range states $(pd.tia_range) A. " * "Check the rear-panel DIP switch; the calibration is only valid on the range it was measured on.") - # 3. output off. The setpoint cannot be zeroed here: the controller ignores - # setpoints with the output off (SETPOINT_NEEDS_OUTPUT); light_on sends the - # real one right after enabling. - check_err(LD_DisableOutput(serialNo), "LD_DisableOutput", serialNo) - light.properties.is_on = false - - # 4. the clamp, as the controller itself reports it + # 3. the clamp, as the controller itself reports it (the output is off: + # `initialize` zeroed and disabled it before this) pd.max_current_clamp = program_clamp!(light) - # 5. closed loop, verified + # 4. closed loop, verified check_err(LD_SetClosedLoopMode(serialNo), "LD_SetClosedLoopMode", serialNo) read_status_fresh(serialNo) & STATUS_BITS.closed_loop != 0 || error("$name: LD_SetClosedLoopMode returned success but the status word does not report closed loop (0x4)") - # 6. the display calibration, verified + # 5. the display calibration, verified check_err(LD_SetWACalibFactor(serialNo, Cfloat(pd.wa_calibration)), "LD_SetWACalibFactor", serialNo) check_err(LD_RequestWACalibFactor(serialNo), "LD_RequestWACalibFactor", serialNo) sleep(REQUEST_WAIT_S[]) diff --git a/test/runtests.jl b/test/runtests.jl index f3704d2..f22ba45 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -343,17 +343,20 @@ lab_summary("Core") do # `initialize`, so this runs the real `initialize` against the fake # Kinesis SDK. Restoring the old `light.max_current = ...` # assignment must fail this testset. - FakeKinesis.reset!() # controller reports a 160 mA limit + # The controller reports a 160 mA limit and the pot follows the rig's + # scale, so initialize lowers it under the 80 mA ceiling. + FakeKinesis.reset!() + FakeKinesis.limit_follows_pot[] = true laser = cc(; max_current=80.0) initialize(laser) - @test FakeKinesis.calls == ["TLI_BuildDeviceList", "TLI_GetDeviceListSize", - "LD_Open", "LD_StartPolling", "LD_SetOpenLoopMode", "LD_RequestReadings", - "LD_RequestLaserDiodeMaxCurrentLimit", - "LD_GetLaserDiodeMaxCurrentLimit"] + # The output is zeroed and disabled BEFORE the mode command. + @test FakeKinesis.calls[1:7] == ["TLI_BuildDeviceList", "TLI_GetDeviceListSize", + "LD_Open", "LD_StartPolling", "LD_SetLaserSetPoint", "LD_DisableOutput", "LD_SetOpenLoopMode"] + @test FakeKinesis.calls[8:9] == ["LD_RequestReadings", "LD_RequestLaserDiodeMaxCurrentLimit"] @test laser.max_current == 80.0 # survived initialize - @test laser.controller_max_current ≈ 160.0 rtol = 1e-3 - @test TCube.effective_max_current(laser) == 80.0 + @test laser.controller_max_current <= 80.0 # lowered, never above the caller's ceiling + @test TCube.effective_max_current(laser) == laser.controller_max_current # the lowered limit is now the tighter one # A post-initialize request between the caller's ceiling and the # controller's is refused, and nothing reaches the SDK. @@ -405,7 +408,7 @@ lab_summary("Core") do @test weak.max_current == 80.0 # still the caller's @test TCube.effective_max_current(weak) == weak.controller_max_current @test_throws ArgumentError setcurrent!(weak, 70.0) # between the two ceilings - @test isempty(FakeKinesis.setpoints) + @test FakeKinesis.setpoints == [UInt16(0)] # initialize's own zero, nothing from the refused request # Polling is a success FLAG, not a status code: `false` fails. FakeKinesis.reset!() @@ -414,6 +417,40 @@ lab_summary("Core") do @test FakeKinesis.calls[end] == "LD_Close" # and the handle is released end + @testset "initialize leaves the output off; the open-loop clamp only lowers (fake SDK)" begin + # (a) Output left on by other software: zeroed and disabled BEFORE the mode command. + FakeKinesis.reset!() + FakeKinesis.bits[] |= FakeKinesis.ENABLED + FakeKinesis.setpoint_held[] = UInt16(32767) + laser = cc() + laser.properties.is_on = true + initialize(laser) + @test FakeKinesis.bits[] & FakeKinesis.ENABLED == 0 + @test FakeKinesis.setpoint_held[] == 0 + @test laser.properties.is_on == false + @test findfirst(==("LD_DisableOutput"), FakeKinesis.calls) < + findfirst(==("LD_SetOpenLoopMode"), FakeKinesis.calls) + + # (b) The controller's limit is above the ceiling: the pot is lowered. + FakeKinesis.reset!() + FakeKinesis.limit_follows_pot[] = true + low = cc(; max_current=100.0) + initialize(low) + @test low.controller_max_current <= 100.0 + @test FakeKinesis.digpot[] < 204 + @test !isempty(FakeKinesis.digpot_sets) + @test all(<(204), FakeKinesis.digpot_sets) + + # (c) The limit is at or below the ceiling: the pot is never raised. + FakeKinesis.reset!() + FakeKinesis.limit_follows_pot[] = true + high = cc(; max_current=200.0) + initialize(high) + @test isempty(FakeKinesis.digpot_sets) + @test FakeKinesis.digpot[] == 204 + @test high.controller_max_current ≈ 160.74 atol = 0.05 + end + @testset "a failed initialize closes the connection (fake SDK)" begin # A controller left open refuses the next LD_Open, so a failure # after the open must not leak the handle -- and the error the @@ -431,7 +468,8 @@ lab_summary("Core") do @test occursin("LD_SetOpenLoopMode", err.msg) @test occursin("3", err.msg) # the Thorlabs code, not a cleanup error @test FakeKinesis.calls == ["TLI_BuildDeviceList", "TLI_GetDeviceListSize", - "LD_Open", "LD_StartPolling", "LD_SetOpenLoopMode", "LD_StopPolling", "LD_Close"] + "LD_Open", "LD_StartPolling", "LD_SetLaserSetPoint", "LD_DisableOutput", + "LD_SetOpenLoopMode", "LD_StopPolling", "LD_Close"] @test isnan(laser.controller_max_current) # nothing recorded # A failure at the open itself has no handle to close. @@ -458,7 +496,8 @@ lab_summary("Core") do @test occursin("3", bothErr.msg) @test !occursin("fake close explosion", bothErr.msg) # ... not the cleanup's @test FakeKinesis.calls == ["TLI_BuildDeviceList", "TLI_GetDeviceListSize", - "LD_Open", "LD_StartPolling", "LD_SetOpenLoopMode", "LD_StopPolling", "LD_Close"] + "LD_Open", "LD_StartPolling", "LD_SetLaserSetPoint", "LD_DisableOutput", + "LD_SetOpenLoopMode", "LD_StopPolling", "LD_Close"] @test isnan(both.controller_max_current) end @@ -476,28 +515,27 @@ lab_summary("Core") do @test FakeKinesis.calls == ["TLI_BuildDeviceList", "TLI_GetDeviceListSize", "LD_Open", # polling first: the setpoint read-back only refreshes through it "LD_StartPolling", + # the output left on is zeroed, then disabled, before any mode command + "LD_SetLaserSetPoint", "LD_DisableOutput", # 1-2: key, interlock and the amplifier range, from a fresh read "LD_RequestStatusBits", "LD_GetStatusBits", - # 3: output off (no setpoint: the controller ignores one with the output off) - "LD_DisableOutput", - # 4: the clamp. At position 204 the controller reports 160.74 mA, + # 3: the clamp. At position 204 the controller reports 160.74 mA, # over the 160 mA ceiling, so it steps to 203 (159.91 mA) and stops. clamp_read..., limit_read..., "LD_EnableMaxCurrentAdjust", "LD_SetMaxCurrentDigPot", clamp_read..., "LD_EnableMaxCurrentAdjust", limit_read..., - # 5: closed loop, confirmed from the status word + # 4: closed loop, confirmed from the status word "LD_SetClosedLoopMode", "LD_RequestStatusBits", "LD_GetStatusBits", - # 6: the display calibration, confirmed + # 5: the display calibration, confirmed "LD_SetWACalibFactor", "LD_RequestWACalibFactor", "LD_GetWACalibFactor", # shared tail: the controller's limit "LD_RequestReadings", limit_read...] @test "LD_EnableOutput" ∉ FakeKinesis.calls # initialize never emits @test FakeKinesis.bits[] & FakeKinesis.ENABLED == 0 @test laser.properties.is_on == false - # No setpoint is sent with the output off (the controller would ignore - # it); the stale word stays on the controller until light_on replaces it. - @test isempty(FakeKinesis.setpoints) - @test FakeKinesis.setpoint_held[] == 11915 + # The stale setpoint the output was left on is zeroed before the disable. + @test FakeKinesis.setpoints == [UInt16(0)] + @test FakeKinesis.setpoint_held[] == 0 # The diode flag is never raised: passed as false both times. @test FakeKinesis.adjust_calls == [(true, false), (false, false)] @test FakeKinesis.digpot_sets == [203] @@ -706,6 +744,7 @@ lab_summary("Core") do FakeKinesis.setpoint_held[] = FakeKinesis.STALE_SETPOINT # a controller left near full scale laser = cc() initialize(laser) + empty!(FakeKinesis.setpoints) # initialize zeroed the output it found # Nothing requested yet: light_on replaces the stale word with 0. light_on(laser) @test FakeKinesis.setpoints == [UInt16(0)] @@ -804,15 +843,15 @@ lab_summary("Core") do end @testset "setlevel! maps onto each mode's declared range (fake SDK)" begin - FakeKinesis.reset!() + FakeKinesis.reset!(limit_raw=22341) # 149.99 mA: under the 150 ceiling, so the pot is left alone laser = cc(; min_current=70.0, max_current=150.0) - initialize(laser) # the controller's 160 mA limit does not narrow 150 + initialize(laser) setlevel!(laser, 0.0) @test laser.drive_current == 70.0 # the floor, which still emits: not off setlevel!(laser, 1.0) - @test laser.drive_current == 150.0 + @test laser.drive_current ≈ 150.0 atol = 0.02 setlevel!(laser, 0.25) - @test laser.drive_current ≈ 90.0 + @test laser.drive_current ≈ 90.0 atol = 0.02 @test_throws ArgumentError setlevel!(laser, 1.5) @test_throws ArgumentError setlevel!(laser, -0.1) From e769cce4c7023a59254772e5c0aabf5f0856a361 Mon Sep 17 00:00:00 2001 From: kalidke Date: Tue, 29 Sep 2026 11:45:47 -0600 Subject: [PATCH 18/37] Closed-loop light_on and setoutputpower! re-check the controller before 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 --- .../tcube_laser/interface_methods.jl | 25 +++++++++- test/runtests.jl | 46 ++++++++++++++++--- 2 files changed, 63 insertions(+), 8 deletions(-) diff --git a/src/hardware_implementations/tcube_laser/interface_methods.jl b/src/hardware_implementations/tcube_laser/interface_methods.jl index 68ee72a..1ae8676 100644 --- a/src/hardware_implementations/tcube_laser/interface_methods.jl +++ b/src/hardware_implementations/tcube_laser/interface_methods.jl @@ -791,7 +791,14 @@ stored setpoint, bounded in hardware only by its current-limit potentiometer; see [`TCubeLaser`](@ref). A `ConstantPhotocurrent` laser refuses until `initialize` has programmed and -verified its clamp (`pd.max_current_clamp` is not `NaN`). +verified its clamp (`pd.max_current_clamp` is not `NaN`), and re-checks the +controller before it emits: a fresh status read must report closed loop, and a +fresh read of the controller's limit must not exceed the programmed clamp by +more than 1 mA (a controller power cycle can restore the pot). + +`[limitation]` those two checks add two request/read round trips (about 2 x +`REQUEST_WAIT_S`) to every closed-loop `light_on` and `setoutputpower!`; +unvalidated on hardware. """ function LightSourceInterface.light_on(light::TCubeLaser) require_clamp(regulation_mode(light), light, "light_on") @@ -829,6 +836,16 @@ function require_clamp(::ConstantPhotocurrent, light::TCubeLaser, op) isnan(light.pd.max_current_clamp) && error( "TCubeLaser $(light.serialNo): $op refused: the max-current clamp has not been programmed and verified. " * "Call initialize first; it is the only real protection in closed loop.") + # Fresh reads, not the polled cache: the mode or the pot may have changed + # since initialize (front panel, or a controller power cycle). + bits = read_status_fresh(light.serialNo) + bits & STATUS_BITS.closed_loop != 0 || error( + "TCubeLaser $(light.serialNo): $op refused: the controller is not in closed loop (status 0x$(string(bits; base=16))); " * + "was the mode changed on the front panel? Call initialize again.") + limit = read_limit_mA(light) + limit > light.pd.max_current_clamp + 1.0 && error( + "TCubeLaser $(light.serialNo): $op refused: the controller's max-current clamp reads $(limit) mA, above the $(light.pd.max_current_clamp) mA " * + "that initialize programmed. The clamp may have been reset by a controller power cycle: call initialize again.") return nothing end @@ -877,7 +894,11 @@ end Command the optical power at the laser output, in mW -- the plane where `pd.wa_calibration` was measured. Not the power at the sample. -1. Refuse unless `initialize` programmed the clamp. +1. Refuse unless `initialize` programmed the clamp, and re-check the controller: + a fresh status read must report closed loop and a fresh limit read must not + exceed the programmed clamp by more than 1 mA. `[limitation]` this adds two + request/read round trips (about 2 x `REQUEST_WAIT_S`) to every call; + unvalidated on hardware. 2. [`check_power`](@ref) against `properties.min_power..max_power`. 3. Refuse from the (polled) status word: unless it reports closed loop; if the photodiode amplifier is over range; or if it is under range while the output diff --git a/test/runtests.jl b/test/runtests.jl index f22ba45..7a5a7a3 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -629,9 +629,39 @@ lab_summary("Core") do @test "LD_EnableOutput" ∉ FakeKinesis.calls end + @testset "power mode re-checks the controller before emitting (fake SDK)" begin + # A 100 mA ceiling, so a pot restored to 204 (160.74 mA) is far above the + # clamp: the check allows 1 mA of slack, which is more than one pot step + # (0.83 mA), so a one-step drift at a 160 mA ceiling is not caught. + ready() = (FakeKinesis.reset!(); FakeKinesis.limit_follows_pot[] = true; + l = cp(; max_current=100.0); initialize(l); empty!(FakeKinesis.calls); l) + # (a) A power cycle restored the pot: the limit is back above the clamp. + laser = ready() + FakeKinesis.digpot[] = 204 + @test_throws "clamp" light_on(laser) + @test "LD_EnableOutput" ∉ FakeKinesis.calls + @test_throws "clamp" setoutputpower!(laser, 10.0) + # (b) The closed-loop bit is gone. + laser = ready() + FakeKinesis.setbits!(FakeKinesis.CLOSED; on=false) + @test_throws "closed loop" setoutputpower!(laser, 10.0) + @test_throws "closed loop" light_on(laser) + @test "LD_EnableOutput" ∉ FakeKinesis.calls + # (c) A re-initialize that fails in enter_mode! leaves no stale clamp. + # The key switch refusal is the first step that can fail, before the + # clamp is programmed again. + laser = ready() + @test !isnan(laser.pd.max_current_clamp) + FakeKinesis.setbits!(FakeKinesis.KEY; on=false) + @test_throws "key switch" initialize(laser) + @test isnan(laser.pd.max_current_clamp) + end + @testset "setoutputpower! (fake SDK)" begin # Refused before initialize: the clamp is not programmed, and it is # the only real protection in closed loop. + guard = ["LD_RequestStatusBits", "LD_GetStatusBits", + "LD_RequestLaserDiodeMaxCurrentLimit", "LD_GetLaserDiodeMaxCurrentLimit"] FakeKinesis.reset!() laser = cp() @test_throws "clamp" setoutputpower!(laser, 10.0) @@ -644,14 +674,18 @@ lab_summary("Core") do @test_throws ArgumentError setoutputpower!(laser, 80.0) @test_throws ArgumentError setoutputpower!(laser, 0.5) @test_throws ArgumentError setoutputpower!(laser, NaN) - @test isempty(FakeKinesis.calls) + # (`require_clamp`'s fresh reads come first; nothing is sent.) + @test all(c -> c in guard, FakeKinesis.calls) + @test isempty(FakeKinesis.setpoints) @test isnan(laser.pd.output_power_requested) # 50 mW -> 50/1000/224.2 A of photocurrent -> a word of 1 mA full scale. # With the output off it is recorded only; light_on sends it after enabling. + empty!(FakeKinesis.calls) setoutputpower!(laser, 50.0) i_pd = 50.0 / 1000 / 224.2 - @test FakeKinesis.calls == ["LD_GetStatusBits"] + # ... after the two fresh reads `require_clamp` makes before emitting. + @test FakeKinesis.calls == [guard..., "LD_GetStatusBits"] @test isempty(FakeKinesis.setpoints) @test laser.pd.output_power_requested == 50.0 empty!(FakeKinesis.calls) @@ -659,7 +693,7 @@ lab_summary("Core") do # The ramp is off by default (jumps were reliable on the rig when last # tested): enable, read the held setpoint, one write, confirmed. @test isinf(TCube.RAMP_STEP_mW[]) - @test FakeKinesis.calls == ["LD_EnableOutput", "LD_SetLaserSetPoint", "LD_GetLaserSetPoint"] + @test FakeKinesis.calls == [guard..., "LD_EnableOutput", "LD_SetLaserSetPoint", "LD_GetLaserSetPoint"] code = FakeKinesis.setpoints[end] @test code == UInt16(floor(i_pd / 1e-3 * 32767)) @test FakeKinesis.setpoint_held[] == code @@ -671,9 +705,9 @@ lab_summary("Core") do try empty!(FakeKinesis.calls); empty!(FakeKinesis.setpoints) light_on(laser) - @test FakeKinesis.calls[1:2] == ["LD_EnableOutput", "LD_GetLaserSetPoint"] + @test FakeKinesis.calls[1:6] == [guard..., "LD_EnableOutput", "LD_GetLaserSetPoint"] @test FakeKinesis.calls[end] == "LD_GetLaserSetPoint" - @test all(==("LD_SetLaserSetPoint"), FakeKinesis.calls[3:end-1]) + @test all(==("LD_SetLaserSetPoint"), FakeKinesis.calls[7:end-1]) @test FakeKinesis.setpoints[end] == code step = round(Int, 3.0 / 1000 / 224.2 / 1e-3 * 32767) @test 15 <= length(FakeKinesis.setpoints) <= 18 @@ -686,7 +720,7 @@ lab_summary("Core") do # checking the photodiode is not reading 0x8000, over range). empty!(FakeKinesis.calls) setoutputpower!(laser, 50.0) - @test FakeKinesis.calls == ["LD_GetStatusBits", "LD_GetPhotoCurrentReading", "LD_SetLaserSetPoint", "LD_GetLaserSetPoint"] + @test FakeKinesis.calls == [guard..., "LD_GetStatusBits", "LD_GetPhotoCurrentReading", "LD_SetLaserSetPoint", "LD_GetLaserSetPoint"] # Downward steps are sent directly, no ramp. empty!(FakeKinesis.calls); empty!(FakeKinesis.setpoints) setoutputpower!(laser, 10.0) From 2360e1c834030ea79050674773bbd329955e0c4b Mon Sep 17 00:00:00 2001 From: kalidke Date: Tue, 29 Sep 2026 11:49:32 -0600 Subject: [PATCH 19/37] Move the ramp to per-laser PhotodiodeLoop fields, ramp from what the 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 --- .../tcube_laser/interface_methods.jl | 129 +++++++++++------- .../tcube_laser/types.jl | 12 +- .../lightsource_interface/interface_types.jl | 53 +++++-- test/gui.jl | 3 +- test/runtests.jl | 67 +++++++-- test/tcube_fake_sdk.jl | 1 - 6 files changed, 193 insertions(+), 72 deletions(-) diff --git a/src/hardware_implementations/tcube_laser/interface_methods.jl b/src/hardware_implementations/tcube_laser/interface_methods.jl index 1ae8676..0c3a4c3 100644 --- a/src/hardware_implementations/tcube_laser/interface_methods.jl +++ b/src/hardware_implementations/tcube_laser/interface_methods.jl @@ -524,7 +524,7 @@ output_enabled(serialNo::AbstractString) = UInt32(LD_GetStatusBits(serialNo)) & Send a setpoint **with the output on** and wait for the controller to report it back within [`SETPOINT_READBACK_TOLERANCE`](@ref) codes, throwing if it does not. -Only call it with the output enabled: see [`SETPOINT_NEEDS_OUTPUT`](@ref). +Only call it with the output enabled: see [`send_setpoint`](@ref). Nothing is recorded by this function. """ function send_setpoint(light::TCubeLaser, code::UInt16) @@ -543,56 +543,59 @@ function send_setpoint(light::TCubeLaser, code::UInt16) end """ - RAMP_STEP_mW, RAMP_STEP_S - -An optional ramp for upward closed-loop setpoint steps, **off by default** -(`RAMP_STEP_mW[] = Inf`: every setpoint is a single write, ~10 ms). Set -`RAMP_STEP_mW[]` to a finite value in mW and [`send_setpoint_ramped`](@ref) -walks any larger upward step in increments of that size, `RAMP_STEP_S[]` -apart. Downward steps and open loop are always sent directly. - -Why it exists, for the record (642 nm rig's TLD001, 2026-09-28/29): on one -night a setpoint jumped from 0 to 10 mW locked the loop at ~90 mA / ~21 mW -whatever the request, 3 times out of 3, while the same target reached in steps -settled at 78 mA / 9.3 mW, as the Kinesis application does. Later the same -night, after a Kinesis CONST P session, the jump worked 4 times out of 4 -(9.30-9.31 mW). Ramped runs never failed (10 of 10, 10-70 mW). The cause is -not known. The jump is the default because it is the fastest and was reliable -when last tested; if the lock recurs (the symptom: ~90 mA and ~21 mW whatever -is requested) set `RAMP_STEP_mW[] = 3.0` and `RAMP_STEP_S[] = 0.01`, which -were verified on the rig: 1 mW steps worked at 2 s, 50, 20, 10 and 5 ms -spacing; below ~10 ms the USB round trip per write (~15 ms) sets the pace; -3 mW steps at 10 ms reached 40 mW in 0.22 s (38.74 mW) and 70 mW in 0.38 s -(68.59 mW). -""" -const RAMP_STEP_mW = Ref(Inf) - -"See [`RAMP_STEP_mW`](@ref)." -const RAMP_STEP_S = Ref(0.01) + send_setpoint_ramped(light::TCubeLaser, code::UInt16, from::Integer) +Send a setpoint with the output on. In `ConstantPhotocurrent` mode, if +`pd.ramp_step_mW` is finite (see [`PhotodiodeLoop`](@ref), which also records +why the ramp exists) an upward step of more than that many mW is walked up from +`from`, one step every `pd.ramp_step_s`; the final code is confirmed by +[`send_setpoint`](@ref). `from` is what the DRIVER knows the controller holds +(`light_on` passes 0; `setoutputpower!` passes the code of its previous request), +never `LD_GetLaserSetPoint`, which returns a stale word right after an enable +(65530 on the rig). Downward steps, `ramp_step_mW = Inf` and open loop are one +write, and open loop ignores `from`. """ - send_setpoint_ramped(light::TCubeLaser, code::UInt16) - -Send a setpoint with the output on. In `ConstantPhotocurrent` mode an upward -step larger than [`RAMP_STEP_mW`](@ref) is walked up from the setpoint the -controller currently holds, one step every `RAMP_STEP_S`; the final code is -confirmed by [`send_setpoint`](@ref). -""" -send_setpoint_ramped(light::TCubeLaser{ConstantCurrent}, code::UInt16) = send_setpoint(light, code) -function send_setpoint_ramped(light::TCubeLaser{ConstantPhotocurrent}, code::UInt16) +send_setpoint_ramped(light::TCubeLaser{ConstantCurrent}, code::UInt16, from::Integer) = send_setpoint(light, code) +function send_setpoint_ramped(light::TCubeLaser{ConstantPhotocurrent}, code::UInt16, from::Integer) serialNo, pd = light.serialNo, light.pd - isfinite(RAMP_STEP_mW[]) || return send_setpoint(light, code) # ramp off: one write - start = Int(LD_GetLaserSetPoint(serialNo)) - step = max(1, round(Int, RAMP_STEP_mW[] / 1000 / pd.wa_calibration / pd.tia_range * light.max_setpoint)) - if 0 <= start && Int(code) - start > step - for c in (start + step):step:(Int(code) - 1) + isfinite(pd.ramp_step_mW) || return send_setpoint(light, code) # ramp off: one write + step = max(1, round(Int, pd.ramp_step_mW / 1000 / pd.wa_calibration / pd.tia_range * light.max_setpoint)) + if 0 <= from && Int(code) - from > step + for c in (from + step):step:(Int(code) - 1) check_err(LD_SetLaserSetPoint(serialNo, UInt16(c)), "LD_SetLaserSetPoint", serialNo) - sleep(RAMP_STEP_S[]) + sleep(pd.ramp_step_s) end end send_setpoint(light, code) end +""" + check_lock(light::TCubeLaser{ConstantPhotocurrent}, code) + +After a setpoint, wait `pd.lock_check_s` and compare the measured photocurrent +([`measured_photocurrent`](@ref)) with the one `code` requests. If `code > 0` +and the measurement exceeds `pd.lock_ratio` times the request, throw: the loop +has probably locked at a high current whatever is requested (the failure the +ramp works around; see [`PhotodiodeLoop`](@ref)). It never disables the output +by itself; its callers do. A no-op in open loop. + +`[limitation]` the threshold and the wait are unvalidated on hardware (one +night's lock measured about 98 uA for 44.6 uA requested, 2.2x) and need the +642 nm rig check. A false trip refuses, which is the safe direction. +""" +check_lock(light::TCubeLaser{ConstantCurrent}, code) = nothing +function check_lock(light::TCubeLaser{ConstantPhotocurrent}, code) + pd = light.pd + sleep(pd.lock_check_s) + measured = measured_photocurrent(light) + requested = photocurrent_from_code(light, code, pd.tia_range) + (code > 0 && measured > pd.lock_ratio * requested) && error( + "TCubeLaser $(light.serialNo): loop lock suspected: the photodiode reads $(measured) A for a request of $(requested) A " * + "(ratio $(measured / requested), limit $(pd.lock_ratio)). The output should be treated as running away from its setpoint. " * + "Construct the laser with the ramp (ramp_step_mW = 3.0) and try again.") + return nothing +end + """ intended_code(light::TCubeLaser) @@ -773,7 +776,7 @@ end Enable the controller's output, then immediately send the setpoint the driver's recorded request asks for ([`intended_code`](@ref)) and confirm it, then record `properties.is_on`. The setpoint has to follow the enable: the controller -ignores setpoints while its output is off ([`SETPOINT_NEEDS_OUTPUT`](@ref)). +ignores setpoints while its output is off ([`send_setpoint`](@ref)). Called while the output is already on, this re-sends the recorded request, replacing any lower value set from Kinesis or the front panel. If nothing has been requested yet, setpoint 0 is sent, with a warning. In `ConstantCurrent` @@ -784,7 +787,10 @@ If the setpoint cannot be sent or confirmed after the enable, the output is disabled again and the original error rethrown. `properties.is_on` is then what the cleanup left: `false` if the disable succeeded, `true` if it failed too (both failures are logged), so a failed `light_on` never records a lit diode as -off. +off. In `ConstantPhotocurrent` mode the setpoint is ramped from 0 when the laser +was built with a finite `ramp_step_mW`, and [`check_lock`](@ref) then runs; a +suspected loop lock is a failure after the enable like any other, so the output +is disabled and the error rethrown. `[limitation]` Between the enable and the setpoint the controller runs on its stored setpoint, bounded in hardware only by its current-limit potentiometer; @@ -807,7 +813,8 @@ function LightSourceInterface.light_on(light::TCubeLaser) code = intended_code(light) check_err(LD_EnableOutput(serialNo), "LD_EnableOutput", serialNo) try - send_setpoint_ramped(light, code) + send_setpoint_ramped(light, code, 0) # the driver zeroes before every disable + check_lock(light, code) catch disabled, offerr = false, nothing try @@ -860,7 +867,7 @@ before anything reaches the controller, and encodes through exceeds the requested one. With the output **on**, the setpoint is sent and confirmed from the controller's read-back. With the output **off**, it is recorded and `light_on` applies it right after enabling -- the controller would -ignore it now ([`SETPOINT_NEEDS_OUTPUT`](@ref)). +ignore it now ([`send_setpoint`](@ref)). # The bottom of the range commands nothing @@ -906,9 +913,17 @@ Command the optical power at the laser output, in mW -- the plane where 4. Convert: photocurrent `= power_mW / 1000 / wa_calibration` A, encoded by [`photocurrent_code`](@ref), rounding DOWN; above full scale it throws naming the DIP switch. -5. With the output on, send and confirm the setpoint; with it off, leave it for - `light_on` ([`SETPOINT_NEEDS_OUTPUT`](@ref)). -6. Record `pd.output_power_requested` and the DECODED `pd.photocurrent_requested`. +5. With the output on, send and confirm the setpoint + ([`send_setpoint_ramped`](@ref), ramping from the code of the previous + request, `0` if none); with it off, leave it for `light_on` + ([`send_setpoint`](@ref)). +6. With the output on, [`check_lock`](@ref) after sending. If the loop looks + locked, zero and disable the output (a failure of that is logged and does + not mask the lock error), then throw; the request is not recorded. +7. Record `pd.output_power_requested` and the DECODED `pd.photocurrent_requested`. + +`[limitation]` the lock check's threshold and wait are unvalidated on hardware +(see [`check_lock`](@ref)). It never touches `drive_current`: the loop owns the current. Whether the light at the output matches is a question for a power meter; see @@ -931,7 +946,21 @@ function LightSourceInterface.setoutputpower!(light::TCubeLaser{ConstantPhotocur (bits & STATUS_BITS.tia_under != 0 && on) && @warn( "TCubeLaser $serialNo: the photodiode amplifier reports UNDER range with the output on; the photocurrent is small for the selected range and its resolution is reduced") code = photocurrent_code(light, power_mW / 1000 / pd.wa_calibration) - on && send_setpoint_ramped(light, code) + if on + # What the controller holds is the previous request (read BEFORE `pd` is updated). + from = isnan(pd.photocurrent_requested) ? 0 : Int(photocurrent_code(light, pd.photocurrent_requested)) + send_setpoint_ramped(light, code, from) + try + check_lock(light, code) + catch + try + zero_then_disable(light) + catch offerr + @error "TCubeLaser $serialNo: loop lock suspected and the disable failed; the output may still be ON" exception = offerr + end + rethrow() + end + end pd.output_power_requested = power_mW pd.photocurrent_requested = photocurrent_from_code(light, code, pd.tia_range) println("Laser output power set to $power_mW mW (photocurrent setpoint $(pd.photocurrent_requested) A)", diff --git a/src/hardware_implementations/tcube_laser/types.jl b/src/hardware_implementations/tcube_laser/types.jl index 1b33b48..ca1c11b 100644 --- a/src/hardware_implementations/tcube_laser/types.jl +++ b/src/hardware_implementations/tcube_laser/types.jl @@ -210,6 +210,10 @@ function TCubeLaser(serialNo::String; wa_calibration::Union{Nothing,Real}=nothing, tia_range::Union{Nothing,Real}=nothing, tec_stabilised::Union{Nothing,Bool,Missing}=nothing, + ramp_step_mW::Union{Nothing,Real}=nothing, + ramp_step_s::Union{Nothing,Real}=nothing, + lock_check_s::Union{Nothing,Real}=nothing, + lock_ratio::Union{Nothing,Real}=nothing, ) name = "TCubeLaser $serialNo" pd = if mode isa ConstantPhotocurrent @@ -221,10 +225,14 @@ function TCubeLaser(serialNo::String; "wa_calibration is W/A measured with a power meter at the laser output; tia_range is the rear-panel DIP switch in A; " * "tec_stabilised is true, false or missing; properties carries the enforced min_power/max_power in mW; " * "max_current is the clamp initialize programs into the controller, the only real protection in closed loop.")) - PhotodiodeLoop(; wa_calibration=wa_calibration, tia_range=tia_range, tec_stabilised=tec_stabilised) + loop_kw = (; (kw => v for (kw, v) in (:ramp_step_mW => ramp_step_mW, :ramp_step_s => ramp_step_s, + :lock_check_s => lock_check_s, :lock_ratio => lock_ratio) if v !== nothing)...) + PhotodiodeLoop(; wa_calibration=wa_calibration, tia_range=tia_range, tec_stabilised=tec_stabilised, loop_kw...) else given = [kw for (kw, v) in (:wa_calibration => wa_calibration, :tia_range => tia_range, - :tec_stabilised => tec_stabilised) if v !== nothing] + :tec_stabilised => tec_stabilised, :ramp_step_mW => ramp_step_mW, + :ramp_step_s => ramp_step_s, :lock_check_s => lock_check_s, + :lock_ratio => lock_ratio) if v !== nothing] isempty(given) || throw(ArgumentError( "$name: $(join(given, ", ")) describe a photodiode loop, which only a ConstantPhotocurrent laser has; " * "this one is $(nameof(typeof(mode)))")) diff --git a/src/hardware_interfaces/lightsource_interface/interface_types.jl b/src/hardware_interfaces/lightsource_interface/interface_types.jl index 20901ac..63e0ba6 100644 --- a/src/hardware_interfaces/lightsource_interface/interface_types.jl +++ b/src/hardware_interfaces/lightsource_interface/interface_types.jl @@ -112,8 +112,32 @@ delivered power drifts while photocurrent is held steady. That is why - `photocurrent_requested::Float64`: A. The DECODED setpoint the loop was actually given, which the downward-rounding encoding makes differ from `output_power_requested / wa_calibration`. `NaN` before the first command. - -Construct it with the keyword form, which fills the last three fields. +- `ramp_step_mW::Float64`: `Inf` (the default) sends every setpoint as one + write, ~10 ms. A finite value in mW makes the driver walk any larger upward + step in increments of that size, `ramp_step_s` apart; downward steps are + always one write. History (642 nm rig's TLD001, 2026-09-28/29): on one night + a setpoint jumped from 0 to 10 mW locked the loop at ~90 mA / ~21 mW whatever + the request, 3 times out of 3, while the same target reached in steps settled + at 78 mA / 9.3 mW, as the Kinesis application does. Later the same night, + after a Kinesis CONST P session, the jump worked 4 times out of 4 (9.30-9.31 + mW). Ramped runs never failed (10 of 10, 10-70 mW). The cause is not known. + The jump is the default because it is the fastest and was reliable when last + tested; if the lock recurs (the symptom: ~90 mA and ~21 mW whatever is + requested) construct the laser with `ramp_step_mW = 3.0, ramp_step_s = 0.01`, + which were verified on the rig: 1 mW steps worked at 2 s, 50, 20, 10 and 5 ms + spacing; below ~10 ms the USB round trip per write (~15 ms) sets the pace; 3 + mW steps at 10 ms reached 40 mW in 0.22 s (38.74 mW) and 70 mW in 0.38 s + (68.59 mW). +- `ramp_step_s::Float64`: seconds between ramp steps. Default `0.01`. +- `lock_check_s::Float64`: seconds the driver waits after sending a setpoint + before it compares the measured photocurrent with the request. Default `0.2`. +- `lock_ratio::Float64`: the measured photocurrent may exceed the request by + this factor before the driver reports a suspected loop lock. Default `1.5`. + `[limitation]` this threshold and wait are unvalidated on hardware: the one + lock observed measured about 98 uA for 44.6 uA requested (2.2x). A false trip + refuses, which is the safe direction. + +Construct it with the keyword form, which fills the last seven fields. """ mutable struct PhotodiodeLoop wa_calibration::Float64 @@ -122,20 +146,33 @@ mutable struct PhotodiodeLoop max_current_clamp::Float64 output_power_requested::Float64 photocurrent_requested::Float64 + ramp_step_mW::Float64 + ramp_step_s::Float64 + lock_check_s::Float64 + lock_ratio::Float64 end """ - PhotodiodeLoop(; wa_calibration, tia_range, tec_stabilised) + PhotodiodeLoop(; wa_calibration, tia_range, tec_stabilised, + ramp_step_mW=Inf, ramp_step_s=0.01, lock_check_s=0.2, lock_ratio=1.5) -All three are required, and none has a default: a power-mode laser cannot be -built without a measured calibration, a stated amplifier range and an answer -(possibly `missing`) to whether the diode's temperature is stabilised. +The first three are required, and none has a default: a power-mode laser cannot +be built without a measured calibration, a stated amplifier range and an answer +(possibly `missing`) to whether the diode's temperature is stabilised. The last +four are documented on [`PhotodiodeLoop`](@ref). """ -function PhotodiodeLoop(; wa_calibration::Real, tia_range::Real, tec_stabilised::Union{Bool,Missing}) +function PhotodiodeLoop(; wa_calibration::Real, tia_range::Real, tec_stabilised::Union{Bool,Missing}, + ramp_step_mW::Real=Inf, ramp_step_s::Real=0.01, + lock_check_s::Real=0.2, lock_ratio::Real=1.5) (isfinite(wa_calibration) && wa_calibration > 0) || throw(ArgumentError( "PhotodiodeLoop: wa_calibration is W/A measured at the laser output and must be finite and positive, got $(wa_calibration)")) (isfinite(tia_range) && tia_range > 0) || throw(ArgumentError( "PhotodiodeLoop: tia_range is the photodiode amplifier's full scale in A and must be finite and positive, got $(tia_range)")) - return PhotodiodeLoop(Float64(wa_calibration), Float64(tia_range), tec_stabilised, NaN, NaN, NaN) + ramp_step_mW > 0 || throw(ArgumentError("PhotodiodeLoop: ramp_step_mW must be positive (Inf for no ramp), got $(ramp_step_mW)")) + (isfinite(ramp_step_s) && ramp_step_s >= 0) || throw(ArgumentError("PhotodiodeLoop: ramp_step_s must be finite and non-negative, got $(ramp_step_s)")) + (isfinite(lock_check_s) && lock_check_s >= 0) || throw(ArgumentError("PhotodiodeLoop: lock_check_s must be finite and non-negative, got $(lock_check_s)")) + (isfinite(lock_ratio) && lock_ratio > 1) || throw(ArgumentError("PhotodiodeLoop: lock_ratio must be finite and above 1, got $(lock_ratio)")) + return PhotodiodeLoop(Float64(wa_calibration), Float64(tia_range), tec_stabilised, NaN, NaN, NaN, + Float64(ramp_step_mW), Float64(ramp_step_s), Float64(lock_check_s), Float64(lock_ratio)) end diff --git a/test/gui.jl b/test/gui.jl index 3a43ea3..f1c323c 100644 --- a/test/gui.jl +++ b/test/gui.jl @@ -141,7 +141,8 @@ MicroscopeControl.light_off(light::RecordingLight) = (push!(light.log, :light_of FakeKinesis.reset!() for laser in (TCubeLaser("00000000"; mode=ConstantCurrent()), TCubeLaser("00000000"; mode=ConstantPhotocurrent(), wa_calibration=224.2, - tia_range=1e-3, tec_stabilised=missing, properties=cp_props(), max_current=160.0)) + tia_range=1e-3, tec_stabilised=missing, properties=cp_props(), max_current=160.0, + lock_check_s=0.0)) gui(laser) GLMakie.closeall() end diff --git a/test/runtests.jl b/test/runtests.jl index 7a5a7a3..5f42cf7 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -127,7 +127,8 @@ lab_summary("Core") do cc(; kw...) = TCubeLaser("00000000"; mode=ConstantCurrent(), kw...) cp_props() = LightSourceProperties("mW", 0.0, false, 1.0, 70.0) cp(; kw...) = TCubeLaser("00000000"; mode=ConstantPhotocurrent(), wa_calibration=224.2, - tia_range=1e-3, tec_stabilised=missing, properties=cp_props(), max_current=160.0, kw...) + tia_range=1e-3, tec_stabilised=missing, properties=cp_props(), max_current=160.0, + lock_check_s=0.0, ramp_step_s=0.0, kw...) # no sleeping in the suite @testset "Constructor defaults" begin # Closed loop has no default clamp: it is the only real protection. @@ -657,6 +658,49 @@ lab_summary("Core") do @test isnan(laser.pd.max_current_clamp) end + @testset "the ramp starts from what the driver knows; lock detection (fake SDK)" begin + ready(; kw...) = (FakeKinesis.reset!(); FakeKinesis.limit_follows_pot[] = true; + l = cp(; kw...); initialize(l); l) + code_for(l, mW) = Int(TCube.photocurrent_code(l, mW / 1000 / 224.2)) + enabled() = FakeKinesis.bits[] & FakeKinesis.ENABLED != 0 + # (a) Measured 2x the request, output on: refused, and the output is turned off. + laser = ready() + setoutputpower!(laser, 10.0); light_on(laser) + FakeKinesis.photocurrent_raw[] = 2 * code_for(laser, 30.0) + @test_throws r"loop lock" setoutputpower!(laser, 30.0) + @test !enabled() + @test laser.properties.is_on == false + @test laser.pd.output_power_requested == 10.0 # the refused request is not recorded + # (b) The same through light_on. + laser = ready() + setoutputpower!(laser, 30.0) + FakeKinesis.photocurrent_raw[] = 2 * code_for(laser, 30.0) + @test_throws r"loop lock" light_on(laser) + @test !enabled() + @test laser.properties.is_on == false + # (d) 1.2x the request does not trip. + laser = ready() + setoutputpower!(laser, 30.0) + FakeKinesis.photocurrent_raw[] = round(Int, 1.2 * code_for(laser, 30.0)) + light_on(laser) + @test enabled() && laser.properties.is_on + # (c) With a finite ramp and the output on, a step up walks from the + # PREVIOUS request's code: neither 0 nor the stale 65530. + laser = ready(; ramp_step_mW=3.0) + setoutputpower!(laser, 10.0); light_on(laser) + FakeKinesis.photocurrent_raw[] = 0 + prev = code_for(laser, 10.0) + step = round(Int, 3.0 / 1000 / 224.2 / 1e-3 * 32767) + empty!(FakeKinesis.setpoints) + setoutputpower!(laser, 50.0) + @test prev < Int(first(FakeKinesis.setpoints)) <= prev + step + @test Int(last(FakeKinesis.setpoints)) == code_for(laser, 50.0) + # The ramp keywords are for closed loop only. + @test_throws ArgumentError cc(; ramp_step_mW=3.0) + @test_throws ArgumentError cc(; lock_ratio=2.0) + @test cp().pd.lock_ratio == 1.5 && cp().pd.ramp_step_mW == Inf + end + @testset "setoutputpower! (fake SDK)" begin # Refused before initialize: the clamp is not programmed, and it is # the only real protection in closed loop. @@ -692,35 +736,38 @@ lab_summary("Core") do light_on(laser) # The ramp is off by default (jumps were reliable on the rig when last # tested): enable, read the held setpoint, one write, confirmed. - @test isinf(TCube.RAMP_STEP_mW[]) - @test FakeKinesis.calls == [guard..., "LD_EnableOutput", "LD_SetLaserSetPoint", "LD_GetLaserSetPoint"] + @test isinf(laser.pd.ramp_step_mW) + @test FakeKinesis.calls == [guard..., "LD_EnableOutput", "LD_SetLaserSetPoint", "LD_GetLaserSetPoint", + "LD_GetPhotoCurrentReading"] # the last is check_lock's code = FakeKinesis.setpoints[end] @test code == UInt16(floor(i_pd / 1e-3 * 32767)) @test FakeKinesis.setpoint_held[] == code # With the ramp enabled (the safeguard verified on the rig), the same # 0 -> 50 mW is walked in ~3 mW steps: ~16 intermediate sends, then - # the final one, confirmed. + # the final one, confirmed. It starts from 0, not from a read-back. light_off(laser) - TCube.RAMP_STEP_mW[] = 3.0 + laser.pd.ramp_step_mW = 3.0 try empty!(FakeKinesis.calls); empty!(FakeKinesis.setpoints) light_on(laser) - @test FakeKinesis.calls[1:6] == [guard..., "LD_EnableOutput", "LD_GetLaserSetPoint"] - @test FakeKinesis.calls[end] == "LD_GetLaserSetPoint" - @test all(==("LD_SetLaserSetPoint"), FakeKinesis.calls[7:end-1]) + @test FakeKinesis.calls[1:5] == [guard..., "LD_EnableOutput"] + @test FakeKinesis.calls[end] == "LD_GetPhotoCurrentReading" + @test FakeKinesis.calls[end-1] == "LD_GetLaserSetPoint" + @test all(==("LD_SetLaserSetPoint"), FakeKinesis.calls[6:end-2]) @test FakeKinesis.setpoints[end] == code step = round(Int, 3.0 / 1000 / 224.2 / 1e-3 * 32767) @test 15 <= length(FakeKinesis.setpoints) <= 18 @test issorted(FakeKinesis.setpoints) @test all(d -> 0 < d <= step, diff(Int.(FakeKinesis.setpoints))) finally - TCube.RAMP_STEP_mW[] = Inf + laser.pd.ramp_step_mW = Inf end # With the output on, a new power is sent and confirmed at once (after # checking the photodiode is not reading 0x8000, over range). empty!(FakeKinesis.calls) setoutputpower!(laser, 50.0) - @test FakeKinesis.calls == [guard..., "LD_GetStatusBits", "LD_GetPhotoCurrentReading", "LD_SetLaserSetPoint", "LD_GetLaserSetPoint"] + @test FakeKinesis.calls == [guard..., "LD_GetStatusBits", "LD_GetPhotoCurrentReading", "LD_SetLaserSetPoint", "LD_GetLaserSetPoint", + "LD_GetPhotoCurrentReading"] # Downward steps are sent directly, no ramp. empty!(FakeKinesis.calls); empty!(FakeKinesis.setpoints) setoutputpower!(laser, 10.0) diff --git a/test/tcube_fake_sdk.jl b/test/tcube_fake_sdk.jl index b044ff0..204978c 100644 --- a/test/tcube_fake_sdk.jl +++ b/test/tcube_fake_sdk.jl @@ -304,4 +304,3 @@ end MicroscopeControl.HardwareImplementations.TCubeLaserControl.REQUEST_WAIT_S[] = 0.0 MicroscopeControl.HardwareImplementations.TCubeLaserControl.CLAMP_WAIT_S[] = 0.0 MicroscopeControl.HardwareImplementations.TCubeLaserControl.SETPOINT_CONFIRM_TIMEOUT_S[] = 0.05 -MicroscopeControl.HardwareImplementations.TCubeLaserControl.RAMP_STEP_S[] = 0.0 From c962c3cb2f09db1f45352b617f0f65109fd2fa16 Mon Sep 17 00:00:00 2001 From: kalidke Date: Tue, 29 Sep 2026 11:52:21 -0600 Subject: [PATCH 20/37] Diode laser panel: the output toggle's label follows the driver, not the click Co-Authored-By: Claude Sonnet 5.5 --- .../lightsource_interface/diode_laser_gui.jl | 24 +++++++++++++++++-- test/gui.jl | 24 +++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/src/hardware_interfaces/lightsource_interface/diode_laser_gui.jl b/src/hardware_interfaces/lightsource_interface/diode_laser_gui.jl index c087570..9ca1e10 100644 --- a/src/hardware_interfaces/lightsource_interface/diode_laser_gui.jl +++ b/src/hardware_interfaces/lightsource_interface/diode_laser_gui.jl @@ -136,18 +136,38 @@ end On/off toggle, initialised from `properties.is_on` so opening the panel does not impose a state. The slider's lowest position is not "off"; this is. + +The label follows the driver, not the click: after every command it shows +`properties.is_on`, whatever the command did, and if the toggle disagrees (a +failed `light_off` leaves the diode on) the toggle is put back, under a guard +that keeps the correction from firing `light_on`/`light_off` again. Returns +`(; toggle, status)`, the `Toggle` and the label's `Observable{String}`. """ function _attach_output_toggle!(fig, row, laser::DiodeLaser, message) + label(is_on) = is_on ? "output on" : "output off" toggle = Toggle(fig[row, 1], active=laser.properties.is_on, halign=:left) - Label(fig[row, 2], lift(x -> x ? "output on" : "output off", toggle.active)) + status = Observable(label(laser.properties.is_on)) + Label(fig[row, 2], status) + correcting = Ref(false) on(toggle.active) do x + correcting[] && return try x ? light_on(laser) : light_off(laser) catch e message.text = "refused: " * _panel_message(e) end + is_on = laser.properties.is_on + status[] = label(is_on) + if toggle.active[] != is_on + correcting[] = true + try + toggle.active[] = is_on + finally + correcting[] = false + end + end end - return toggle + return (; toggle, status) end function _show_panel(fig, laser::DiodeLaser) diff --git a/test/gui.jl b/test/gui.jl index f1c323c..d1f173f 100644 --- a/test/gui.jl +++ b/test/gui.jl @@ -148,4 +148,28 @@ MicroscopeControl.light_off(light::RecordingLight) = (push!(light.log, :light_of end @test isempty(FakeKinesis.calls) end + + # The toggle's label follows the driver's `is_on`, not the click: a + # `light_off` that fails leaves the diode on, and the panel must say so. + @testset "Output toggle reports the driver's state, not the click" begin + FakeKinesis.reset!() + laser = TCubeLaser("00000000"; mode=ConstantCurrent()) + initialize(laser) + light_on(laser) + fig = Figure() + message = Label(fig[1, 1], "") + toggle, status = MicroscopeControl.LightSourceInterface._attach_output_toggle!(fig, 2, laser, message) + @test toggle.active[] && status[] == "output on" + FakeKinesis.fail!("LD_DisableOutput") + toggle.active[] = false + @test laser.properties.is_on + @test status[] == "output on" + @test toggle.active[] + @test startswith(message.text[], "refused") + # A command that works is followed by the label. + delete!(FakeKinesis.status, "LD_DisableOutput") + toggle.active[] = false + @test !laser.properties.is_on && status[] == "output off" && !toggle.active[] + GLMakie.closeall() + end end From eabb0f85d3458c53650c6a2ceb2369142bd9bd3c Mon Sep 17 00:00:00 2001 From: kalidke Date: Tue, 29 Sep 2026 11:56:24 -0600 Subject: [PATCH 21/37] TCube docs: placeholder serials, cut the rig diary from CALIBRATION.md, 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 --- CHANGELOG.md | 42 ++++- skills/mc-extend/references/rig-causes.md | 2 +- .../references/driver-caveats.md | 2 +- .../tcube_laser/CALIBRATION.md | 158 +++--------------- .../tcube_laser/TCubeLaserControl.jl | 50 ------ .../tcube_laser/interface_methods.jl | 46 +++-- .../tcube_laser/types.jl | 14 +- 7 files changed, 88 insertions(+), 226 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0aff259..9d06412 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,11 @@ the next version with `-DEV`). ## [Unreleased] ### Fixed +- **Diode laser panel: the output toggle's label follows the driver, not the + click.** After a `light_on`/`light_off` that failed, the label and the toggle + showed the requested state while the diode was in the other; both now show + `properties.is_on`, and the toggle is put back without re-firing a command. + Not yet run on hardware (#66). - **PI stage: `initialize` no longer finishes on an unreferenced stage.** It ignored the return of the reference move (`PI_FRF`), so when the controller rejected it (GCS error 5, e.g. one axis's servo off) the later move to the @@ -53,8 +58,8 @@ Laser diodes in two regulation modes, and closed loop (photodiode feedback, Later the same night the jump worked at 10 mW (4 of 4), 40 and 70 mW, so the lock is intermittent and its cause unknown. The single write is the default (fastest, ~10 ms); an optional ramp of upward closed-loop steps is - kept in the driver as the verified fallback (`RAMP_STEP_mW[] = 3.0`, - `RAMP_STEP_S[] = 0.01`: 40 mW in ~0.2 s, 70 mW in ~0.4 s; ramped runs never + kept in the driver as the verified fallback (`ramp_step_mW = 3.0`, + `ramp_step_s = 0.01`, keywords of `TCubeLaser`: 40 mW in ~0.2 s, 70 mW in ~0.4 s; ramped runs never failed, 10 of 10). The photodiode's UNDER-range flag now warns instead of refusing (it was set at 1 mW while the loop regulated correctly). - **Found on the rig and fixed before release**, none of which the fake SDK @@ -97,18 +102,19 @@ default): ```julia # 0.2.4 line, still works -laser = TCubeLaser("64849775"; max_current = 160.0) +laser = TCubeLaser("00000000"; max_current = 150.0) # your diode's rating setpower(laser, 80.0) # mA, deprecated # open loop, with the unit in the name -laser = TCubeLaser("64849775"; mode = ConstantCurrent(), max_current = 160.0) # mode optional +laser = TCubeLaser("00000000"; mode = ConstantCurrent(), max_current = 150.0) # mode optional; max_current: your diode's rating setcurrent!(laser, 80.0) # mA # closed loop (calibration: src/hardware_implementations/tcube_laser/CALIBRATION.md) -laser = TCubeLaser("64849775"; mode = ConstantPhotocurrent(), +laser = TCubeLaser("00000000"; mode = ConstantPhotocurrent(), wa_calibration = 224.2, tia_range = 1e-3, tec_stabilised = missing, - threshold_current = 65.0, max_current = 160.0, - properties = LightSourceProperties("mW", 0.0, false, 1.0, 70.0)) + threshold_current = 65.0, + max_current = 150.0, # mA, your diode's rating + properties = LightSourceProperties("mW", 0.0, false, 1.0, 70.0)) # 70.0 mW: your diode's rating setoutputpower!(laser, 20.0) # mW at the laser output # either mode, when the unit does not matter: a fraction of the declared range @@ -116,6 +122,17 @@ setlevel!(laser, 0.4) ``` ### Added +- **`TCubeLaser` closed-loop loop-lock check and per-laser ramp keywords** + (#66): `ramp_step_mW` (default `Inf`, one write), `ramp_step_s` (`0.01`), + `lock_check_s` (`0.2`) and `lock_ratio` (`1.5`) are keywords of the + `ConstantPhotocurrent` `TCubeLaser` constructor and fields of + `PhotodiodeLoop`; they replace the `RAMP_STEP_mW` and `RAMP_STEP_S` globals, + which were never released. After a setpoint the driver now compares the + measured photocurrent with the request and, above `lock_ratio` times it, + disables the output and throws "loop lock suspected". The ramp starts from + what the driver knows the controller holds (0 in `light_on`, the previous + request in `setoutputpower!`), not from the stale `LD_GetLaserSetPoint`. + The threshold and wait are not yet run on hardware. - **`DiodeLaser <: LightSource`**, for lasers on a controller that regulates something, and the mode types **`ConstantCurrent`** (the loop holds drive current) and **`ConstantPhotocurrent`** (the loop holds the monitor photodiode @@ -179,6 +196,17 @@ setlevel!(laser, 0.4) range, measured before the fibre, with its closed-loop verification table). ### Changed +- **`TCubeLaser.initialize` now zeroes and disables the output** in both modes, + before the mode command, so it never sends a mode command with the diode lit + and `properties.is_on` is false afterwards. Not yet run on hardware. +- **Open loop: `initialize` lowers the controller's max-current potentiometer + when its limit is above `max_current`**, and never raises it. Not yet run on + hardware in open loop. +- **Power mode re-checks the controller before emitting**: closed-loop + `light_on` and `setoutputpower!` read the status word and the current limit + afresh and refuse if the loop bit is gone or the limit is above the + programmed clamp (a controller power cycle can restore the pot); two more + request/read round trips per call. Not yet run on hardware. - `setpower(laser, mA)` on a `DiodeLaser`: on a `ConstantCurrent` laser it forwards to `setcurrent!` with a deprecation warning; on a `ConstantPhotocurrent` laser it throws, naming `setoutputpower!` and diff --git a/skills/mc-extend/references/rig-causes.md b/skills/mc-extend/references/rig-causes.md index 7c313d1..0896393 100644 --- a/skills/mc-extend/references/rig-causes.md +++ b/skills/mc-extend/references/rig-causes.md @@ -22,7 +22,7 @@ and each looks like a broken `ccall`. | `initialize` in power mode throws "the controller's photodiode range is X A but tia_range states Y A" or "reports no single photodiode range" | The rear-panel TIA DIP switch does not match the `tia_range` keyword. **[guarantee]** refused because the W/A calibration is valid only on the range it was measured on. | Set the switch to the calibrated range (`10e-6`, `100e-6`, `1e-3` or `10e-3` A); do not just edit `tia_range` unless you recalibrate on the new range. | | `initialize` in power mode throws about the max-current potentiometer or clamp | **[guarantee]** `initialize` leaves the max-current potentiometer at the highest position whose limit, **as the controller reports it**, is `<= max_current`, and records that reported limit in `pd.max_current_clamp`. Hardware-verified on the 642 nm rig (2026-09-28): a plain pot write is ignored, adjust mode is required with a pause before leaving it, and the header's position-to-mA scale is wrong for that unit, so no clamp value is computed from a position. The pot position does not survive a controller power cycle. | Report it with the message, the pinned tag and the controller firmware; until then run `ConstantCurrent`. `light_on`/`setoutputpower!` refuse while `laser.pd.max_current_clamp` is `NaN`. | | `light_on(::TCubeLaser)` drives more current than `setpower` asked for, up to the controller's limit | **[fixed in v0.2.4]** The TLD001 ignores a setpoint sent while its output is off and, at the next enable, runs on the setpoint it had stored; before v0.2.4 `setpower` followed by `light_on` ran at that stale value (seen on a rig at the ~160 mA limit). From v0.2.4 `light_on` sends the setpoint right after enabling and `light_off`/`shutdown` zero it before disabling. A separate cause with the same symptom: a control source that includes the front-panel potentiometer (`LD_GetControlSource` reading 5) makes the diode follow the knob whatever the setpoint. | On an older tag, call `light_on` before `setpower`, and `setpower(l, 0.0)` before `light_off`. If the current still ignores the setpoint while the output is on, turn the front-panel knob fully down: that is the potentiometer, not the driver. | -| Power mode holds about 21 mW (~90 mA, `loop_status().photocurrent_A` pinned near 98 µA) whatever is requested | The TLD001 locks its loop when the setpoint is JUMPED up from a low value; observed 3 of 3 times at 10 mW on the 642 nm rig (2026-09-28/29), while the same target reached in steps regulated exactly (9.30 mW measured, as Kinesis). **[limitation]** the lock is intermittent (later the same night the jump worked at 10 mW 4 of 4 times, and at 40 and 70 mW) and its mechanism unknown. **[guarantee]** since 0.2.5 the driver sends a single write by default and carries an optional ramp of upward closed-loop steps, verified at 10-70 mW (10 of 10 ramped runs): `RAMP_STEP_mW[] = 3.0`, `RAMP_STEP_S[] = 0.01` (40 mW in ~0.2 s). | If it recurs, turn the ramp on (see the driver docstring) and report the run with the pinned tag. | +| Power mode holds about 21 mW (~90 mA, `loop_status().photocurrent_A` pinned near 98 µA) whatever is requested | The TLD001 locks its loop when the setpoint is JUMPED up from a low value; observed 3 of 3 times at 10 mW on the 642 nm rig (2026-09-28/29), while the same target reached in steps regulated exactly (9.30 mW measured, as Kinesis). **[limitation]** the lock is intermittent (later the same night the jump worked at 10 mW 4 of 4 times, and at 40 and 70 mW) and its mechanism unknown. **[guarantee]** since 0.2.5 the driver sends a single write by default and carries an optional ramp of upward closed-loop steps, verified at 10-70 mW (10 of 10 ramped runs): construct the laser with `ramp_step_mW = 3.0, ramp_step_s = 0.01` (40 mW in ~0.2 s); the driver also refuses, and disables the output, when the measured photocurrent exceeds `lock_ratio` (1.5) times the request (not yet run on hardware). | If it recurs, turn the ramp on (see the driver docstring) and report the run with the pinned tag. | | `loop_status(laser).saturated` is `true` in power mode | The drive current is at the clamp, so the power is not being held: a blocked or misaligned monitor photodiode, or a requested power the diode cannot reach under the clamp. **[policy]** treat a sustained `saturated` as a fault in unattended runs. | Check `measured_current` against `pd.max_current_clamp`; unblock the photodiode, lower the request, or (if the rig allows) raise `max_current`. `below_threshold === true` is a different fault: the diode is not lasing. | | `initialize(::TCubeLaser)` fails at `LD_Open` with Thorlabs error code 2 right after the Kinesis application used the controller (or Kinesis reports "load device failed" right after this driver did) | **[limitation]** observed on the 642 nm rig (2026-09-28/29): the TLD001 is not handed over cleanly between the Kinesis application and this DLL in either direction, even with the other side closed and no process holding it. | Power-cycle the TLD001 (off, 10 s, on) before switching between Kinesis and MicroscopeControl. | | `initialize(::TCubeLaser)` fails once, then every retry fails at `LD_Open` | **[fixed in v0.2.3]** a failure after a successful `LD_Open` — a mode change, a readings request — threw without closing, and a controller left open refuses the next `LD_Open`, so the first failure poisoned the retry. From v0.2.3 the handle is closed on the way out and the original error is the one raised. | On an older pinned tag, call `TCubeLaserControl.LD_Close(laser.serialNo)` before retrying, or power-cycle the cube. | diff --git a/skills/mc-system-design/references/driver-caveats.md b/skills/mc-system-design/references/driver-caveats.md index 0ed6d14..4378d51 100644 --- a/skills/mc-system-design/references/driver-caveats.md +++ b/skills/mc-system-design/references/driver-caveats.md @@ -45,7 +45,7 @@ The runtime constraints come from object ownership: |---|---|---|---| | `LCC1620` | `scope::Triggerscope4` | keyword `scope=`; defaults to a fresh `Triggerscope4()` on `COM3` | construct the scope first if you want to share it. `initialize(att)` calls `initialize(att.scope)` itself when the scope's port is not open, then sets the DAC range and drives to `min_voltage`. `shutdown(att)` drives the channel to `min_voltage` (0 V, which is **full transmission** on the LCC1620) and leaves the port open. | | `XEM` (module `OK_XEM`) | `daq::NIdaq` | keyword `daq=`; defaults to `NIdaq()` | none: `NIdaq` holds no connection (tasks are created and deleted per operation, `initialize`/`shutdown` are no-ops) | -| `TCubeLaser` | `daq::NIdaq` for the modulation channel | keyword `daq=` | none, as above. `TCubeLaserControl.setupIO(laser)` creates the AO task and picks the **second** discovered device and its **second** AO channel unless you pass `daq_device=`/`ao_channel=` (those keywords, and a validating error message in place of a bare `BoundsError`, are v0.2.3). **[guarantee]** (0.2.5, traced) `initialize` in `ConstantPhotocurrent` mode is a protected sequence and never enables the output: it requires key switch and interlock; requires the controller's TIA range bits to match `tia_range`; disables the output (it cannot zero the setpoint: the TLD001 ignores setpoints while the output is off, so `light_on` sends the setpoint right after enabling and `light_off` zeroes it before disabling); leaves the max-current potentiometer at the highest position whose controller-reported limit is `<= max_current` and records that limit in `pd.max_current_clamp`; enters closed loop and verifies the status bit; writes and reads back the W/A factor. Any failed step throws and closes the handle. `light_on` and `setoutputpower!` refuse until the clamp is verified. Both modes start Kinesis polling (50 ms), so the readbacks are cache reads. Hardware-verified on the 642 nm rig (2026-09-28, output off and on): polling refreshes the setpoint cache, the pot needs adjust mode, setpoints only take with the output on. Closed loop verified at 1-70 mW with single-write setpoints; an optional ramp (`RAMP_STEP_mW[]`) is kept because a jump from 0 once locked the controller's loop at ~21 mW, intermittently; see `mc-extend/references/rig-causes.md` | +| `TCubeLaser` | `daq::NIdaq` for the modulation channel | keyword `daq=` | none, as above. `TCubeLaserControl.setupIO(laser)` creates the AO task and picks the **second** discovered device and its **second** AO channel unless you pass `daq_device=`/`ao_channel=` (those keywords, and a validating error message in place of a bare `BoundsError`, are v0.2.3). **[guarantee]** (0.2.5, traced) `initialize` in `ConstantPhotocurrent` mode is a protected sequence and never enables the output: it requires key switch and interlock; requires the controller's TIA range bits to match `tia_range`; zeroes the setpoint and disables the output first, in both modes and before the mode command (the TLD001 ignores setpoints while the output is off, so `light_on` sends the setpoint right after enabling and `light_off` zeroes it before disabling); leaves the max-current potentiometer at the highest position whose controller-reported limit is `<= max_current` and records that limit in `pd.max_current_clamp`; enters closed loop and verifies the status bit; writes and reads back the W/A factor. Any failed step throws and closes the handle. `light_on` and `setoutputpower!` refuse until the clamp is verified, and re-read the closed-loop bit and the controller's limit before emitting (not yet run on hardware). Both modes start Kinesis polling (50 ms), so the readbacks are cache reads. Hardware-verified on the 642 nm rig (2026-09-28, output off and on): polling refreshes the setpoint cache, the pot needs adjust mode, setpoints only take with the output on. Closed loop verified at 1-70 mW with single-write setpoints; an optional ramp (`RAMP_STEP_mW[]`) is kept because a jump from 0 once locked the controller's loop at ~21 mW, intermittently; see `mc-extend/references/rig-causes.md` | | `CrystaLaser`, `VortranLaser`, `DaqTrLight` | `daq::NIdaq` | **built internally**; no keyword, cannot be shared | none; each constructor does its own discovery (see side effects) | Because `NIdaq` is a stateless handle, several devices each holding their own diff --git a/src/hardware_implementations/tcube_laser/CALIBRATION.md b/src/hardware_implementations/tcube_laser/CALIBRATION.md index c6a4852..b187063 100644 --- a/src/hardware_implementations/tcube_laser/CALIBRATION.md +++ b/src/hardware_implementations/tcube_laser/CALIBRATION.md @@ -57,14 +57,14 @@ Constructing the laser with it: ```julia using MicroscopeControl -laser = TCubeLaser("64849775"; +laser = TCubeLaser("00000000"; mode = ConstantPhotocurrent(), wa_calibration = 224.2, # W/A, this document tia_range = 1e-3, # A, the rear-panel DIP switch tec_stabilised = missing, # true / false once known; `missing` is honest until then threshold_current = 65.0, # mA - max_current = 150.0, # mA: programmed into the controller as the loop's clamp (see "The 642 nm rig's ceiling") - properties = LightSourceProperties("mW", 0.0, false, 1.0, 70.0)) # [1 mW, 70 mW] + max_current = 150.0, # mA, your diode's rating: programmed into the controller as the loop's clamp + properties = LightSourceProperties("mW", 0.0, false, 1.0, 70.0)) # [1 mW, 70 mW]: the 70 is max_power, your diode's rating initialize(laser) # checks key, interlock and the DIP switch; programs the clamp; never emits setoutputpower!(laser, 20.0) # mW at the laser output @@ -72,97 +72,23 @@ light_on(laser) loop_status(laser) # photocurrent, drive current, saturated / TIA flags ``` -## Hardware check, 2026-09-28 (642 nm rig, this driver) - -Power meter before the fibre, read by Ali Kazemi Nasaban Shotorban; 30 s (open -loop) or 20 s (closed loop) per point. - -Open loop (`ConstantCurrent`), setpoint sent after the output is enabled: - -| setpoint | front display | measured | photodiode x 224.2 W/A | -|---:|---:|---:|---:| -| 70 mA | 70.0 | 1.687 mW | 2.2 mW | -| 90 mA | 90.0 | 20.67 mW | 21.4 mW | -| 110 mA | 110.0 | 39.42 mW | over range (`0x8000`) | - -Threshold about 68 mA, slope about 0.94 mW/mA. - -Closed loop (`ConstantPhotocurrent`, 224.2 W/A, 1 mA range): - -| requested | measured | photodiode held at | drive current | -|---:|---:|---:|---:| -| 1 mW | 0.561 mW | 4.46 µA (= setpoint) | 68.5 mA | -| 2 mW | 1.554 mW | 8.91 µA (= setpoint) | 69.8 mA | -| 3 mW | 2.543 mW | 13.37 µA (= setpoint) | 70.7 mA | -| 5 mW | 4.517 mW | 22.28 µA (= setpoint) | 72.8 mA | -| 5 mW (first attempt) | 21.43 mW | 98.09 µA (stuck) | 90.8 mA | -| 10 mW | 21.42 mW | 98.09 µA (stuck) | 90.8 mA | - -Where the loop regulated, measured = requested - 0.46 mW with a slope of 0.99: -**the 224.2 W/A calibration still holds.** The 5 and 10 mW failures were -diagnosed the next day (below): not the photodiode channel, but a **jump of -the setpoint from 0**, which locks the loop at ~21 mW (~90 mA, photocurrent -pinned near 98 µA) whatever the request. The Kinesis application at 10 mW -gave 9.30 mW / 77.9 mA / 44.56 µA, i.e. exactly the driver's setpoint; the -difference was only how the setpoint is reached. - -### 2026-09-29: the ramp, and closed loop verified to 40 mW - -Re-checked after the manual's PD range / gain procedure (1 mA range confirmed -"In Range"; 386 µA at the 160 mA limit in Kinesis; gain re-optimised). The -driver now **ramps** upward closed-loop steps ([`RAMP_STEP_mW`] = 3 mW every -[`RAMP_STEP_S`] = 10 ms, i.e. 40 mW in about 0.2 s): - -| requested | reached by | measured | drive current | -|---:|---|---:|---:| -| 10 mW | jump from 0 (before the fix) | 21.38 mW | 90.4 mA | -| 10 mW | 1 mW steps, 2 s apart | 9.31 mW | 77 mA | -| 10 mW | 1 mW steps, 50 / 20 / 10 / 5 ms apart | 9.29 / 9.31 / 9.30 / 9.31 mW | 77.7 mA | -| 20 mW | 1 mW steps, 50 ms | 19.04 mW | 88.2 mA | -| 40 mW | 1 mW steps, 50 ms | 38.79 mW | 109.1 mA | -| 40 mW | **3 mW steps, 10 ms (shipped default)** | **38.74 mW** | 109.3 mA | -| 70 mW | 3 mW steps, 10 ms (0.38 s) | 68.59 mW | 141.4 mA | -| 10 mW | jump from 0, later the same night, 4 runs | 9.30 / 9.31 / 9.30 / 9.31 mW | 77.6-78.0 mA | - -Open loop the same day: 110 mA -> 39.43 mW, 130 mA -> 57.71 mW. - -How the Kinesis application reaches a power setpoint was not determined (the C -API has only `LD_SetLaserSetPoint`; the APT protocol document could not be -fetched and the application was not traced with a USB capture). The ramp's -time is almost all USB round trips, about 15 ms per write, so 5 ms pauses were -no faster than 10 ms; a USB trace of Kinesis (Wireshark + USBPcap while it -sets 10 mW) is the way to find out whether a single write can work. - -Two practical notes from the session: the controller must be **power-cycled -when switching between the Kinesis application and this driver**, in either -direction (`LD_Open` error 2, or "load device failed" in Kinesis, until then); -and the max-current potentiometer position does not survive a power cycle -(`initialize` re-programs it every time). - -Measured power is requested x 0.97 - ~0.3 mW over 1-70 mW (the loop holds the -photocurrent exactly; the meter reads a little under 224.2 W/A's prediction). -Closed loop is therefore verified over the rig's range, and -`properties.max_power = 70.0` is supported. Note that 70 mW needs ~141 mA, -above the 132 mA the rig had used as its working ceiling; the clamp at -`max_current` is the hard limit (150 mA on the rig, see "The 642 nm rig's ceiling" below). - -**The lock is intermittent, and its cause is not known.** The jump from 0 to -10 mW failed 3 times out of 3 on 09-28/29 (before and after the gain -re-optimisation, before and after a power cycle), then, later on 09-29 after a -Kinesis CONST P session at 10 mW with the W/A factor persisted, worked 4 times -out of 4, and then also at 40 mW (controller readings identical to the ramped -run) and 70 mW (68.5 mW measured). Ramped runs have never failed (10 of 10, -10-70 mW). **The jump is the shipped default** (one write, ~10 ms) because it -is the fastest and was reliable when last tested; the ramp is kept in the -driver as a recorded, verified fallback: set `RAMP_STEP_mW[] = 3.0` and -`RAMP_STEP_S[] = 0.01` (40 mW in 0.22 s, 70 mW in 0.38 s). If the lock recurs -the symptom is unmistakable, ~90 mA and ~21 mW whatever the request; switch -the ramp on and report the run. - -Found on the same day, and fixed in the driver: the controller ignores a -setpoint sent while its output is off (it then runs on a stale stored setpoint, -which on this rig drove the diode to its ~160 mA limit, 85-88 mW), so the -driver only sends setpoints with the output on. +## Controller facts (TLD001) + +Kept from the session diary cut from this file (2026-09-28/29, the 642 nm +rig's TLD001, serial `64849775`). Facts the driver's docstrings already state +are marked with where. + +- A plain `LD_SetMaxCurrentDigPot` is ignored: adjust mode (`LD_EnableMaxCurrentAdjust`) and a pause before leaving it are needed, 2026-09-28 (driver: `set_digpot!`, `CLAMP_WAIT_S`). +- The header's potentiometer scale (`position * 220 / 255` mA) is wrong for this unit: position 204 gave 160.74 mA and 194 gave 152.43 mA, 2026-09-28 (driver: `DIGPOT_STEP_ESTIMATE_mA`). +- The potentiometer position does not survive a controller power cycle, 2026-09-29; `initialize` re-programs it every time and power-mode `light_on` re-checks it. +- The controller ignores a setpoint sent while its output is off and then runs on a stale stored setpoint (on this rig the diode went to its ~160 mA limit); the setpoint read-back is stale with the output off (65530), 2026-09-28 (driver: `send_setpoint`). +- The photodiode UNDER-range flag was set at 1 mW (4.5 uA on the 1 mA range) while the loop regulated correctly, 2026-09-29; the driver warns and does not refuse. +- The photocurrent reading is signed and `0x8000` means over range (seen at 110 mA open loop on the 1 mA range), 2026-09-28 (driver: `measured_photocurrent`). +- A setpoint jumped up from 0 can lock the loop at ~90 mA / ~21 mW / ~98 uA whatever is requested: 3 of 3 at 10 mW, then 4 of 4 without the lock after a Kinesis CONST P session; stepped setpoints never failed (10 of 10), 2026-09-28/29 (driver: `PhotodiodeLoop`'s `ramp_step_mW` field, `check_lock`). +- One USB write takes about 15 ms, so ramp pauses below ~10 ms do not go faster, 2026-09-29. +- The controller must be power-cycled when switching between the Kinesis application and this driver, in either direction (`LD_Open` error 2, or "load device failed" in Kinesis, until then), 2026-09-29. Recorded only here. +- How the Kinesis application reaches a power setpoint was not determined (the C API has only `LD_SetLaserSetPoint`); a USB trace (Wireshark + USBPcap while it sets 10 mW) would show whether a single write can work. + ## The photodiode range (DIP switch) and the TIA gain @@ -224,49 +150,7 @@ Done on the 642 nm rig on 2026-09-28: the 1 mA range showed "In Range" with 386 µA at the 160 mA limit (10 mA showed 383 µA, i.e. under range), and the gain was re-optimised and persisted. It did not change the light (110 mA gave 39.43 mW before and after); the ~98 µA "ceiling" seen that day turned out to -be the setpoint-jump lock described above, not the photodiode channel. - -## The 642 nm rig's ceiling: the diode's rating and the measurement plane - -The diode is an **Ushio HL6366DG** (642 nm): absolute maximum optical output -90 mW, rated 80 mW CW, threshold 80 mA typical / 95 max, operating current -155 mA typical at 80 mW, monitor current 0.1-0.3 mA at 80 mW (data sheet -HL6366DG/67DG Rev 0, 2014-10-28). This diode: threshold ~68 mA, 88 mW at the -160 mA limit at the usual meter position. - -All powers in this document and in the driver are **at the usual meter -position, before the fibre coupler but after a filter, two mirrors and two -dichroics**. Measured 2026-09-29 at 90 mA: 22.23 mW at the laser head (before -the filter) vs 20.67 mW at the usual position, so the path passes -**T = 0.93**, and power at the diode = reading / 0.93: - -| usual position | at the diode | -|---:|---:| -| 60 mW | 64.5 mW | -| 68.5 mW (the 70 mW run) | 73.7 mW | -| 74.4 mW | 80 mW, the rating | -| 83.7 mW | 90 mW, the absolute maximum | - -For the record: on 2026-09-29 the diode also ran for about 13 s at the 160 mA -limit (a test script that sent its setpoint before enabling the output, which -the controller ignores), roughly 93 mW at the diode, just over the absolute -maximum; and for about 100 s in total at 141 mA (74 mW at the diode) during -the 70 mW tests. - -Measured directly at the head in closed loop the same day: 70 mW requested -> -73.5 mW at the diode (141.6 mA), 72 mW requested -> 75.9 mW at the diode -(143.9 mA), consistent with T = 0.93. - -Rig settings chosen from this (the construction example above uses them): -`properties.max_power = 70.0` mW at the usual position, which is 73.5 mW -measured at the head and about 75 mW at the facet behind the collimating lens -(an AR-coated lens loses ~1-2 %; this one is not measurable), about 94 % of -the rating; and `max_current = 150.0` mA. The clamp is the loop's protection, -not its operating point: 70 mW needs ~142 mA, the extra ~8 mA is headroom for -the loop as the diode warms, and it stays under the 155 mA typical operating -current. A runaway with a blocked photodiode stops at the clamp, ~92 mW at the -diode, instead of the ~96 mW the previous 160 mA clamp allowed. Redo the head -measurement if anything in the path before the usual meter position changes. +be the setpoint-jump lock (see "Controller facts" above), not the photodiode channel. ## When to recalibrate diff --git a/src/hardware_implementations/tcube_laser/TCubeLaserControl.jl b/src/hardware_implementations/tcube_laser/TCubeLaserControl.jl index e0558df..e31e7a9 100644 --- a/src/hardware_implementations/tcube_laser/TCubeLaserControl.jl +++ b/src/hardware_implementations/tcube_laser/TCubeLaserControl.jl @@ -22,56 +22,6 @@ import ...MicroscopeControl.HardwareInterfaces.LightSourceInterface: effective_m const Thorlabs_Tcube_laser = "C:\\Program Files\\Thorlabs\\Kinesis\\Thorlabs.MotionControl.TCube.LaserDiode.dll" -# Closed-loop (constant power) calibration of the 642 nm rig: TLD001 serial -# 64849775, monitor photodiode on the 1 mA TIA range. Measured by Ali Kazemi Nasaban Shotorban -# with a power meter before the fiber (see the module docstring), and recorded -# in the deleted `helpers.jl` (Oct 2024, which drove the laser at include time -# and so could not be kept): -# -# W/A calibration factor 224.2 (LD_SetWACalibFactor) -# TIA range 1.0 mA (rear-panel DIP switch; read-only in software) -# -# Closed-loop entry and command sequence from `helpers.jl`: -# -# LD_SetClosedLoopMode(serialNo) -# LD_SetWACalibFactor(serialNo, 224.2) -# LD_SetLaserSetPoint(serialNo, UInt16(round(power_mW * 32767 / 224.2 / 1.0))) -# -# In closed loop the setpoint word is photocurrent, 0..32767 = 0..TIA full -# scale, so the conversions both ways are -# -# word = power_mW / calibration_W_per_A / TIA_range_mA * 32767 -# power_mW = raw / 32767 * TIA_range_mA * calibration_W_per_A -# -# where `raw` is the word `LD_GetPhotoCurrentReading` returns. Verification, in -# closed loop with those two factors: power commanded through the formula -# versus power measured before the fiber, in mW; single readings, no repeats -# or statistics: -# -# commanded measured -# 0.0 < 0.001 -# 10.0 9.31 -# 20.0 19.11 -# 30.0 29.11 -# 40.0 39.06 -# 50.0 48.97 -# 60.0 59.22 -# 70.0 69.65 -# 80.0 79.50 -# -# Measured power sits 0.35–1.03 mW below the command at every point: within -# 1.5 % from 60 mW up, 2–4.5 % at 20–50 mW, and 7 % at 10 mW. -# -# These numbers belong to this photodiode, this TIA range and this measurement -# plane: moving the DIP switch or replacing the diode invalidates them, and the -# driver cannot read the W/A factor's provenance back. Closed loop regulates the -# monitor photocurrent, so without a TEC-stabilised mount the delivered power -# can still drift with diode temperature. -# -# How to measure these numbers again, and how to use them to build the laser in -# closed loop (`mode = ConstantPhotocurrent()`, `setoutputpower!`), is in -# `CALIBRATION.md` beside this file. - include("constants_Tlaser.jl") include("functions_Tlaser.jl") include("types.jl") diff --git a/src/hardware_implementations/tcube_laser/interface_methods.jl b/src/hardware_implementations/tcube_laser/interface_methods.jl index 0c3a4c3..e540a15 100644 --- a/src/hardware_implementations/tcube_laser/interface_methods.jl +++ b/src/hardware_implementations/tcube_laser/interface_methods.jl @@ -62,8 +62,8 @@ current scaled linearly onto `properties.max_power`. **Deprecated, and scheduled for removal at the next breaking release** -- read `light.drive_current` instead. It is a guess, not a measurement. The controller reports no optical power in -the open-loop mode this driver uses, and the bench table preserved in -`TCubeLaserControl.jl` shows the real curve is not this line. It is reproduced +the open-loop mode this driver uses, and the bench table in `CALIBRATION.md` +shows the real curve is not this line. It is reproduced here only so that a rig reading `properties.power` across an upgrade reads the same number it read before. @@ -476,6 +476,25 @@ function read_status_fresh(serialNo::AbstractString) end """ + SETPOINT_CONFIRM_TIMEOUT_S, SETPOINT_READBACK_TOLERANCE + +How long [`send_setpoint`](@ref) waits for the polled setpoint to show the code +it sent, in seconds (a `Ref` so the test suite can shorten it), and how many +codes the read-back may differ by: the rig's TLD001 reported 10424 for 10425. +""" +const SETPOINT_CONFIRM_TIMEOUT_S = Ref(1.0) +const SETPOINT_READBACK_TOLERANCE = 2 + +"Whether the controller reports its output enabled (polled status word)." +output_enabled(serialNo::AbstractString) = UInt32(LD_GetStatusBits(serialNo)) & STATUS_BITS.output_enabled != 0 + +""" + send_setpoint(light::TCubeLaser, code::UInt16) + +Send a setpoint **with the output on** and wait for the controller to report it +back within [`SETPOINT_READBACK_TOLERANCE`](@ref) codes, throwing if it does not. +Only call it with the output enabled. Nothing is recorded by this function. + # The setpoint only takes while the output is ON Hardware-verified on the 642 nm rig's TLD001 (64849775, 2026-09-28), and the @@ -504,29 +523,6 @@ a `light_off` from this driver that is 0; on a controller last left by other software it may be anything up to full scale, bounded only by the current limit (the max-current clamp in power mode). """ -const SETPOINT_NEEDS_OUTPUT = true - -""" - SETPOINT_CONFIRM_TIMEOUT_S, SETPOINT_READBACK_TOLERANCE - -How long [`send_setpoint`](@ref) waits for the polled setpoint to show the code -it sent, in seconds (a `Ref` so the test suite can shorten it), and how many -codes the read-back may differ by: the rig's TLD001 reported 10424 for 10425. -""" -const SETPOINT_CONFIRM_TIMEOUT_S = Ref(1.0) -const SETPOINT_READBACK_TOLERANCE = 2 - -"Whether the controller reports its output enabled (polled status word)." -output_enabled(serialNo::AbstractString) = UInt32(LD_GetStatusBits(serialNo)) & STATUS_BITS.output_enabled != 0 - -""" - send_setpoint(light::TCubeLaser, code::UInt16) - -Send a setpoint **with the output on** and wait for the controller to report it -back within [`SETPOINT_READBACK_TOLERANCE`](@ref) codes, throwing if it does not. -Only call it with the output enabled: see [`send_setpoint`](@ref). -Nothing is recorded by this function. -""" function send_setpoint(light::TCubeLaser, code::UInt16) serialNo = light.serialNo check_err(LD_SetLaserSetPoint(serialNo, code), "LD_SetLaserSetPoint", serialNo) diff --git a/src/hardware_implementations/tcube_laser/types.jl b/src/hardware_implementations/tcube_laser/types.jl index ca1c11b..f423c1b 100644 --- a/src/hardware_implementations/tcube_laser/types.jl +++ b/src/hardware_implementations/tcube_laser/types.jl @@ -160,18 +160,18 @@ with `mode = ConstantPhotocurrent()`. The default will not change. ```julia # 642 nm rig, closed loop. Calibration: see CALIBRATION.md beside this file. -laser = TCubeLaser("64849775"; +laser = TCubeLaser("00000000"; mode = ConstantPhotocurrent(), wa_calibration = 224.2, # W/A, measured with a power meter before the fibre tia_range = 1e-3, # A: the rear-panel DIP switch, as set tec_stabilised = missing, # honest until checked threshold_current = 65.0, # mA, from a bench sweep - max_current = 150.0, # mA: programmed as the loop's clamp - properties = LightSourceProperties("mW", 0.0, false, 2.0, 80.0)) + max_current = 150.0, # mA, your diode's rating: programmed as the loop's clamp + properties = LightSourceProperties("mW", 0.0, false, 1.0, 70.0)) # max_power 70.0 mW: your diode's rating # The same diode in open loop. -laser = TCubeLaser("64849775"; mode = ConstantCurrent(), # mode is optional here: the default - min_current = 70.0, max_current = 160.0) +laser = TCubeLaser("00000000"; mode = ConstantCurrent(), # mode is optional here: the default + min_current = 70.0, max_current = 150.0) # max_current: your diode's rating ``` In `ConstantPhotocurrent` mode `wa_calibration`, `tia_range`, `tec_stabilised`, @@ -188,6 +188,10 @@ defaults to `LightSourceProperties("mA", 0.0, false, min_current, max_current)`. real protection while the loop raises the current by itself; `initialize` records the controller's own limit separately in `controller_max_current`, and `setcurrent!` enforces the smaller of the two. +- `ramp_step_mW`, `ramp_step_s`, `lock_check_s` and `lock_ratio` (closed loop + only; passing one in open loop throws) set the fields of the same names on + [`PhotodiodeLoop`](@ref), which documents them: an optional ramp of upward + setpoint steps and the loop-lock check. - `min_current` defaults to `0.0`. A non-zero default would reject safe small currents without protecting against large ones. """ From e36d3e04361191f15c56b186867300ab2cd6b39e Mon Sep 17 00:00:00 2001 From: kalidke Date: Tue, 29 Sep 2026 12:02:17 -0600 Subject: [PATCH 22/37] Power-mode clamp re-check catches a one-step pot drift; clamp recorded 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 --- .../tcube_laser/interface_methods.jl | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/hardware_implementations/tcube_laser/interface_methods.jl b/src/hardware_implementations/tcube_laser/interface_methods.jl index e540a15..a73e05a 100644 --- a/src/hardware_implementations/tcube_laser/interface_methods.jl +++ b/src/hardware_implementations/tcube_laser/interface_methods.jl @@ -748,8 +748,9 @@ function enter_mode!(::ConstantPhotocurrent, light::TCubeLaser) "Check the rear-panel DIP switch; the calibration is only valid on the range it was measured on.") # 3. the clamp, as the controller itself reports it (the output is off: - # `initialize` zeroed and disabled it before this) - pd.max_current_clamp = program_clamp!(light) + # `initialize` zeroed and disabled it before this). Recorded only once the + # whole sequence has succeeded, so a failure below leaves it NaN. + clamp = program_clamp!(light) # 4. closed loop, verified check_err(LD_SetClosedLoopMode(serialNo), "LD_SetClosedLoopMode", serialNo) @@ -763,6 +764,7 @@ function enter_mode!(::ConstantPhotocurrent, light::TCubeLaser) wa = Float64(LD_GetWACalibFactor(serialNo)) isapprox(wa, pd.wa_calibration; rtol=1e-6) || error( "$name: set the W/A calibration factor to $(pd.wa_calibration) but the controller reports $(wa)") + pd.max_current_clamp = clamp return nothing end @@ -845,8 +847,11 @@ function require_clamp(::ConstantPhotocurrent, light::TCubeLaser, op) bits & STATUS_BITS.closed_loop != 0 || error( "TCubeLaser $(light.serialNo): $op refused: the controller is not in closed loop (status 0x$(string(bits; base=16))); " * "was the mode changed on the front panel? Call initialize again.") + # The clamp is the highest pot position whose limit is <= max_current, so + # one step up already exceeds max_current; half a step (~0.4 mA) of slack + # above the recorded clamp catches that drift too. limit = read_limit_mA(light) - limit > light.pd.max_current_clamp + 1.0 && error( + (limit > light.max_current || limit > light.pd.max_current_clamp + 0.5) && error( "TCubeLaser $(light.serialNo): $op refused: the controller's max-current clamp reads $(limit) mA, above the $(light.pd.max_current_clamp) mA " * "that initialize programmed. The clamp may have been reset by a controller power cycle: call initialize again.") return nothing @@ -903,9 +908,10 @@ Command the optical power at the laser output, in mW -- the plane where request/read round trips (about 2 x `REQUEST_WAIT_S`) to every call; unvalidated on hardware. 2. [`check_power`](@ref) against `properties.min_power..max_power`. -3. Refuse from the (polled) status word: unless it reports closed loop; if the - photodiode amplifier is over range; or if it is under range while the output - is on. An under-range flag with the output off is expected and ignored. +3. Refuse from the (polled) status word unless it reports closed loop, or if the + photodiode amplifier is over range. An under-range flag with the output on + only warns (the resolution is reduced; the loop still regulates, as seen at + 1 mW on the 642 nm rig); with the output off it is expected and ignored. 4. Convert: photocurrent `= power_mW / 1000 / wa_calibration` A, encoded by [`photocurrent_code`](@ref), rounding DOWN; above full scale it throws naming the DIP switch. From 891193a733c549f28c33523c3b842c63acce2c58 Mon Sep 17 00:00:00 2001 From: kalidke Date: Tue, 29 Sep 2026 12:02:17 -0600 Subject: [PATCH 23/37] SimDiodeLaser: constructed exactly as TCubeLaser 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 --- CHANGELOG.md | 10 +++-- .../references/driver-caveats.md | 2 +- skills/mc-testing/SKILL.md | 2 +- .../SimulatedDiodeLaser.jl | 41 ++++++++++++------- test/gui.jl | 2 +- test/runtests.jl | 24 +++++++---- 6 files changed, 53 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d06412..bba8759 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -189,7 +189,9 @@ setlevel!(laser, 0.4) is shown in the panel instead of being thrown inside the event handler. - **`SimDiodeLaser{M}`**, a simulated twin on the same abstract type, with a diode, photodiode and loop model, faults (blocked photodiode, responsivity - drift, TIA flags, key/interlock) and a log of every command and read. + drift, TIA flags, key/interlock) and a log of every command and read. It is + constructed exactly as `TCubeLaser`, `mode` default and closed-loop required + keywords included, so one construction line serves a rig and its twin. - **`CALIBRATION.md`** in the TCube driver folder: how to choose the TIA range, measure the threshold and the W/A factor, build the laser in closed loop and verify it, and the 642 nm rig's current calibration (224.2 W/A on the 1 mA @@ -204,8 +206,10 @@ setlevel!(laser, 0.4) hardware in open loop. - **Power mode re-checks the controller before emitting**: closed-loop `light_on` and `setoutputpower!` read the status word and the current limit - afresh and refuse if the loop bit is gone or the limit is above the - programmed clamp (a controller power cycle can restore the pot); two more + afresh and refuse if the loop bit is gone or the limit is above `max_current` + or more than half a pot step above the programmed clamp (a controller power + cycle can restore the pot). The clamp is recorded only after the whole + closed-loop `initialize` succeeded. Two more request/read round trips per call. Not yet run on hardware. - `setpower(laser, mA)` on a `DiodeLaser`: on a `ConstantCurrent` laser it forwards to `setcurrent!` with a deprecation warning; on a diff --git a/skills/mc-system-design/references/driver-caveats.md b/skills/mc-system-design/references/driver-caveats.md index 4378d51..2c29d85 100644 --- a/skills/mc-system-design/references/driver-caveats.md +++ b/skills/mc-system-design/references/driver-caveats.md @@ -21,7 +21,7 @@ the connection; the table shows which drivers currently follow it. | `PIStage()`, `N472()`, `MCLStage()`, `MCS2Stage()` | pure (`connectionstatus=false`, handle 0) | none until `initialize` | executed (constructors), traced (initialize) | | `ThorcamDCXCamera()` | pure | none until `initialize` | traced | | `TCubeLaser(serialNo; daq=NIdaq(), daq_device=nothing, ao_channel=nothing, ...)` | pure; stores the Kinesis serial number, a DAQ handle and (from v0.2.3) the DAQ device/AO channel names `setupIO` should use. `mode` defaults to `ConstantCurrent()` (0.2.x behaviour; closed loop is `mode = ConstantPhotocurrent()`) and the result is a `TCubeLaser{M} <: DiodeLaser` (from 0.2.5). Power mode also requires `max_current` (the clamp `initialize` programs; no default there), `wa_calibration` (W/A at the laser output), `tia_range` (A, the TLD001 DIP switch: `10e-6`, `100e-6`, `1e-3`, `10e-3`), `tec_stabilised` (`Bool` or `missing`) and `properties`, whose `min_power`/`max_power` become enforced mW bounds; the constructor validates them (`max_power` must fit `tia_range * wa_calibration`). `threshold_current` (mA, `NaN` unknown) is optional in both modes. The pre-0.2.5 positional arities still construct, as `ConstantCurrent`. | none until `initialize` | traced | -| `SimDiodeLaser(; mode, ...)` | pure; the simulated `DiodeLaser`, `mode` required (unlike `TCubeLaser`), and in `ConstantPhotocurrent` the same further keywords; every command and read throws before `initialize` and after `shutdown` | none | traced (0.2.5) | +| `SimDiodeLaser(; mode = ConstantCurrent(), ...)` | pure; the simulated `DiodeLaser`, constructed exactly as `TCubeLaser` (the same `mode` default, and in `ConstantPhotocurrent` the same required keywords including `max_current`; the ramp and lock keywords are stored, not simulated); every command and read throws before `initialize` and after `shutdown` | none | traced (0.2.5) | | `Triggerscope4(; portname="COM3")` | creates a `LibSerialPort.SerialPort` object for the port; does not open it | `shutdown`, if `initialize` opened it | traced | | `LCC1620(; scope=Triggerscope4(), dac_channel=1)` | constructs its own `Triggerscope4` unless you pass `scope`; validates `dac_channel` against `scope.dacoutputs` with `@error` (does not throw) | as for the scope it holds | traced | | **`DCAM4Camera(dev_id=0)`** | **calls `dcamapi_init` and `dcamdev_open`, reads sensor size and exposure.** The camera is claimed before `initialize`; `initialize(::DCAM4Camera)` is then a no-op. **Fixed in 0.2.1:** both failure paths now throw an `ErrorException` naming the device id and the `DCAMERR` code, instead of returning a non-camera value; on each of those two checked paths the constructor calls `dcamapi_uninit()` before it throws. That is not a general guarantee: an exception raised after `dcamdev_open` succeeds has no cleanup guard, and on the two paths that do clean up the cleanup is only *attempted*: `dcamapi_uninit()` checks its own SDK result and logs an `@error` when it fails, but the constructor ignores that return and throws either way, so a failed uninitialize is visible in the log and nowhere else. (Before 0.2.1, neither failure path returned a camera: if `dcamapi_init` failed the constructor called `dcamapi_uninit` itself and returned the `DCAMERR` code; if init succeeded but `dcamdev_open` failed it `@error`d "Could not open camera" and returned `nothing`, **leaving the DCAM API initialized**.) | On success: `shutdown(cam)` (`dcamdev_close` + `dcamapi_uninit`) even if you never called `initialize`. On an installed copy older than 0.2.1, a `nothing` return needs a manual `MicroscopeControl.HardwareImplementations.DCAM4.dcamapi_uninit()` call, or the next `DCAM4Camera()` in the session starts from a half-initialized SDK; check `cam isa DCAM4Camera` before storing it. | traced (hardware verification: NOT DONE for the 0.2.1 fix — no Hamamatsu camera available) | diff --git a/skills/mc-testing/SKILL.md b/skills/mc-testing/SKILL.md index 11345e0..6ce60ea 100644 --- a/skills/mc-testing/SKILL.md +++ b/skills/mc-testing/SKILL.md @@ -47,7 +47,7 @@ machine with no hardware or DLLs loads the package fine. | `SimCamera` | keyword, all defaulted | `exposure_time=0.1` (s), `roi=CameraROI(1, 1, 1024, 1024)`, `sequence_length=10`, `capture_mode=LIVE`, `is_running=false` | | `SimStage3d` / `SimStage2d` / `SimStage1d` | `Base.@kwdef` | `dimensions=3/2/1`, `range_*=(0, 100)`, `real_*=0.0`, `targ_*=0.0`, `connectionstatus=false`, `label` | | `SimLight` | keyword | `properties=LightSourceProperties("mW", 0.0, false, 0.0, 100.0)` | -| `SimDiodeLaser{M}` (0.2.5) | keyword; **`mode` required**, and in `ConstantPhotocurrent` also `wa_calibration`, `tia_range`, `tec_stabilised`, `properties`, as `TCubeLaser` (which, unlike this, defaults `mode`) | `threshold_current=65.0`, `max_current=160.0` (mA), `min_current=0.0`, `efficiency=1.2` (mW/mA above threshold), `responsivity=1/wa_calibration` (calibration starts true); fault fields `pd_blocked`, `responsivity_drift`, `tia_range_A` (the simulated DIP switch), `tia_over_fault`, `tia_under_fault`, `key`, `interlock`; `log` | +| `SimDiodeLaser{M}` (0.2.5) | keyword, exactly as `TCubeLaser`: `mode` defaults to `ConstantCurrent()`; in `ConstantPhotocurrent` also `wa_calibration`, `tia_range`, `tec_stabilised`, `properties` and `max_current` are required, and the ramp and lock keywords are stored but not simulated | `threshold_current=65.0`, `max_current=160.0` (mA, open loop), `min_current=0.0`, `efficiency=1.2` (mW/mA above threshold), `responsivity=1/wa_calibration` (calibration starts true); fault fields `pd_blocked`, `responsivity_drift`, `tia_range_A` (the simulated DIP switch), `tia_over_fault`, `tia_under_fault`, `key`, `interlock`; `log` | All implement `initialize`, `shutdown`, `export_state` **[guarantee]**. There is no simulated DAQ, attenuator, Triggerscope or SLM; for those, write a fake behind diff --git a/src/hardware_implementations/simulated_diode_laser/SimulatedDiodeLaser.jl b/src/hardware_implementations/simulated_diode_laser/SimulatedDiodeLaser.jl index ee6a30a..8bbb5a1 100644 --- a/src/hardware_implementations/simulated_diode_laser/SimulatedDiodeLaser.jl +++ b/src/hardware_implementations/simulated_diode_laser/SimulatedDiodeLaser.jl @@ -90,24 +90,32 @@ LightSourceInterface.supported_modes(::Type{<:SimDiodeLaser}) = (ConstantCurrent LightSourceInterface.regulation_mode(::Type{SimDiodeLaser{M}}) where {M} = M() """ - SimDiodeLaser(; mode, kwargs...) - -`mode` is required here (unlike `TCubeLaser`, where it defaults to `ConstantCurrent()`). In `ConstantPhotocurrent` mode so are -`wa_calibration`, `tia_range`, `tec_stabilised` and `properties`, exactly as on -the hardware driver, so that a system built against the simulation constructs -the same way. `threshold_current` defaults to 65 mA and `max_current` to 160 mA -(the 642 nm diode's numbers); the model fields are documented on the type. + SimDiodeLaser(; mode = ConstantCurrent(), kwargs...) + +Constructed exactly as [`TCubeLaser`](@ref) is, so that one construction line +serves a system and its simulated twin. `mode` defaults to `ConstantCurrent()`. +In `ConstantPhotocurrent` mode `wa_calibration`, `tia_range`, `tec_stabilised`, +`properties` and `max_current` are required, and the loop keywords `ramp_step_mW`, +`ramp_step_s`, `lock_check_s` and `lock_ratio` are accepted into the +[`PhotodiodeLoop`](@ref). `[limitation]` the simulation neither ramps nor +checks for a loop lock; it only stores them. `threshold_current` defaults to +65 mA and, in `ConstantCurrent` mode, `max_current` to 160 mA (the 642 nm +diode's numbers); the model fields are documented on the type. """ function SimDiodeLaser(; - mode::Union{Nothing,RegulationMode}=nothing, + mode::RegulationMode=ConstantCurrent(), unique_id::String="SimDiodeLaser", properties::Union{Nothing,LightSourceProperties}=nothing, min_current::Float64=0.0, - max_current::Float64=160.0, + max_current::Union{Nothing,Float64}=nothing, threshold_current::Float64=65.0, wa_calibration::Union{Nothing,Real}=nothing, tia_range::Union{Nothing,Real}=nothing, tec_stabilised::Union{Nothing,Bool,Missing}=nothing, + ramp_step_mW::Union{Nothing,Real}=nothing, + ramp_step_s::Union{Nothing,Real}=nothing, + lock_check_s::Union{Nothing,Real}=nothing, + lock_ratio::Union{Nothing,Real}=nothing, efficiency::Float64=1.2, responsivity::Union{Nothing,Float64}=nothing, responsivity_drift::Float64=0.0, @@ -117,19 +125,22 @@ function SimDiodeLaser(; pd_blocked::Bool=false, settle_s::Float64=0.0, ) - mode === nothing && throw(ArgumentError( - "SimDiodeLaser: the `mode` keyword is required: ConstantCurrent() or ConstantPhotocurrent()")) pd = if mode isa ConstantPhotocurrent absent = [kw for (kw, v) in (:wa_calibration => wa_calibration, :tia_range => tia_range, - :tec_stabilised => tec_stabilised, :properties => properties) if v === nothing] + :tec_stabilised => tec_stabilised, :properties => properties, + :max_current => max_current) if v === nothing] isempty(absent) || throw(ArgumentError( "SimDiodeLaser: a ConstantPhotocurrent laser needs these keywords, none of which has a default: $(join(absent, ", "))")) - PhotodiodeLoop(; wa_calibration=wa_calibration, tia_range=tia_range, tec_stabilised=tec_stabilised) + loopkw = (; (k => v for (k, v) in (:ramp_step_mW => ramp_step_mW, :ramp_step_s => ramp_step_s, + :lock_check_s => lock_check_s, :lock_ratio => lock_ratio) if v !== nothing)...) + PhotodiodeLoop(; wa_calibration=wa_calibration, tia_range=tia_range, tec_stabilised=tec_stabilised, loopkw...) else - any(!isnothing, (wa_calibration, tia_range, tec_stabilised)) && throw(ArgumentError( - "SimDiodeLaser: wa_calibration, tia_range and tec_stabilised describe a photodiode loop, which only a ConstantPhotocurrent laser has")) + any(!isnothing, (wa_calibration, tia_range, tec_stabilised, ramp_step_mW, ramp_step_s, lock_check_s, lock_ratio)) && + throw(ArgumentError("SimDiodeLaser: wa_calibration, tia_range, tec_stabilised and the ramp and lock keywords " * + "describe a photodiode loop, which only a ConstantPhotocurrent laser has")) nothing end + max_current = something(max_current, 160.0) props = something(properties, LightSourceProperties("mA", 0.0, false, min_current, max_current)) resp = something(responsivity, pd === nothing ? 1 / 224.2 : 1 / pd.wa_calibration) range = something(tia_range_A, pd === nothing ? 1e-3 : pd.tia_range) diff --git a/test/gui.jl b/test/gui.jl index d1f173f..6d4a9c1 100644 --- a/test/gui.jl +++ b/test/gui.jl @@ -126,7 +126,7 @@ MicroscopeControl.light_off(light::RecordingLight) = (push!(light.log, :light_of @testset "Diode laser panels issue nothing on open" begin cp_props() = LightSourceProperties("mW", 0.0, false, 1.0, 70.0) sims = (SimDiodeLaser(; mode=ConstantCurrent(), min_current=70.0, max_current=150.0), - SimDiodeLaser(; mode=ConstantPhotocurrent(), wa_calibration=224.2, tia_range=1e-3, + SimDiodeLaser(; mode=ConstantPhotocurrent(), max_current=160.0, wa_calibration=224.2, tia_range=1e-3, tec_stabilised=missing, properties=cp_props())) for sim in sims initialize(sim) diff --git a/test/runtests.jl b/test/runtests.jl index 5f42cf7..bae4c7d 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -631,17 +631,20 @@ lab_summary("Core") do end @testset "power mode re-checks the controller before emitting (fake SDK)" begin - # A 100 mA ceiling, so a pot restored to 204 (160.74 mA) is far above the - # clamp: the check allows 1 mA of slack, which is more than one pot step - # (0.83 mA), so a one-step drift at a 160 mA ceiling is not caught. - ready() = (FakeKinesis.reset!(); FakeKinesis.limit_follows_pot[] = true; - l = cp(; max_current=100.0); initialize(l); empty!(FakeKinesis.calls); l) + ready(mA=100.0) = (FakeKinesis.reset!(); FakeKinesis.limit_follows_pot[] = true; + l = cp(; max_current=mA); initialize(l); empty!(FakeKinesis.calls); l) # (a) A power cycle restored the pot: the limit is back above the clamp. laser = ready() FakeKinesis.digpot[] = 204 @test_throws "clamp" light_on(laser) @test "LD_EnableOutput" ∉ FakeKinesis.calls @test_throws "clamp" setoutputpower!(laser, 10.0) + # ... and one pot step at a 160 mA ceiling: the clamp is 159.91 mA at + # position 203, and 204 reads 160.74 mA, above max_current. + laser = ready(160.0) + FakeKinesis.digpot[] = 204 + @test_throws "clamp" light_on(laser) + @test "LD_EnableOutput" ∉ FakeKinesis.calls # (b) The closed-loop bit is gone. laser = ready() FakeKinesis.setbits!(FakeKinesis.CLOSED; on=false) @@ -656,6 +659,11 @@ lab_summary("Core") do FakeKinesis.setbits!(FakeKinesis.KEY; on=false) @test_throws "key switch" initialize(laser) @test isnan(laser.pd.max_current_clamp) + # ... and one that fails after programming the pot, entering closed loop. + laser = ready() + FakeKinesis.fail!("LD_SetClosedLoopMode") + @test_throws "LD_SetClosedLoopMode" initialize(laser) + @test isnan(laser.pd.max_current_clamp) end @testset "the ramp starts from what the driver knows; lock detection (fake SDK)" begin @@ -1146,13 +1154,15 @@ lab_summary("Core") do @testset "Simulated Diode Laser" begin SimDL = MicroscopeControl.HardwareImplementations.SimulatedDiodeLaser sim_cc(; kw...) = SimDiodeLaser(; mode=ConstantCurrent(), kw...) - sim_cp(; kw...) = SimDiodeLaser(; mode=ConstantPhotocurrent(), wa_calibration=224.2, tia_range=1e-3, + sim_cp(; kw...) = SimDiodeLaser(; mode=ConstantPhotocurrent(), max_current=160.0, wa_calibration=224.2, tia_range=1e-3, tec_stabilised=missing, properties=LightSourceProperties("mW", 0.0, false, 1.0, 70.0), kw...) @test sim_cc() isa DiodeLaser - @test_throws ArgumentError SimDiodeLaser() + @test SimDiodeLaser() isa SimDiodeLaser{ConstantCurrent} # the default, as on TCubeLaser @test_throws ArgumentError SimDiodeLaser(; mode=ConstantPhotocurrent()) + @test_throws "max_current" SimDiodeLaser(; mode=ConstantPhotocurrent(), wa_calibration=224.2, tia_range=1e-3, + tec_stabilised=missing, properties=LightSourceProperties("mW", 0.0, false, 1.0, 70.0)) @testset "1. the loop converges on the requested power" begin sim = sim_cp() From 83a15145e54919907d66c9543fdb68363a2019af Mon Sep 17 00:00:00 2001 From: kalidke Date: Thu, 24 Sep 2026 08:37:54 -0600 Subject: [PATCH 24/37] Revert the boolean binding change: the header says four bytes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v0.2.3 retyped nine returns and three arguments from `BOOL` (`Cuint`) to a one-byte `Bool`, on a second-hand report that the Kinesis header declares C++ `bool`. The header says otherwise, and it was on the lab NAS the whole time: Thorlabs.MotionControl.TCube.LaserDiode.h:37 typedef unsigned int BOOL; 27 uppercase `BOOL`, zero lowercase `bool`. The original binding was correct and the change was a regression. It is wrong in the dangerous direction for ARGUMENTS: passing one byte where the callee reads four leaves the upper three undefined, so a `false` can arrive as true. The call that matters is `LD_EnableMaxCurrentAdjust(serial, enableAdjust, enableDiode)`, whose second flag enables the laser diode during a max-current adjustment — the call the closed-loop clamp sequence depends on being able to pass as false. Four bytes is safe under either ABI for an argument; one byte is not. The same report also drove a `[limitation]` warning on `TLI_DeviceInfo` saying it was packed to 100 bytes and misaligned from `PID` onward. The header's `#pragma pack(1)` is COMMENTED OUT, and the fields are the same 4-byte `BOOL`. Verified: our declaration is 120 bytes with `PID` at offset 88, which is exactly the header's default-alignment arithmetic. That warning was false and is deleted — it would have sent the next reader hunting a defect that does not exist. Both docstrings now record the history, so nobody re-applies either "fix". Nothing in this package calls the affected functions, so the regression is latent here; a downstream calls `LD_CheckConnection`. Suite: 959 passed, 8 broken, 0 failed. Co-Authored-By: Claude Opus 5 (1M context) --- .../tcube_laser/constants_Tlaser.jl | 61 ++++++++++--------- .../tcube_laser/functions_Tlaser.jl | 24 ++++---- 2 files changed, 44 insertions(+), 41 deletions(-) diff --git a/src/hardware_implementations/tcube_laser/constants_Tlaser.jl b/src/hardware_implementations/tcube_laser/constants_Tlaser.jl index 4406eda..fa201b6 100644 --- a/src/hardware_implementations/tcube_laser/constants_Tlaser.jl +++ b/src/hardware_implementations/tcube_laser/constants_Tlaser.jl @@ -11,20 +11,26 @@ const __int32 = Cint const BOOL = Cuint """ - CPPBOOL - -Julia's `Bool`, i.e. one byte, for the Kinesis entry points the header declares -as C++ `bool` rather than as a Windows `BOOL`. The distinction is not cosmetic: -a `bool` return sets only the low byte of the return register, so reading it as -a 4-byte `BOOL` reads three bytes of whatever happened to be there, and -`LD_CheckConnection(...) != 0` can then report a disconnected controller as -connected. Reported by the 642 nm rig from the Kinesis header, 2026-09-23; the -same file's `tcubeapi.jl` already used `::Bool` for `LD_StartPolling` and -`LD_StopPolling`, so the package disagreed with itself. Not hardware-verified. - -`BOOL` above stays `Cuint` for the genuine Windows `BOOL` uses. + BOOL + +The Kinesis headers `typedef unsigned int BOOL` (verified at line 37 of +`Thorlabs.MotionControl.TCube.LaserDiode.h`), so this is four bytes, and every +`BOOL` argument and return in `functions_Tlaser.jl` is that width. + +**History, because this was got wrong once.** v0.2.3 changed these to a +one-byte `Bool` on a report that the header declared C++ `bool`. It does not: +that header contains no lowercase `bool` at all. The change was reverted +because it is wrong in the dangerous direction for ARGUMENTS — passing one +byte where the callee reads four leaves the upper three undefined, so a +`false` can arrive as true. `LD_EnableMaxCurrentAdjust(serial, true, false)` +is the call that matters: its second flag enables the laser diode during a +max-current adjustment. + +If a future Kinesis version really does declare `bool`, check the header for +that version before changing this, and change arguments and returns +separately — four bytes is safe for an argument under either ABI, one byte is +not. """ -const CPPBOOL = Bool struct tagSAFEARRAYBOUND cElements::Culong @@ -67,22 +73,19 @@ end """ TLI_DeviceInfo -**[limitation] This layout is suspect and unverified.** The Kinesis header -declares the `is*` fields as C++ `bool` (one byte), and they are typed here as -`BOOL` = `Cuint` (four). If that is right, every field from `isKnownType` -onward is misaligned and `TLI_GetDeviceInfo` returns nonsense. Nothing in this -package calls `TLI_GetDeviceInfo`, so the defect is latent rather than active, -and it is left alone as deferred ABI repair rather than corrected in passing. - -Correcting it is more than retyping the five fields, and does NOT need a -controller -- it needs the vendor header, which this repo does not carry. The -header declares the structure `#pragma pack(1)` at 100 bytes; this declaration -is 120, and the divergence starts at `PID`, *before* the first `bool`. So -retyping the booleans alone would leave it wrong. Repair it against the header, -with the size asserted, or do not call it. The function signatures in -`functions_Tlaser.jl` had a related discrepancy and WERE corrected -- see -`CPPBOOL` -- because there the fix moves no field and the ABI rule is -unambiguous. +Mirrors the `TLI_DeviceInfo` struct in the Kinesis headers. `BOOL` is four +bytes there (`typedef unsigned int BOOL`, line 37), the fields are in this +order, and the struct is NOT packed -- the header's `#pragma pack(1)` is +commented out, so default alignment applies and both declarations come to 120 +bytes with `PID` at offset 88. + +**This declaration carried a `[limitation]` warning in v0.2.3 saying it was +suspect and wrong from `PID` onward. That warning was itself wrong** and is +removed. It came from a second-hand report that the header packs to one byte +and declares the flags as C++ `bool`; the header on the lab NAS does neither. +Nothing in this package calls `TLI_GetDeviceInfo`, so nothing depended on +either claim -- but a false warning costs the next reader a hunt for a defect +that is not there, which is why it is deleted rather than softened. """ struct TLI_DeviceInfo typeID::DWORD diff --git a/src/hardware_implementations/tcube_laser/functions_Tlaser.jl b/src/hardware_implementations/tcube_laser/functions_Tlaser.jl index 0680825..124459c 100644 --- a/src/hardware_implementations/tcube_laser/functions_Tlaser.jl +++ b/src/hardware_implementations/tcube_laser/functions_Tlaser.jl @@ -55,7 +55,7 @@ function LD_Close(serialNo) end function LD_CheckConnection(serialNo) - ccall((:LD_CheckConnection, Thorlabs_Tcube_laser), CPPBOOL, (Ptr{Cchar},), serialNo) + ccall((:LD_CheckConnection, Thorlabs_Tcube_laser), BOOL, (Ptr{Cchar},), serialNo) end function LD_Identify(serialNo) @@ -79,15 +79,15 @@ function LD_GetSoftwareVersion(serialNo) end function LD_LoadSettings(serialNo) - ccall((:LD_LoadSettings, Thorlabs_Tcube_laser), CPPBOOL, (Ptr{Cchar},), serialNo) + ccall((:LD_LoadSettings, Thorlabs_Tcube_laser), BOOL, (Ptr{Cchar},), serialNo) end function LD_LoadNamedSettings(serialNo, settingsName) - ccall((:LD_LoadNamedSettings, Thorlabs_Tcube_laser), CPPBOOL, (Ptr{Cchar}, Ptr{Cchar}), serialNo, settingsName) + ccall((:LD_LoadNamedSettings, Thorlabs_Tcube_laser), BOOL, (Ptr{Cchar}, Ptr{Cchar}), serialNo, settingsName) end function LD_PersistSettings(serialNo) - ccall((:LD_PersistSettings, Thorlabs_Tcube_laser), CPPBOOL, (Ptr{Cchar},), serialNo) + ccall((:LD_PersistSettings, Thorlabs_Tcube_laser), BOOL, (Ptr{Cchar},), serialNo) end function LD_Disable(serialNo) @@ -111,11 +111,11 @@ function LD_MessageQueueSize(serialNo) end function LD_GetNextMessage(serialNo, messageType, messageID, messageData) - ccall((:LD_GetNextMessage, Thorlabs_Tcube_laser), CPPBOOL, (Ptr{Cchar}, Ptr{WORD}, Ptr{WORD}, Ptr{DWORD}), serialNo, messageType, messageID, messageData) + ccall((:LD_GetNextMessage, Thorlabs_Tcube_laser), BOOL, (Ptr{Cchar}, Ptr{WORD}, Ptr{WORD}, Ptr{DWORD}), serialNo, messageType, messageID, messageData) end function LD_WaitForMessage(serialNo, messageType, messageID, messageData) - ccall((:LD_WaitForMessage, Thorlabs_Tcube_laser), CPPBOOL, (Ptr{Cchar}, Ptr{WORD}, Ptr{WORD}, Ptr{DWORD}), serialNo, messageType, messageID, messageData) + ccall((:LD_WaitForMessage, Thorlabs_Tcube_laser), BOOL, (Ptr{Cchar}, Ptr{WORD}, Ptr{WORD}, Ptr{DWORD}), serialNo, messageType, messageID, messageData) end function LD_SetOpenLoopMode(serialNo) @@ -127,7 +127,7 @@ function LD_SetClosedLoopMode(serialNo) end function LD_EnableMaxCurrentAdjust(serialNo, enableAdjust, enableDiode) - ccall((:LD_EnableMaxCurrentAdjust, Thorlabs_Tcube_laser), Cshort, (Ptr{Cchar}, CPPBOOL, CPPBOOL), serialNo, enableAdjust, enableDiode) + ccall((:LD_EnableMaxCurrentAdjust, Thorlabs_Tcube_laser), Cshort, (Ptr{Cchar}, BOOL, BOOL), serialNo, enableAdjust, enableDiode) end function LD_RequestMaxCurrentDigPot(serialNo) @@ -147,7 +147,7 @@ function LD_FindTIAGain(serialNo) end function LD_EnableTIAGainAdjust(serialNo, enable) - ccall((:LD_EnableTIAGainAdjust, Thorlabs_Tcube_laser), Cshort, (Ptr{Cchar}, CPPBOOL), serialNo, enable) + ccall((:LD_EnableTIAGainAdjust, Thorlabs_Tcube_laser), Cshort, (Ptr{Cchar}, BOOL), serialNo, enable) end function LD_DisableOutput(serialNo) @@ -267,7 +267,7 @@ function LD_GetStatusBits(serialNo) end function LD_StartPolling(serialNo, milliseconds) - ccall((:LD_StartPolling, Thorlabs_Tcube_laser), CPPBOOL, (Ptr{Cchar}, Cint), serialNo, milliseconds) + ccall((:LD_StartPolling, Thorlabs_Tcube_laser), BOOL, (Ptr{Cchar}, Cint), serialNo, milliseconds) end function LD_PollingDuration(serialNo) @@ -279,15 +279,15 @@ function LD_StopPolling(serialNo) end function LD_TimeSinceLastMsgReceived(serialNo, arg2) - ccall((:LD_TimeSinceLastMsgReceived, Thorlabs_Tcube_laser), CPPBOOL, (Ptr{Cchar}, __int64), serialNo, arg2) + ccall((:LD_TimeSinceLastMsgReceived, Thorlabs_Tcube_laser), BOOL, (Ptr{Cchar}, __int64), serialNo, arg2) end function LD_EnableLastMsgTimer(serialNo, enable, lastMsgTimeout) - ccall((:LD_EnableLastMsgTimer, Thorlabs_Tcube_laser), Cvoid, (Ptr{Cchar}, CPPBOOL, __int32), serialNo, enable, lastMsgTimeout) + ccall((:LD_EnableLastMsgTimer, Thorlabs_Tcube_laser), Cvoid, (Ptr{Cchar}, BOOL, __int32), serialNo, enable, lastMsgTimeout) end function LD_HasLastMsgTimerOverrun(serialNo) - ccall((:LD_HasLastMsgTimerOverrun, Thorlabs_Tcube_laser), CPPBOOL, (Ptr{Cchar},), serialNo) + ccall((:LD_HasLastMsgTimerOverrun, Thorlabs_Tcube_laser), BOOL, (Ptr{Cchar},), serialNo) end function LD_RequestSettings(serialNo) From 7a4cb59ad669805c4d7be42585cc25ad7773b547 Mon Sep 17 00:00:00 2001 From: kalidke Date: Thu, 24 Sep 2026 09:48:02 -0600 Subject: [PATCH 25/37] Split the two Kinesis boolean roles; they have different right answers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rig repo diffed the installed vendor header against the copy on the lab NAS and settled this properly. The NAS copy is a Clang.jl generation input, hand-edited to parse without Windows headers: the vendor preamble was replaced with local typedefs including `typedef unsigned int BOOL`, `OaIdl.h` and `__declspec` were removed, both pack pragmas were commented out, and every lowercase `bool` was rewritten to `BOOL`. It carries no ABI information of its own, and I treated it as authoritative. The INSTALLED header has 32 lowercase `bool`, zero `BOOL`, and an active `#pragma pack(1)`. So v0.2.3 was half right and my revert was half wrong, and neither pure type is correct. The two roles differ: - `KBOOL_ARG = Cuint`, zero-extended 0 or 1, for arguments. Robust under either ABI: a `bool` callee reads the low byte, a `BOOL` callee reads four, both see 0 or 1. A 1-byte argument is NOT robust — the upper three bytes are undefined, so a `false` can arrive as true, and `LD_EnableMaxCurrentAdjust`'s second flag enables the diode. - `KBOOL_RET = Bool`, one byte, for returns. A `bool` return sets only `AL` and leaves the rest of `EAX` undefined, so reading four bytes can turn `false` into nonzero — `LD_CheckConnection` reporting a disconnected controller as connected. Reading the low byte is right under `bool` and still right under `BOOL` returning 0 or 1. Nine returns and four argument positions, split accordingly. `TLI_DeviceInfo`'s warning is restored and corrected rather than deleted: the real layout is 100 bytes with `PID` at 85 under `pack(1)` with 1-byte flags, against this declaration's verified 120 and 88. Still not fixed — correcting it needs field types AND packing, and a controller to read a device list back from. The docstring now names the NAS copy as untrustworthy so nobody relitigates this from it a third time. Suite: 959 passed, 8 broken, 0 failed. Co-Authored-By: Claude Opus 5 (1M context) --- .../tcube_laser/constants_Tlaser.jl | 80 +++++++++++++++---- .../tcube_laser/functions_Tlaser.jl | 24 +++--- 2 files changed, 78 insertions(+), 26 deletions(-) diff --git a/src/hardware_implementations/tcube_laser/constants_Tlaser.jl b/src/hardware_implementations/tcube_laser/constants_Tlaser.jl index fa201b6..9327f85 100644 --- a/src/hardware_implementations/tcube_laser/constants_Tlaser.jl +++ b/src/hardware_implementations/tcube_laser/constants_Tlaser.jl @@ -10,10 +10,49 @@ const __int32 = Cint const BOOL = Cuint +""" + KBOOL_ARG + +The type to pass a Kinesis C++ `bool` ARGUMENT: a zero-extended `Cuint` +carrying exactly 0 or 1. + +This is robust whichever width the callee really reads. A `bool` callee takes +the low byte and sees 0 or 1; a `BOOL` callee takes all four and sees 0 or 1. +Passing a 1-byte `Bool` is NOT robust in this direction: the upper three bytes +of the register are undefined, so a `false` can arrive as true. That matters +for `LD_EnableMaxCurrentAdjust(serialNo, enableAdjust, enableDiode)`, whose +second flag enables the laser diode during a max-current adjustment. +""" +const KBOOL_ARG = Cuint + +""" + KBOOL_RET + +The type to read a Kinesis C++ `bool` RETURN: one byte. + +The installed vendor header declares these `bool`, which on MSVC x86-64 +returns in `AL` and leaves the rest of `EAX` **undefined**. Reading four bytes +can therefore turn a `false` into a nonzero value — `LD_CheckConnection` +reporting a disconnected controller as connected. Reading the low byte is +correct under the `bool` ABI and still correct under a `BOOL` ABI returning +0 or 1. + +**History, because this was got wrong twice.** v0.2.3 retyped both roles to a +1-byte `Bool`, which was right for returns and wrong for arguments. A copy of +the header on the lab NAS appeared to contradict it — but that copy is a +Clang.jl generation input, hand-edited to parse without Windows headers: its +`typedef unsigned int BOOL` and its commented-out `#pragma pack` are artefacts +of that editing, not the vendor's ABI. The installed header has 32 lowercase +`bool`, zero `BOOL`, and an active `#pragma pack(1)`. Splitting the two roles +is what is actually correct, and is safe under either reading. +""" +const KBOOL_RET = Bool + """ BOOL -The Kinesis headers `typedef unsigned int BOOL` (verified at line 37 of +Retained for `TLI_DeviceInfo`'s fields only. Note the Kinesis headers +`typedef unsigned int BOOL` (verified at line 37 of `Thorlabs.MotionControl.TCube.LaserDiode.h`), so this is four bytes, and every `BOOL` argument and return in `functions_Tlaser.jl` is that width. @@ -73,19 +112,32 @@ end """ TLI_DeviceInfo -Mirrors the `TLI_DeviceInfo` struct in the Kinesis headers. `BOOL` is four -bytes there (`typedef unsigned int BOOL`, line 37), the fields are in this -order, and the struct is NOT packed -- the header's `#pragma pack(1)` is -commented out, so default alignment applies and both declarations come to 120 -bytes with `PID` at offset 88. - -**This declaration carried a `[limitation]` warning in v0.2.3 saying it was -suspect and wrong from `PID` onward. That warning was itself wrong** and is -removed. It came from a second-hand report that the header packs to one byte -and declares the flags as C++ `bool`; the header on the lab NAS does neither. -Nothing in this package calls `TLI_GetDeviceInfo`, so nothing depended on -either claim -- but a false warning costs the next reader a hunt for a defect -that is not there, which is why it is deleted rather than softened. +**[limitation] This layout is wrong for the real DLL, and is left alone +deliberately.** + +The installed vendor header declares this struct under an ACTIVE +`#pragma pack(1)` with C++ `bool` flags. Packed, the real layout is +**100 bytes with `PID` at offset 85**: `typeID` 4 + `description` 65 + +`serialNo` 16 puts `PID` at 85 with no padding, and the five flags are one +byte each. This declaration uses 4-byte `BOOL` and default alignment, so it +measures **120 bytes with `PID` at offset 88** (verified by execution) and is +wrong from `PID` onward. + +Nothing in this package calls `TLI_GetDeviceInfo`, so this is latent rather +than a hazard, and correcting it means changing field types AND adding +packing — not a change to make without a controller to read a real device +list back from. Fix it against the installed header with the size asserted, +or do not call it. + +**A copy of this header on the lab NAS says otherwise; do not trust it.** That +copy (`Personal Folders/Sheng/code/generate_lib/lib/`) is a Clang.jl +generation input, hand-edited to parse without Windows headers: the vendor +preamble was replaced with local typedefs including +`typedef unsigned int BOOL`, `OaIdl.h` and `__declspec` were removed, both +pack pragmas were commented out, and every lowercase `bool` was rewritten to +`BOOL`. Those are artefacts of the editing, not the vendor's ABI. This +docstring briefly claimed, on the strength of that copy, that the layout was +correct; it is not. """ struct TLI_DeviceInfo typeID::DWORD diff --git a/src/hardware_implementations/tcube_laser/functions_Tlaser.jl b/src/hardware_implementations/tcube_laser/functions_Tlaser.jl index 124459c..7d6a5a0 100644 --- a/src/hardware_implementations/tcube_laser/functions_Tlaser.jl +++ b/src/hardware_implementations/tcube_laser/functions_Tlaser.jl @@ -55,7 +55,7 @@ function LD_Close(serialNo) end function LD_CheckConnection(serialNo) - ccall((:LD_CheckConnection, Thorlabs_Tcube_laser), BOOL, (Ptr{Cchar},), serialNo) + ccall((:LD_CheckConnection, Thorlabs_Tcube_laser), KBOOL_RET, (Ptr{Cchar},), serialNo) end function LD_Identify(serialNo) @@ -79,15 +79,15 @@ function LD_GetSoftwareVersion(serialNo) end function LD_LoadSettings(serialNo) - ccall((:LD_LoadSettings, Thorlabs_Tcube_laser), BOOL, (Ptr{Cchar},), serialNo) + ccall((:LD_LoadSettings, Thorlabs_Tcube_laser), KBOOL_RET, (Ptr{Cchar},), serialNo) end function LD_LoadNamedSettings(serialNo, settingsName) - ccall((:LD_LoadNamedSettings, Thorlabs_Tcube_laser), BOOL, (Ptr{Cchar}, Ptr{Cchar}), serialNo, settingsName) + ccall((:LD_LoadNamedSettings, Thorlabs_Tcube_laser), KBOOL_RET, (Ptr{Cchar}, Ptr{Cchar}), serialNo, settingsName) end function LD_PersistSettings(serialNo) - ccall((:LD_PersistSettings, Thorlabs_Tcube_laser), BOOL, (Ptr{Cchar},), serialNo) + ccall((:LD_PersistSettings, Thorlabs_Tcube_laser), KBOOL_RET, (Ptr{Cchar},), serialNo) end function LD_Disable(serialNo) @@ -111,11 +111,11 @@ function LD_MessageQueueSize(serialNo) end function LD_GetNextMessage(serialNo, messageType, messageID, messageData) - ccall((:LD_GetNextMessage, Thorlabs_Tcube_laser), BOOL, (Ptr{Cchar}, Ptr{WORD}, Ptr{WORD}, Ptr{DWORD}), serialNo, messageType, messageID, messageData) + ccall((:LD_GetNextMessage, Thorlabs_Tcube_laser), KBOOL_RET, (Ptr{Cchar}, Ptr{WORD}, Ptr{WORD}, Ptr{DWORD}), serialNo, messageType, messageID, messageData) end function LD_WaitForMessage(serialNo, messageType, messageID, messageData) - ccall((:LD_WaitForMessage, Thorlabs_Tcube_laser), BOOL, (Ptr{Cchar}, Ptr{WORD}, Ptr{WORD}, Ptr{DWORD}), serialNo, messageType, messageID, messageData) + ccall((:LD_WaitForMessage, Thorlabs_Tcube_laser), KBOOL_RET, (Ptr{Cchar}, Ptr{WORD}, Ptr{WORD}, Ptr{DWORD}), serialNo, messageType, messageID, messageData) end function LD_SetOpenLoopMode(serialNo) @@ -127,7 +127,7 @@ function LD_SetClosedLoopMode(serialNo) end function LD_EnableMaxCurrentAdjust(serialNo, enableAdjust, enableDiode) - ccall((:LD_EnableMaxCurrentAdjust, Thorlabs_Tcube_laser), Cshort, (Ptr{Cchar}, BOOL, BOOL), serialNo, enableAdjust, enableDiode) + ccall((:LD_EnableMaxCurrentAdjust, Thorlabs_Tcube_laser), Cshort, (Ptr{Cchar}, KBOOL_ARG, KBOOL_ARG), serialNo, enableAdjust, enableDiode) end function LD_RequestMaxCurrentDigPot(serialNo) @@ -147,7 +147,7 @@ function LD_FindTIAGain(serialNo) end function LD_EnableTIAGainAdjust(serialNo, enable) - ccall((:LD_EnableTIAGainAdjust, Thorlabs_Tcube_laser), Cshort, (Ptr{Cchar}, BOOL), serialNo, enable) + ccall((:LD_EnableTIAGainAdjust, Thorlabs_Tcube_laser), Cshort, (Ptr{Cchar}, KBOOL_ARG), serialNo, enable) end function LD_DisableOutput(serialNo) @@ -267,7 +267,7 @@ function LD_GetStatusBits(serialNo) end function LD_StartPolling(serialNo, milliseconds) - ccall((:LD_StartPolling, Thorlabs_Tcube_laser), BOOL, (Ptr{Cchar}, Cint), serialNo, milliseconds) + ccall((:LD_StartPolling, Thorlabs_Tcube_laser), KBOOL_RET, (Ptr{Cchar}, Cint), serialNo, milliseconds) end function LD_PollingDuration(serialNo) @@ -279,15 +279,15 @@ function LD_StopPolling(serialNo) end function LD_TimeSinceLastMsgReceived(serialNo, arg2) - ccall((:LD_TimeSinceLastMsgReceived, Thorlabs_Tcube_laser), BOOL, (Ptr{Cchar}, __int64), serialNo, arg2) + ccall((:LD_TimeSinceLastMsgReceived, Thorlabs_Tcube_laser), KBOOL_RET, (Ptr{Cchar}, __int64), serialNo, arg2) end function LD_EnableLastMsgTimer(serialNo, enable, lastMsgTimeout) - ccall((:LD_EnableLastMsgTimer, Thorlabs_Tcube_laser), Cvoid, (Ptr{Cchar}, BOOL, __int32), serialNo, enable, lastMsgTimeout) + ccall((:LD_EnableLastMsgTimer, Thorlabs_Tcube_laser), Cvoid, (Ptr{Cchar}, KBOOL_ARG, __int32), serialNo, enable, lastMsgTimeout) end function LD_HasLastMsgTimerOverrun(serialNo) - ccall((:LD_HasLastMsgTimerOverrun, Thorlabs_Tcube_laser), BOOL, (Ptr{Cchar},), serialNo) + ccall((:LD_HasLastMsgTimerOverrun, Thorlabs_Tcube_laser), KBOOL_RET, (Ptr{Cchar},), serialNo) end function LD_RequestSettings(serialNo) From 36e1323d73d6fc749fe1f901fc8ec362c5425615 Mon Sep 17 00:00:00 2001 From: kalidke Date: Thu, 24 Sep 2026 12:47:05 -0600 Subject: [PATCH 26/37] Record both Kinesis header variants; no single struct layout fits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 405 rig read its installed Kinesis 1.14.10 header and confirmed the 642 rig's reading independently: 32 lowercase C++ `bool`, zero `BOOL`, no `BOOL` typedef, and `#pragma pack(1)` ACTIVE around `TLI_DeviceInfo`. It also found something neither of us had — the two versions differ in an ARRAY LENGTH: 1.14.10 char serialNo[9] the 642 rig char serialNo[16] So the struct layout varies by SDK version in more than one dimension, and no single declaration can be right for both. Ours matches neither: 120 bytes with `PID` at 88, against a packed 100-with-85 on one and something different again on the other. The docstring now records both observed variants as a table, states the policy (do not call `TLI_GetDeviceInfo` through this declaration; declare it per SDK version beside the header it came from, and assert `sizeof`), and confirms by inspection that `initialize` touches only the two counting `TLI_` functions, so nothing reaches the struct. It also names the NAS copy as untrustworthy, with the reason, so this is not relitigated from that file a fourth time. The boolean split stands and both rigs endorse it: 4-byte zero-extended arguments, low-byte returns. Suite: 959 passed, 8 broken, 0 failed. Co-Authored-By: Claude Opus 5 (1M context) --- .../tcube_laser/constants_Tlaser.jl | 59 +++++++++++-------- 1 file changed, 33 insertions(+), 26 deletions(-) diff --git a/src/hardware_implementations/tcube_laser/constants_Tlaser.jl b/src/hardware_implementations/tcube_laser/constants_Tlaser.jl index 9327f85..1f675c3 100644 --- a/src/hardware_implementations/tcube_laser/constants_Tlaser.jl +++ b/src/hardware_implementations/tcube_laser/constants_Tlaser.jl @@ -112,32 +112,39 @@ end """ TLI_DeviceInfo -**[limitation] This layout is wrong for the real DLL, and is left alone -deliberately.** - -The installed vendor header declares this struct under an ACTIVE -`#pragma pack(1)` with C++ `bool` flags. Packed, the real layout is -**100 bytes with `PID` at offset 85**: `typeID` 4 + `description` 65 + -`serialNo` 16 puts `PID` at 85 with no padding, and the five flags are one -byte each. This declaration uses 4-byte `BOOL` and default alignment, so it -measures **120 bytes with `PID` at offset 88** (verified by execution) and is -wrong from `PID` onward. - -Nothing in this package calls `TLI_GetDeviceInfo`, so this is latent rather -than a hazard, and correcting it means changing field types AND adding -packing — not a change to make without a controller to read a real device -list back from. Fix it against the installed header with the size asserted, -or do not call it. - -**A copy of this header on the lab NAS says otherwise; do not trust it.** That -copy (`Personal Folders/Sheng/code/generate_lib/lib/`) is a Clang.jl -generation input, hand-edited to parse without Windows headers: the vendor -preamble was replaced with local typedefs including -`typedef unsigned int BOOL`, `OaIdl.h` and `__declspec` were removed, both -pack pragmas were commented out, and every lowercase `bool` was rewritten to -`BOOL`. Those are artefacts of the editing, not the vendor's ABI. This -docstring briefly claimed, on the strength of that copy, that the layout was -correct; it is not. +**[limitation] This layout does not match either Kinesis version we have +looked at, and it is left alone deliberately, because there is no single +layout that would match both.** + +Two rigs read their installed headers and reported different declarations: + +| | Kinesis 1.14.10 (405 rig) | the 642 rig's install | +|---|---|---| +| `serialNo` | `char serialNo[9]` | `char serialNo[16]` | +| flags | 1-byte C++ `bool` | 1-byte C++ `bool` | +| `#pragma pack(1)` | ACTIVE (lines 63-125) | ACTIVE | +| reported size | — | 100 bytes, `PID` at 85 | + +This declaration uses `NTuple{16,Cchar}`, 4-byte `BOOL` flags and default +alignment, measuring **120 bytes with `PID` at offset 88** (verified by +execution). It is wrong for both, and the field that differs between the two +vendor versions is an array LENGTH, so no reinterpretation fixes both at once. + +`[policy]` Do not call `TLI_GetDeviceInfo` through this declaration. Nothing +in this package does — `initialize` uses only `TLI_BuildDeviceList` and +`TLI_GetDeviceListSize`, both of which return counts and touch no struct +(verified). If a caller ever needs device info, declare the struct for the +SDK version in use, assert `sizeof`, and keep it beside the header it was +read from. + +**One copy of this header on the lab NAS contradicts all of the above; do not +trust it.** `Personal Folders/Sheng/code/generate_lib/lib/` holds a Clang.jl +generation input, hand-edited to parse without Windows headers: local +typedefs including `typedef unsigned int BOOL`, `OaIdl.h` and `__declspec` +stripped, both pack pragmas commented out, every lowercase `bool` rewritten. +Those are artefacts of the editing. This docstring briefly asserted, on the +strength of that file, that our layout was correct; it is not, and the round +trip cost two wrong rulings in opposite directions. """ struct TLI_DeviceInfo typeID::DWORD From 8ee364dabaf7d8240c4c2ab657143adc6c5d42d8 Mon Sep 17 00:00:00 2001 From: kalidke Date: Thu, 24 Sep 2026 13:32:22 -0600 Subject: [PATCH 27/37] Correct the BOOL docstring against the vendor headers, and attach it Two rigs have now read their own vendor-installed Kinesis header -- 1.14.10 on seq-sr, 1.14.47.22504 on quickbeam -- and both declare lowercase C++ `bool` with `#pragma pack(1)` active. The `BOOL` docstring still asserted the opposite, on the strength of a copy on the NAS that turns out to be a Clang.jl generation input hand-edited to parse without the Windows SDK. That claim also contradicted the `KBOOL_RET` docstring three lines above it. The docstring was additionally unattached: it sat between `const KBOOL_RET` and `struct tagSAFEARRAYBOUND`, so Julia bound it to the SAFEARRAY bound struct rather than to `BOOL`. Moved above `const BOOL = Cuint`, where it belongs. No binding changes. `BOOL` is still four bytes and still used only by `TLI_DeviceInfo`, which nothing calls; it stays because the two vendor versions disagree on `serialNo`'s length, so there is no single layout to change it to. Also record the instrument archive: `manuals/` is a local-only, gitignored symlink to the lab-wide archive on the NAS, documented in CLAUDE.md with the reason it must never be committed. Local suite: 959 pass, 8 broken, 0 fail. Rebased onto main for its pull request: the two dev/output plan files are dropped (decision 0028; they stay in fix/revert-cppbool's history), CLAUDE.md and .gitignore keep main's text beside this commit's, and CHANGELOG gains the [Unreleased] entry for the Kinesis boolean split. Co-Authored-By: Claude Opus 5 (1M context) Co-Authored-By: Claude Opus 5.5 --- .gitignore | 2 + CHANGELOG.md | 10 ++++ CLAUDE.md | 47 +++++++++++++++++ .../tcube_laser/constants_Tlaser.jl | 51 ++++++++++--------- 4 files changed, 86 insertions(+), 24 deletions(-) diff --git a/.gitignore b/.gitignore index c55a1b8..ae24170 100644 --- a/.gitignore +++ b/.gitignore @@ -5,5 +5,7 @@ /docs/Manifest.toml /docs/build/ .DS_Store +# local-only symlink to the lab instrument archive on the NAS (see CLAUDE.md) +/manuals # Local run output: test records, logs, handoffs (lab decision 0028) dev/output/ diff --git a/CHANGELOG.md b/CHANGELOG.md index b6a94c3..ff5f9fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,16 @@ the next version with `-DEV`). ## [Unreleased] ### Fixed +- **TCube laser: Kinesis boolean arguments are passed as four bytes.** Every + Kinesis boolean was bound as a one-byte `Bool`. That is right for return + values, but the headers declare arguments as a four-byte type, so the + controller could read three bytes of whatever the register held. Arguments + (`LD_EnableMaxCurrentAdjust`, `LD_EnableTIAGainAdjust`, + `LD_EnableLastMsgTimer`) are now a zero-extended `Cuint` (`KBOOL_ARG`), and + returns stay one byte (`KBOOL_RET`); the vendor facts are in + `manuals/Thorlabs/TLD001/BINDING.md` (see CLAUDE.md, "Instrument + documentation archive"). No driver method calls those three functions yet, + so no current behaviour changes; not yet run on hardware. - **PI stage: `initialize` no longer finishes on an unreferenced stage.** It ignored the return of the reference move (`PI_FRF`), so when the controller rejected it (GCS error 5, e.g. one axis's servo off) the later move to the diff --git a/CLAUDE.md b/CLAUDE.md index 31f4b85..5c4a248 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -186,3 +186,50 @@ pull request that raises `Z`, and merge the same fix into `main`. hand, after checking the same `lab/tests` coverage. What the numbers mean (decision 0033): before 1.0, in `0.Y.Z` **raising `Z` is any non-breaking change, new features included, and raising `Y` is an interface break** -- `0.2.4 -> 0.3.0` declares a break and `0.2.4 -> 0.2.5` a compatible release, which is also how Julia's `^0.2` compat bound reads them. A break is anything that lets working downstream code behave differently: a signature, an export, or what a call returns or throws. A bug fix that changes behaviour only on a path that was already broken is not a break. Config types are built by keyword (lab decision 0035): adding a field with a default is not a break, and a positional argument's meaning never changes (add a keyword and deprecate the old form instead). Hardware verification is not tracked in this repo; it is recorded by the downstream rig repo that pins to a given tag. The merge gate is the local suite (see "Testing policy" above) plus `test/contract.jl`'s "Interface Contract" testset, which guards the no-ambiguous-exports and core-method invariants described above; CI confirms it on a reduced matrix. + +## Instrument documentation archive (`manuals/`) + +`manuals/` is a **local-only symlink** to the lab-wide instrument archive on +the NAS — vendor manuals, SDK headers, API references and the driver-facing +notes that explain why a binding is the way it is. It is gitignored and must +**never** be committed: git stores a symlink as a mode-120000 blob, and on a +Windows rig without the symlink privilege that checks out as a text file +containing the path, which is worse than nothing. Each clone makes its own. + +```bash +# Linux (any of the four hosts) +ln -sfn /mnt/nas/lidkelab/Projects/lab_instruments manuals +``` +```bat +REM Windows rig, from the repo root. Needs an elevated prompt, OR Developer +REM Mode enabled once (Settings > Privacy & security > For developers). +mklink /D manuals \\192.168.1.21\lidke-lrs\Projects\lab_instruments +``` + +`[limitation]` The junction form, `mklink /J`, does **not** work here: junctions +resolve only to local volumes, so a UNC target fails. `/D` is required, and it +is the one step in this arrangement that needs a privilege — once per rig, not +once per clone. This has not been run on either rig yet; if `/D` is refused, +say so rather than reaching for a mapped drive letter, which differs between +user sessions and services. + +Nothing else is required — no environment variable and no shell profile edit. +If `manuals/` is absent, make it with the line above. + +Layout is manufacturer first, then model: `manuals/Thorlabs/TLD001/`, +`manuals/Hamamatsu/C11440-22CU/`, with shared vendor SDKs under +`manuals//SDK//`. Start at `manuals/INDEX.md`; the rules +for adding anything are in `manuals/README.md`. + +Within a model directory, `source/` is the vendor original, verbatim and never +renamed, and `docs/` is the working copy with a predictable name. A +`BINDING.md`, where one exists, is the distillate a driver author actually +needs — for example `manuals/Thorlabs/TLD001/BINDING.md` records that a Kinesis +C++ boolean must be *passed* as a 4-byte `Cuint` but *read back* as a 1-byte +`Bool`, which this package got wrong twice in opposite directions. + +`[policy]` When a driver's behaviour turns on a vendor fact -- a struct layout, +an ABI width, a scaling constant, a status bit -- record it in that model's +`BINDING.md` and cite the document in `source/` it came from. Three of the four +defects in v0.2.3 were found by a rig holding hardware rather than by review, +because the vendor fact was not written down anywhere a reviewer could check. diff --git a/src/hardware_implementations/tcube_laser/constants_Tlaser.jl b/src/hardware_implementations/tcube_laser/constants_Tlaser.jl index 1f675c3..9206591 100644 --- a/src/hardware_implementations/tcube_laser/constants_Tlaser.jl +++ b/src/hardware_implementations/tcube_laser/constants_Tlaser.jl @@ -8,6 +8,32 @@ const __int64 = Clonglong const __int32 = Cint +""" + BOOL + +Four bytes. **Retained for `TLI_DeviceInfo`'s fields only**, which nothing in +this package calls — see that struct's docstring. Do not use it for a new +binding: use [`KBOOL_ARG`](@ref) for an argument and [`KBOOL_RET`](@ref) for a +return. + +`[limitation]` The vendor headers do **not** declare `BOOL` for these fields. +Two rigs have now read their own vendor-installed +`Thorlabs.MotionControl.TCube.LaserDiode.h` — Kinesis 1.14.10 on the seq-sr rig +and 1.14.47.22504 on quickbeam — and **both declare lowercase C++ `bool` with +`#pragma pack(1)` active**. This `Cuint` is therefore wrong for +`TLI_DeviceInfo`, and is left in place only because no single layout fits both +versions anyway (they disagree on `serialNo`'s length), so there is nothing to +change it *to*. + +**History, because this was got wrong twice in opposite directions.** v0.2.3 +retyped both boolean roles to a one-byte `Bool`. A copy of the header on the lab +NAS appeared to contradict that, carrying `typedef unsigned int BOOL` and no +lowercase `bool` at all, and on its strength the change was reverted — but that +file is a Clang.jl generation input, hand-edited to parse without the Windows +SDK, and its typedefs are artefacts of the editing rather than the vendor's ABI. +Splitting the two roles is the correct answer, and the vendor-installed headers +since read off both rigs confirm it. +""" const BOOL = Cuint """ @@ -48,29 +74,6 @@ is what is actually correct, and is safe under either reading. """ const KBOOL_RET = Bool -""" - BOOL - -Retained for `TLI_DeviceInfo`'s fields only. Note the Kinesis headers -`typedef unsigned int BOOL` (verified at line 37 of -`Thorlabs.MotionControl.TCube.LaserDiode.h`), so this is four bytes, and every -`BOOL` argument and return in `functions_Tlaser.jl` is that width. - -**History, because this was got wrong once.** v0.2.3 changed these to a -one-byte `Bool` on a report that the header declared C++ `bool`. It does not: -that header contains no lowercase `bool` at all. The change was reverted -because it is wrong in the dangerous direction for ARGUMENTS — passing one -byte where the callee reads four leaves the upper three undefined, so a -`false` can arrive as true. `LD_EnableMaxCurrentAdjust(serial, true, false)` -is the call that matters: its second flag enables the laser diode during a -max-current adjustment. - -If a future Kinesis version really does declare `bool`, check the header for -that version before changing this, and change arguments and returns -separately — four bytes is safe for an argument under either ABI, one byte is -not. -""" - struct tagSAFEARRAYBOUND cElements::Culong lLbound::Clong @@ -118,7 +121,7 @@ layout that would match both.** Two rigs read their installed headers and reported different declarations: -| | Kinesis 1.14.10 (405 rig) | the 642 rig's install | +| | Kinesis 1.14.10 (seq-sr rig) | Kinesis 1.14.47.22504 (quickbeam) | |---|---|---| | `serialNo` | `char serialNo[9]` | `char serialNo[16]` | | flags | 1-byte C++ `bool` | 1-byte C++ `bool` | From 4720e1b996ce63b13713b45385651fa267c2c9e0 Mon Sep 17 00:00:00 2001 From: kalidke Date: Tue, 29 Sep 2026 12:23:16 -0600 Subject: [PATCH 28/37] TCubeLaser: one cleanup helper for a failure while the diode may be lit; 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 --- .../tcube_laser/interface_methods.jl | 91 +++++++++++++------ test/runtests.jl | 42 +++++++++ 2 files changed, 104 insertions(+), 29 deletions(-) diff --git a/src/hardware_implementations/tcube_laser/interface_methods.jl b/src/hardware_implementations/tcube_laser/interface_methods.jl index a73e05a..a7f66b1 100644 --- a/src/hardware_implementations/tcube_laser/interface_methods.jl +++ b/src/hardware_implementations/tcube_laser/interface_methods.jl @@ -664,7 +664,9 @@ simplified away: If any step after `LD_Open` fails, the handle is closed before the error propagates -- a half-open controller refuses the next `LD_Open` and so blocks -the retry -- and the original error is the one raised. A power-mode failure +the retry -- and the original error is the one raised. If the +initial disable itself fails, `properties.is_on` is set `true`, since the diode may +still be lit, and the same cleanup stops polling and closes. A power-mode failure leaves the output off. It sets `pd.max_current_clamp` to `NaN` first, so a failed re-initialize cannot leave a stale clamp. @@ -692,7 +694,13 @@ function initialize(light::TCubeLaser) sleep(REQUEST_WAIT_S[]) # Whatever else left the output on, it is off before the mode command # is sent, and `is_on` is false afterwards. - zero_then_disable(light) + try + zero_then_disable(light) + catch + light.properties.is_on = true + @error "TCubeLaser $serialNo: initialize could not disable the output; it may still be ON at the controller's stored setpoint" + rethrow() + end enter_mode!(regulation_mode(light), light) check_err(LD_RequestReadings(serialNo), "LD_RequestReadings", serialNo) # The diode current limit has its OWN request in the Kinesis API, and @@ -781,8 +789,9 @@ been requested yet, setpoint 0 is sent, with a warning. In `ConstantCurrent` mode `drive_current` is checked against the current ceiling before the enable, so nothing is sent if that throws. -If the setpoint cannot be sent or confirmed after the enable, the output is -disabled again and the original error rethrown. `properties.is_on` is then what +If the setpoint cannot be sent or confirmed after the enable, the setpoint is +zeroed, the output is disabled again and the original error rethrown +([`disable_after_failure`](@ref)). `properties.is_on` is then what the cleanup left: `false` if the disable succeeded, `true` if it failed too (both failures are logged), so a failed `light_on` never records a lit diode as off. In `ConstantPhotocurrent` mode the setpoint is ramped from 0 when the laser @@ -794,6 +803,12 @@ is disabled and the error rethrown. stored setpoint, bounded in hardware only by its current-limit potentiometer; see [`TCubeLaser`](@ref). +`[limitation]` Called while the output is already on with a finite +`ramp_step_mW`, the ramp starts from 0 again, so the output dips to one step +and ramps back up. That is deliberate: the ramp starts only from what the driver +knows the controller holds, and a value set from the front panel would make any +other starting point a jump. + A `ConstantPhotocurrent` laser refuses until `initialize` has programmed and verified its clamp (`pd.max_current_clamp` is not `NaN`), and re-checks the controller before it emits: a fresh status read must report closed loop, and a @@ -811,24 +826,10 @@ function LightSourceInterface.light_on(light::TCubeLaser) code = intended_code(light) check_err(LD_EnableOutput(serialNo), "LD_EnableOutput", serialNo) try - send_setpoint_ramped(light, code, 0) # the driver zeroes before every disable + send_setpoint_ramped(light, code, 0) # the ramp starts from 0: every disable in this driver zeroes first check_lock(light, code) catch - disabled, offerr = false, nothing - try - status = LD_DisableOutput(serialNo) - disabled = status == 0 - disabled || (offerr = "status $status") - catch err - offerr = err - end - if disabled - light.properties.is_on = false - @error "TCubeLaser $(serialNo): setpoint after enable failed; output disabled" - else - light.properties.is_on = true - @error "TCubeLaser $(serialNo): setpoint after enable failed and the disable failed ($offerr); the output may still be ON at the controller's stored setpoint" - end + disable_after_failure(light, "setpoint after enable") rethrow() end light.properties.is_on = true @@ -884,6 +885,10 @@ is the rule this driver will not break -- but `drive_current` records the On success, `light.drive_current` holds the accepted current, in mA, and the deprecated `properties.power` holds [`legacy_power`](@ref)'s figure, as it did in 0.2.4. + +`[limitation]` With the output on, a setpoint the controller does not confirm +throws and leaves the output on, at a setpoint that may be the new or the old +one. There is no cleanup: 0.2.4's `setpower` never disabled on a failed write. """ function LightSourceInterface.setcurrent!(light::TCubeLaser{ConstantCurrent}, current::Float64) check_current(light, current) @@ -919,9 +924,10 @@ Command the optical power at the laser output, in mW -- the plane where ([`send_setpoint_ramped`](@ref), ramping from the code of the previous request, `0` if none); with it off, leave it for `light_on` ([`send_setpoint`](@ref)). -6. With the output on, [`check_lock`](@ref) after sending. If the loop looks - locked, zero and disable the output (a failure of that is logged and does - not mask the lock error), then throw; the request is not recorded. +6. With the output on, [`check_lock`](@ref) after sending. A send or confirm + failure, or a suspected lock, zeroes and disables the output, logs, and + rethrows ([`disable_after_failure`](@ref)); `properties.is_on` is `true` + afterwards only if the disable failed. The request is not recorded. 7. Record `pd.output_power_requested` and the DECODED `pd.photocurrent_requested`. `[limitation]` the lock check's threshold and wait are unvalidated on hardware @@ -951,15 +957,11 @@ function LightSourceInterface.setoutputpower!(light::TCubeLaser{ConstantPhotocur if on # What the controller holds is the previous request (read BEFORE `pd` is updated). from = isnan(pd.photocurrent_requested) ? 0 : Int(photocurrent_code(light, pd.photocurrent_requested)) - send_setpoint_ramped(light, code, from) try + send_setpoint_ramped(light, code, from) check_lock(light, code) catch - try - zero_then_disable(light) - catch offerr - @error "TCubeLaser $serialNo: loop lock suspected and the disable failed; the output may still be ON" exception = offerr - end + disable_after_failure(light, "setoutputpower! (setpoint or lock check)") rethrow() end end @@ -992,6 +994,37 @@ function zero_then_disable(light::TCubeLaser) return nothing end +""" + disable_after_failure(light::TCubeLaser, what) + +Cleanup after `what` failed while the output may be lit: zero the setpoint, then +disable the output, never throwing. `properties.is_on` becomes `false` if the +disable succeeded and `true` if it failed, since the diode may still be lit. One +`@error` names `what` and every cleanup failure. The caller rethrows its own +error. The zero comes first for the reason [`zero_then_disable`](@ref) gives: +the next enable must start dark, not on a stale setpoint. +""" +function disable_after_failure(light::TCubeLaser, what::AbstractString) + serialNo = light.serialNo + zeroed = zero_setpoint(light) + disabled, offerr = false, nothing + try + status = LD_DisableOutput(serialNo) + disabled = status == 0 + disabled || (offerr = "status $status") + catch err + offerr = err + end + light.properties.is_on = !disabled + zeronote = isnothing(zeroed) ? "" : "; zeroing the setpoint failed ($zeroed)" + if disabled + @error "TCubeLaser $serialNo: $what failed; output disabled$zeronote" + else + @error "TCubeLaser $serialNo: $what failed and the disable failed ($offerr)$zeronote; the output may still be ON at the controller's stored setpoint" + end + return disabled +end + """ zero_setpoint(light::TCubeLaser) diff --git a/test/runtests.jl b/test/runtests.jl index bae4c7d..c501745 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -879,6 +879,48 @@ lab_summary("Core") do FakeKinesis.reset!() end + @testset "cleanup after a failure while the output may be lit (fake SDK)" begin + FK = FakeKinesis + enabled() = FK.bits[] & FK.ENABLED != 0 + ready_cc() = (FK.reset!(); l = cc(); initialize(l); setcurrent!(l, 10.0); l) + ready_cp() = (FK.reset!(); FK.limit_follows_pot[] = true; l = cp(); initialize(l); + setoutputpower!(l, 10.0); light_on(l); l) + # light_on: the setpoint after the enable is not confirmed. It is zeroed before the disable. + laser = ready_cc() + FK.setpoint_readback[] = UInt16(3) + @test_logs (:error, r"output disabled") match_mode = :any @test_throws ErrorException light_on(laser) + last_off = findlast(==("LD_DisableOutput"), FK.calls) + @test FK.calls[last_off-1] == "LD_SetLaserSetPoint" + @test FK.setpoints[end] == 0 && FK.setpoints[end-1] != 0 + @test laser.properties.is_on == false && !enabled() + FK.reset!() + # setoutputpower! with the output on and the setpoint not confirmed. + laser = ready_cp() + FK.setpoint_readback[] = UInt16(3) + @test_throws ErrorException setoutputpower!(laser, 20.0) + @test !FK.output_on[] && !enabled() + last_off = findlast(==("LD_DisableOutput"), FK.calls) + @test FK.calls[last_off-1] == "LD_SetLaserSetPoint" && FK.setpoints[end] == 0 + @test laser.properties.is_on == false + @test laser.pd.output_power_requested == 10.0 + FK.reset!() + # ... and when the disable fails too, the output may still be on. + laser = ready_cp() + FK.setpoint_readback[] = UInt16(3) + FK.fail!("LD_DisableOutput") + @test_logs (:error, r"may still be ON") match_mode = :any @test_throws ErrorException setoutputpower!(laser, 20.0) + @test laser.properties.is_on == true + @test laser.pd.output_power_requested == 10.0 + FK.reset!() + # initialize: a disable that fails throws, records the output as possibly on, and closes. + laser = cc() + FK.fail!("LD_DisableOutput") + @test_logs (:error, r"may still be ON") match_mode = :any @test_throws ErrorException initialize(laser) + @test laser.properties.is_on == true + @test FK.calls[end] == "LD_Close" + FK.reset!() + end + @testset "readbacks (fake SDK)" begin FakeKinesis.reset!() laser = cc(; threshold_current=65.0) From 6e4f535d066f19c0038650545af4519b1e70ea57 Mon Sep 17 00:00:00 2001 From: kalidke Date: Tue, 29 Sep 2026 12:23:59 -0600 Subject: [PATCH 29/37] TCubeLaser: default properties back to 0.2.4's; drop the power_unit changelog entry Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 3 --- skills/mc-extend/references/rig-causes.md | 2 +- .../SimulatedDiodeLaser.jl | 4 ++++ .../tcube_laser/types.jl | 19 +++++++++++-------- test/runtests.jl | 9 +++++---- 5 files changed, 21 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bba8759..fef722c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,9 +26,6 @@ the next version with `-DEV`). `initialize` starts clean. `referencemove`'s signature and return are unchanged. Reported by the MicroscopeAdapt rig; not yet run on hardware (#64). -- **`TCubeLaser`'s default `properties.power_unit` is now `"mA"` in open loop.** - It said `"mW"` while the values were always mA. Rigs that read `power_unit` - should check it; both known rigs already pass `"mA"`. ### Changed (release process) - **`main` is the development branch**, carrying the next version with diff --git a/skills/mc-extend/references/rig-causes.md b/skills/mc-extend/references/rig-causes.md index 0896393..3ae7973 100644 --- a/skills/mc-extend/references/rig-causes.md +++ b/skills/mc-extend/references/rig-causes.md @@ -17,7 +17,7 @@ and each looks like a broken `ccall`. | `setpower(::TCubeLaser, x)` warns "deprecated" after moving the pin to 0.2.5, or throws "setpower is not defined for TCubeLaser{...}" | **[guarantee]** Not a fault. On a `ConstantCurrent` laser (the default `mode`, which is what every 0.2.4 construction line builds) `setpower(laser, mA)` still works: it forwards to `setcurrent!` with a deprecation warning, and the mode is fixed at construction so the unit cannot change. On a `ConstantPhotocurrent` laser it throws and names `setoutputpower!`, because an mA call must never become an mW one. | Replace `setpower(laser, mA)` with `setcurrent!(laser, mA)`; on a `mode = ConstantPhotocurrent()` laser use `setoutputpower!(laser, mW)`; or `setlevel!(laser, frac)` (unit-free `0..1`) for code that must work in either mode. | | `setcurrent!` (formerly `setpower`) on a `TCubeLaser` drives more current than asked for, or rejects a small current | **[fixed in v0.2.3]** the range check only logged `@error` and then sent the setpoint anyway, so an out-of-range request reached the diode; and `min_current` defaulted to **60.0 mA**, a lower bound that rejected safe small currents while protecting nothing. From v0.2.3 the check throws an `ArgumentError` before any setpoint is computed, and `min_current` defaults to `0.0`. | On 0.2.5, `setcurrent!(laser, 500.0)` throws (on v0.2.3-v0.2.x, `setpower`). On an older pinned tag, do the range check in your own system code before calling. | | A `TCubeLaser` rig ceiling is ignored after `initialize` | **[fixed in v0.2.3]** `initialize` overwrote `light.max_current` with the controller's own limit (160-220 mA), so a caller's `max_current=80.0` was gone by the time `setpower` validated against it. From v0.2.3 the controller's value goes to the new `controller_max_current` field and the current setter (`setcurrent!` from 0.2.5) enforces the smallest of `max_current`, `controller_max_current` and `max_setcurrent`. | Print `laser.max_current` after `initialize`: on an older tag it equals the controller's limit, not yours. | -| The laser's power does not match a power meter | `properties.power` on a `TCubeLaser` is an uncalibrated linear guess, not in `power_unit`'s unit; **[guarantee]** 0.2.5 deprecates it (an open-loop `setcurrent!` still writes it as 0.2.4 did, a `ConstantPhotocurrent` laser never does; `export_state` keeps it) and the default `power_unit` is now `"mA"`. In `ConstantCurrent` mode the driver knows only current (`drive_current`, requested; `measured_current`, read back); no optical number exists. In `ConstantPhotocurrent` mode the power is `setoutputpower!`'s mW **at the laser output**, converted through `wa_calibration`: wrong if the calibration is stale, the TIA DIP switch moved, or you meter at the sample (the fibre and optics are not in it). **[limitation]** without a TEC-stabilised mount the photodiode's responsivity drifts, so the delivered power drifts while the loop holds photocurrent and `indicated_output_power` stays flat. | Meter at the plane the calibration was measured at (before the fibre on the 642 nm rig). Recalibrate per `src/hardware_implementations/tcube_laser/CALIBRATION.md` in the package source. Read `indicated_output_power(laser)` (a conversion, not a measurement) and `measured_current(laser)`: a drive current climbing at a still setpoint is a blocked photodiode or an ageing diode. | +| The laser's power does not match a power meter | `properties.power` on a `TCubeLaser` is an uncalibrated linear guess, not in `power_unit`'s unit; **[guarantee]** 0.2.5 deprecates it (an open-loop `setcurrent!` still writes it as 0.2.4 did, a `ConstantPhotocurrent` laser never does; `export_state` keeps it); the default `properties` are 0.2.4's (`"mW"`, labels only in open loop). In `ConstantCurrent` mode the driver knows only current (`drive_current`, requested; `measured_current`, read back); no optical number exists. In `ConstantPhotocurrent` mode the power is `setoutputpower!`'s mW **at the laser output**, converted through `wa_calibration`: wrong if the calibration is stale, the TIA DIP switch moved, or you meter at the sample (the fibre and optics are not in it). **[limitation]** without a TEC-stabilised mount the photodiode's responsivity drifts, so the delivered power drifts while the loop holds photocurrent and `indicated_output_power` stays flat. | Meter at the plane the calibration was measured at (before the fibre on the 642 nm rig). Recalibrate per `src/hardware_implementations/tcube_laser/CALIBRATION.md` in the package source. Read `indicated_output_power(laser)` (a conversion, not a measurement) and `measured_current(laser)`: a drive current climbing at a still setpoint is a blocked photodiode or an ageing diode. | | `initialize(::TCubeLaser{ConstantPhotocurrent})` throws "refusing closed loop: key switch (0x2) and/or interlock (0x8) not set" | Key switch off or interlock open on the TLD001. **[guarantee]** power-mode `initialize` requires both before it touches the loop. | Turn the key, close the interlock, re-run `initialize`. | | `initialize` in power mode throws "the controller's photodiode range is X A but tia_range states Y A" or "reports no single photodiode range" | The rear-panel TIA DIP switch does not match the `tia_range` keyword. **[guarantee]** refused because the W/A calibration is valid only on the range it was measured on. | Set the switch to the calibrated range (`10e-6`, `100e-6`, `1e-3` or `10e-3` A); do not just edit `tia_range` unless you recalibrate on the new range. | | `initialize` in power mode throws about the max-current potentiometer or clamp | **[guarantee]** `initialize` leaves the max-current potentiometer at the highest position whose limit, **as the controller reports it**, is `<= max_current`, and records that reported limit in `pd.max_current_clamp`. Hardware-verified on the 642 nm rig (2026-09-28): a plain pot write is ignored, adjust mode is required with a pause before leaving it, and the header's position-to-mA scale is wrong for that unit, so no clamp value is computed from a position. The pot position does not survive a controller power cycle. | Report it with the message, the pinned tag and the controller firmware; until then run `ConstantCurrent`. `light_on`/`setoutputpower!` refuse while `laser.pd.max_current_clamp` is `NaN`. | diff --git a/src/hardware_implementations/simulated_diode_laser/SimulatedDiodeLaser.jl b/src/hardware_implementations/simulated_diode_laser/SimulatedDiodeLaser.jl index 8bbb5a1..8f531fd 100644 --- a/src/hardware_implementations/simulated_diode_laser/SimulatedDiodeLaser.jl +++ b/src/hardware_implementations/simulated_diode_laser/SimulatedDiodeLaser.jl @@ -101,6 +101,10 @@ In `ConstantPhotocurrent` mode `wa_calibration`, `tia_range`, `tec_stabilised`, checks for a loop lock; it only stores them. `threshold_current` defaults to 65 mA and, in `ConstantCurrent` mode, `max_current` to 160 mA (the 642 nm diode's numbers); the model fields are documented on the type. + +Unlike `TCubeLaser`, whose default `properties` are 0.2.4's `"mW"` labels, the +default `properties` here are labelled `"mA"`: this type is new, has no 0.2.x +behaviour to keep, and its values are mA. """ function SimDiodeLaser(; mode::RegulationMode=ConstantCurrent(), diff --git a/src/hardware_implementations/tcube_laser/types.jl b/src/hardware_implementations/tcube_laser/types.jl index f423c1b..4e55976 100644 --- a/src/hardware_implementations/tcube_laser/types.jl +++ b/src/hardware_implementations/tcube_laser/types.jl @@ -21,13 +21,15 @@ so that an 80 mA call can never become an 80 mW one. - `properties::LightSourceProperties`: `is_on` is the requested output state. In `ConstantPhotocurrent` mode `min_power`/`max_power` are the ENFORCED bounds of `setoutputpower!`, in mW at the laser output, and the endpoints of - `setlevel!`. In `ConstantCurrent` mode they are not read. `power` is - deprecated: an open-loop `setcurrent!` writes it as 0.2.4's figure + `setlevel!`. In `ConstantCurrent` mode `properties` is labels only: nothing + checks or enforces `min_power`/`max_power`. The default is 0.2.4's + `LightSourceProperties("mW", 0.0, false, 0.0, 100.0)`, kept for compatibility + although this driver's values are mA, so pass `LightSourceProperties("mA", ...)` + to label them truthfully. `power` is deprecated: an open-loop `setcurrent!` + writes it as 0.2.4's figure `drive_current * properties.max_power / `, an uncalibrated - guess that is NOT in `power_unit`'s unit (the `"mA"` label covers only - `min_power`/`max_power` in open loop). With the default `properties`, whose - `max_power` is now `max_current`, the figure differs from 0.2.4's - default-properties value; `drive_current` is the number to read. A + guess that is NOT in `power_unit`'s unit, and with the default `properties` it + is the same figure 0.2.4 gave; `drive_current` is the number to read. A `ConstantPhotocurrent` laser never writes `power`. - `laser_color::String`: The color of the laser. - `min_current::Float64`: The lowest drive current this rig will command, in @@ -180,7 +182,8 @@ are required: a rig cannot reach power mode without a measured calibration, a stated amplifier range, an answer to the temperature question and an explicit clamp. In `ConstantCurrent` mode passing any of the first three throws, and `properties` -defaults to `LightSourceProperties("mA", 0.0, false, min_current, max_current)`. +defaults to 0.2.4's `LightSourceProperties("mW", 0.0, false, 0.0, 100.0)`: labels +only in this mode, nothing enforces them (see the `properties` field). - `max_current` is **your** ceiling for this diode and is never overwritten. In `ConstantCurrent` mode it defaults to `160.0`; in `ConstantPhotocurrent` mode @@ -243,7 +246,7 @@ function TCubeLaser(serialNo::String; nothing end max_current = something(max_current, 160.0) - props = something(properties, LightSourceProperties("mA", 0.0, false, min_current, max_current)) + props = something(properties, LightSourceProperties("mW", 0.0, false, 0.0, 100.0)) TCubeLaser{typeof(mode)}(unique_id, props, laser_color, min_current, max_current, max_setcurrent, max_setpoint, serialNo, task_mod, daq, controller_max_current, daq_device, ao_channel, drive_current, diff --git a/test/runtests.jl b/test/runtests.jl index c501745..c0af2f1 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -161,8 +161,9 @@ lab_summary("Core") do @test isnan(laser.drive_current) @test isnan(laser.threshold_current) @test laser.pd === nothing - # The unit label no longer says mW for a laser commanded in mA. - @test laser.properties.power_unit == "mA" + # The default properties are 0.2.4's, labels only in open loop. + @test laser.properties.power_unit == "mW" + @test laser.properties.min_power == 0.0 && laser.properties.max_power == 100.0 @test laser.daq_device === nothing @test laser.ao_channel === nothing end @@ -1144,8 +1145,8 @@ lab_summary("Core") do @test haskey(attrs, k) end @test attrs["min_current"] == 0.0 && attrs["max_current"] == 160.0 - @test attrs["power_unit"] == "mA" - @test attrs["max_power"] == 160.0 + @test attrs["power_unit"] == "mW" + @test attrs["max_power"] == 100.0 # setcurrent! writes 0.2.4's deprecated figure to properties.power. FakeKinesis.reset!() lp = cc() From c0231afe4ba1f55b7052131d580580ae8c484ab8 Mon Sep 17 00:00:00 2001 From: kalidke Date: Tue, 29 Sep 2026 12:25:58 -0600 Subject: [PATCH 30/37] Open loop: warn below the pot's floor instead of failing; lowering by construction, never raising Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 26 ++++++++++++--- .../tcube_laser/interface_methods.jl | 32 +++++++++++++------ test/runtests.jl | 19 +++++++++++ test/tcube_fake_sdk.jl | 5 +++ 4 files changed, 69 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fef722c..2c2e5cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -195,12 +195,30 @@ setlevel!(laser, 0.4) range, measured before the fibre, with its closed-loop verification table). ### Changed +- **`TCubeLaser.initialize` starts background polling** (`LD_StartPolling`, + every `POLL_INTERVAL_MS`); `shutdown` and a failed `initialize` stop it + (`LD_StopPolling`). Not yet run on hardware; needs the 642 nm rig check. - **`TCubeLaser.initialize` now zeroes and disables the output** in both modes, before the mode command, so it never sends a mode command with the diode lit - and `properties.is_on` is false afterwards. Not yet run on hardware. -- **Open loop: `initialize` lowers the controller's max-current potentiometer - when its limit is above `max_current`**, and never raises it. Not yet run on - hardware in open loop. + and `properties.is_on` is false afterwards. Not yet run on hardware; needs the + 642 nm rig check. +- **Open loop: `initialize` may lower the controller's max-current + potentiometer** when its limit is above `max_current`, and never raises it, by + construction (the search's upper bound is the starting position). Not yet run + on hardware; needs the 642 nm rig check. +- **Open loop: a `max_current` below the potentiometer's floor** (about + 17.25 mA) warns that the ceiling is enforced in software only and leaves the + potentiometer alone, as in 0.2.4. Not yet run on hardware; needs the 642 nm + rig check. +- **`light_on` and `setcurrent!` wait for the controller's setpoint read-back** + (up to `SETPOINT_CONFIRM_TIMEOUT_S`, 1 s) and throw if it does not confirm; + `light_on` then zeroes and disables the output. Not yet run on hardware; needs + the 642 nm rig check. +- **`setcurrent!` with the output off sends nothing** (0.2.4 sent a setpoint the + controller ignored); `light_on` applies it after the enable. Not yet run on + hardware; needs the 642 nm rig check. +- **`gui(laser)` on an open-loop `TCubeLaser` opens the diode-laser current + panel** (mA). Not yet run on hardware; needs the 642 nm rig check. - **Power mode re-checks the controller before emitting**: closed-loop `light_on` and `setoutputpower!` read the status word and the current limit afresh and refuse if the loop bit is gone or the limit is above `max_current` diff --git a/src/hardware_implementations/tcube_laser/interface_methods.jl b/src/hardware_implementations/tcube_laser/interface_methods.jl index a7f66b1..ccc9998 100644 --- a/src/hardware_implementations/tcube_laser/interface_methods.jl +++ b/src/hardware_implementations/tcube_laser/interface_methods.jl @@ -254,10 +254,19 @@ Open loop only, run by `initialize` after the controller's limit is recorded: if `light.controller_max_current`. If the controller's limit is at or below `max_current` the potentiometer is not touched: a rig that lowered it by hand keeps it. Closed loop programs its clamp in `enter_mode!` and this does nothing. + +If `max_current` is below the potentiometer's floor ([`DIGPOT_MIN_mA`](@ref), +about 17.25 mA) no position can clamp to it: it warns, leaves the potentiometer +alone, and `max_current` is enforced in software only, as in 0.2.4. The search +runs with `raise = false`, so no position above the starting one is ever set. """ function lower_open_loop_clamp!(light::TCubeLaser{ConstantCurrent}) light.controller_max_current > light.max_current || return nothing - light.controller_max_current = program_clamp!(light) + if light.max_current < DIGPOT_MIN_mA + @warn "TCubeLaser $(light.serialNo): max_current = $(light.max_current) mA is below the lowest limit the controller's potentiometer can be set to (about $(round(DIGPOT_MIN_mA; digits=2)) mA). The potentiometer is left alone: the controller's own limit stays $(light.controller_max_current) mA and max_current is enforced in software only (setcurrent! refuses above it), as in 0.2.4. Anything outside this driver can still drive the diode to the controller's limit." + return nothing + end + light.controller_max_current = program_clamp!(light; raise = false) return nothing end lower_open_loop_clamp!(light::TCubeLaser{ConstantPhotocurrent}) = nothing @@ -298,7 +307,8 @@ const DIGPOT_STEP_ESTIMATE_mA = 220.0 / 255 The lowest clamp the header's scale allows, `20 * 220 / 255` = 17.25 mA. A diode whose ceiling is below it cannot be clamped, so it cannot be built in -power mode. (In adjust mode the rig's controller reported 16.74 mA at position +power mode; in open loop `initialize` only warns +([`lower_open_loop_clamp!`](@ref)). (In adjust mode the rig's controller reported 16.74 mA at position 20, so this is a conservative floor.) """ const DIGPOT_MIN_mA = DIGPOT_MIN_POS * 220.0 / 255 @@ -356,7 +366,7 @@ function set_digpot!(light::TCubeLaser, position::Int) end """ - program_clamp!(light::TCubeLaser) + program_clamp!(light::TCubeLaser; raise::Bool=true) Leave the controller's diode current limit at the highest potentiometer position whose limit, **as the controller reports it**, does not exceed @@ -372,16 +382,18 @@ it cannot settle. Output must be off (it is, in `initialize`). In `ConstantPhotocurrent` mode `initialize` calls it to program the clamp. In `ConstantCurrent` mode `initialize` calls it through [`lower_open_loop_clamp!`](@ref) only when the controller's limit is above -`max_current`: the search then starts at a position whose limit is over the -ceiling, so every move stays below that position and it can only LOWER the -clamp. It never raises the potentiometer. +`max_current`, and with `raise = false`: the upper bound of the search is the +starting position, so no position above it is ever set, whatever the reads +return. It never raises the potentiometer in that mode. The default, +`raise = true`, is closed loop's search, which may move up toward the ceiling. `[limitation]` lowering the open-loop potentiometer is not validated on hardware beyond the 642 nm rig's closed-loop sequence; not yet run on hardware in open loop. """ -function program_clamp!(light::TCubeLaser) +function program_clamp!(light::TCubeLaser; raise::Bool=true) ceiling = light.max_current pos = read_digpot(light.serialNo) + upper = raise ? DIGPOT_MAX_POS : pos limit = read_limit_mA(light) below = nothing # (position, limit) of the best setting found at or under the ceiling above = nothing # lowest position found over the ceiling @@ -401,7 +413,7 @@ function program_clamp!(light::TCubeLaser) below = (pos, limit) # Up only by whole estimated steps; none fits -> this is it. steps = floor(Int, (ceiling - limit) / DIGPOT_STEP_ESTIMATE_mA) - next = min(pos + steps, DIGPOT_MAX_POS) + next = min(pos + steps, upper) above !== nothing && (next = min(next, above - 1)) next <= pos && return settle() else @@ -634,7 +646,9 @@ write depends on only refreshes through it (see limit is above `max_current`, the potentiometer is then lowered until it is not ([`lower_open_loop_clamp!`](@ref)) and `controller_max_current` is the limit that results; if it is at or below `max_current` the potentiometer is never -touched, so a limit a rig set lower by hand stays. `[limitation]` the open-loop +touched, so a limit a rig set lower by hand stays. If `max_current` is below the +potentiometer's floor ([`DIGPOT_MIN_mA`](@ref)) it warns and leaves the +potentiometer alone; the ceiling is then enforced in software only. `[limitation]` the open-loop potentiometer lowering is unvalidated on hardware beyond the 642 nm rig's closed-loop sequence; not yet run on hardware in open loop. diff --git a/test/runtests.jl b/test/runtests.jl index c0af2f1..486404e 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -922,6 +922,25 @@ lab_summary("Core") do FK.reset!() end + @testset "open loop never newly fails and never raises the pot (fake SDK)" begin + FK = FakeKinesis + # A ceiling below the potentiometer's floor: warns, leaves the pot alone. + FK.reset!() + laser = cc(; max_current=15.0) + @test_logs (:warn, r"software only") match_mode = :any initialize(laser) + @test !("LD_EnableMaxCurrentAdjust" in FK.calls) && !("LD_SetMaxCurrentDigPot" in FK.calls) + @test laser.controller_max_current > 15.0 + # The search's first read differs from initialize's: it reads a limit under the ceiling + # and would raise the pot to 215 if raising were allowed; it may not. + FK.reset!() + laser = cc(; max_current=100.0) + append!(FK.limit_raw_queue, [23830, floor(Int, 90 / 220 * 32767)]) + initialize(laser) + @test all(<=(204), FK.digpot_sets) && FK.digpot[] == 204 + @test isempty(FK.limit_raw_queue) + FK.reset!() + end + @testset "readbacks (fake SDK)" begin FakeKinesis.reset!() laser = cc(; threshold_current=65.0) diff --git a/test/tcube_fake_sdk.jl b/test/tcube_fake_sdk.jl index 204978c..f3636f0 100644 --- a/test/tcube_fake_sdk.jl +++ b/test/tcube_fake_sdk.jl @@ -91,6 +91,9 @@ driver's default scale. """ const diode_limit_raw = Ref{Int}(23830) +"Raw limit values the next `LD_GetLaserDiodeMaxCurrentLimit` reads return, first in first out, before `diode_limit_raw` applies again." +const limit_raw_queue = Int[] + "Raw diode-current reading; `nothing` returns `diode_limit_raw`, as before 0.2.5." const current_raw = Ref{Union{Nothing,Int}}(nothing) @@ -165,6 +168,7 @@ function reset!(; limit_raw::Integer=23830, stored::Integer=0) empty!(throws) empty!(enable_log) diode_limit_raw[] = limit_raw + empty!(limit_raw_queue) current_raw[] = nothing photocurrent_raw[] = 0 bits[] = KEY | INTERLOCK | PSU_OK | TIA_1mA @@ -251,6 +255,7 @@ end end function LD_GetLaserDiodeMaxCurrentLimit(serialNo) Main.FakeKinesis.record!("LD_GetLaserDiodeMaxCurrentLimit") + isempty(Main.FakeKinesis.limit_raw_queue) || return popfirst!(Main.FakeKinesis.limit_raw_queue) Main.FakeKinesis.limit_follows_pot[] || return Main.FakeKinesis.diode_limit_raw[] return floor(Int, Main.FakeKinesis.limit_mA_for(Main.FakeKinesis.digpot[]) / 220 * 32767) end From 29ee845e452e82141604abaa229621be7af6281a Mon Sep 17 00:00:00 2001 From: kalidke Date: Tue, 29 Sep 2026 12:26:18 -0600 Subject: [PATCH 31/37] Open-loop and closed-loop sends follow what the driver knows; correct the limit-drift wording Co-Authored-By: Claude Sonnet 5.5 --- .../tcube_laser/interface_methods.jl | 22 ++++++++++++------- test/runtests.jl | 19 +++++++++++++++- 2 files changed, 32 insertions(+), 9 deletions(-) diff --git a/src/hardware_implementations/tcube_laser/interface_methods.jl b/src/hardware_implementations/tcube_laser/interface_methods.jl index ccc9998..f187aab 100644 --- a/src/hardware_implementations/tcube_laser/interface_methods.jl +++ b/src/hardware_implementations/tcube_laser/interface_methods.jl @@ -826,8 +826,9 @@ other starting point a jump. A `ConstantPhotocurrent` laser refuses until `initialize` has programmed and verified its clamp (`pd.max_current_clamp` is not `NaN`), and re-checks the controller before it emits: a fresh status read must report closed loop, and a -fresh read of the controller's limit must not exceed the programmed clamp by -more than 1 mA (a controller power cycle can restore the pot). +fresh read of the controller's limit must not exceed `max_current`, or the +programmed clamp by more than 0.5 mA, about half a potentiometer step (a +controller power cycle can restore the pot). `[limitation]` those two checks add two request/read round trips (about 2 x `REQUEST_WAIT_S`) to every closed-loop `light_on` and `setoutputpower!`; @@ -880,8 +881,10 @@ Set the diode drive current, in **mA**. Validates through [`check_current`](@ref), so an out-of-range request throws before anything reaches the controller, and encodes through [`setpoint_code`](@ref), whose guarantee is that the *decoded* current never -exceeds the requested one. With the output **on**, the setpoint is sent and -confirmed from the controller's read-back. With the output **off**, it is +exceeds the requested one. With the output **on** -- the driver recorded it on, or the polled status word +reports it -- the setpoint is sent and confirmed from the controller's +read-back. A stale status bit can no longer drop a request silently: the send is +attempted and confirmed, or it throws. With the output **off**, it is recorded and `light_on` applies it right after enabling -- the controller would ignore it now ([`send_setpoint`](@ref)). @@ -907,7 +910,7 @@ one. There is no cleanup: 0.2.4's `setpower` never disabled on a failed write. function LightSourceInterface.setcurrent!(light::TCubeLaser{ConstantCurrent}, current::Float64) check_current(light, current) code = setpoint_code(light, current) - on = output_enabled(light.serialNo) + on = light.properties.is_on || output_enabled(light.serialNo) on && send_setpoint(light, code) light.drive_current = current light.properties.power = legacy_power(light, current) # deprecated; see legacy_power @@ -923,7 +926,8 @@ Command the optical power at the laser output, in mW -- the plane where 1. Refuse unless `initialize` programmed the clamp, and re-check the controller: a fresh status read must report closed loop and a fresh limit read must not - exceed the programmed clamp by more than 1 mA. `[limitation]` this adds two + exceed `max_current`, or the programmed clamp by more than 0.5 mA, about half + a potentiometer step. `[limitation]` this adds two request/read round trips (about 2 x `REQUEST_WAIT_S`) to every call; unvalidated on hardware. 2. [`check_power`](@ref) against `properties.min_power..max_power`. @@ -934,7 +938,9 @@ Command the optical power at the laser output, in mW -- the plane where 4. Convert: photocurrent `= power_mW / 1000 / wa_calibration` A, encoded by [`photocurrent_code`](@ref), rounding DOWN; above full scale it throws naming the DIP switch. -5. With the output on, send and confirm the setpoint +5. With the output on (the driver recorded it on, or the polled status word + reports it; a stale status bit cannot drop the send silently, it is attempted + and confirmed or it throws), send and confirm the setpoint ([`send_setpoint_ramped`](@ref), ramping from the code of the previous request, `0` if none); with it off, leave it for `light_on` ([`send_setpoint`](@ref)). @@ -956,7 +962,7 @@ function LightSourceInterface.setoutputpower!(light::TCubeLaser{ConstantPhotocur check_power(light, power_mW) pd, serialNo = light.pd, light.serialNo bits = UInt32(LD_GetStatusBits(serialNo)) - on = bits & STATUS_BITS.output_enabled != 0 + on = light.properties.is_on || bits & STATUS_BITS.output_enabled != 0 bits & STATUS_BITS.closed_loop != 0 || error( "TCubeLaser $serialNo: setoutputpower! refused: the controller is not in closed loop (status 0x$(string(bits; base=16))); " * "was the mode changed on the front panel? Call initialize again.") diff --git a/test/runtests.jl b/test/runtests.jl index 486404e..864bdbf 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -383,7 +383,8 @@ lab_summary("Core") do # With the output on, a new current is sent and confirmed at once. empty!(FakeKinesis.calls) setcurrent!(laser, 30.0) - @test FakeKinesis.calls == ["LD_GetStatusBits", "LD_SetLaserSetPoint", "LD_GetLaserSetPoint"] + # The driver recorded the output on, so it does not read the status word first. + @test FakeKinesis.calls == ["LD_SetLaserSetPoint", "LD_GetLaserSetPoint"] @test FakeKinesis.setpoint_held[] == TCube.setpoint_code(laser, 30.0) # A setpoint the controller does not confirm is an error, and is @@ -941,6 +942,22 @@ lab_summary("Core") do FK.reset!() end + @testset "a stale status bit cannot drop a setcurrent! (fake SDK)" begin + FK = FakeKinesis + FK.reset!() + laser = cc() + initialize(laser) + setcurrent!(laser, 10.0) + light_on(laser) + FK.setbits!(FK.ENABLED; on=false) # the polled status word no longer reports the output on + n = length(FK.setpoints) + r = try; setcurrent!(laser, 20.0); nothing; catch e; e; end + @test length(FK.setpoints) == n + 1 # the send was attempted, not dropped + @test FK.setpoints[end] == TCube.setpoint_code(laser, 20.0) + @test r === nothing || r isa ErrorException # confirmed or thrown, never silent + FK.reset!() + end + @testset "readbacks (fake SDK)" begin FakeKinesis.reset!() laser = cc(; threshold_current=65.0) From 44b4fc819f8fcc31232dde15a7cc766b75756344 Mon Sep 17 00:00:00 2001 From: kalidke Date: Tue, 29 Sep 2026 12:30:22 -0600 Subject: [PATCH 32/37] Share the diode-laser keyword rules; reword the SimDiodeLaser claim; document light_on's re-ramp from 0 Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 8 ++-- .../references/driver-caveats.md | 2 +- skills/mc-testing/SKILL.md | 2 +- .../SimulatedDiodeLaser.jl | 24 ++++------- .../tcube_laser/types.jl | 24 +---------- .../lightsource_interface/diode_laser.jl | 40 +++++++++++++++++++ 6 files changed, 56 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c2e5cc..1f034fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -186,9 +186,11 @@ setlevel!(laser, 0.4) is shown in the panel instead of being thrown inside the event handler. - **`SimDiodeLaser{M}`**, a simulated twin on the same abstract type, with a diode, photodiode and loop model, faults (blocked photodiode, responsivity - drift, TIA flags, key/interlock) and a log of every command and read. It is - constructed exactly as `TCubeLaser`, `mode` default and closed-loop required - keywords included, so one construction line serves a rig and its twin. + drift, TIA flags, key/interlock) and a log of every command and read. It + takes the same keywords with the same requirements as `TCubeLaser`, minus the + serial (`mode` default and closed-loop required keywords included), and its + default `properties` are labelled `"mA"`, so one construction line serves a rig + and its twin. - **`CALIBRATION.md`** in the TCube driver folder: how to choose the TIA range, measure the threshold and the W/A factor, build the laser in closed loop and verify it, and the 642 nm rig's current calibration (224.2 W/A on the 1 mA diff --git a/skills/mc-system-design/references/driver-caveats.md b/skills/mc-system-design/references/driver-caveats.md index 2c29d85..a4f1599 100644 --- a/skills/mc-system-design/references/driver-caveats.md +++ b/skills/mc-system-design/references/driver-caveats.md @@ -21,7 +21,7 @@ the connection; the table shows which drivers currently follow it. | `PIStage()`, `N472()`, `MCLStage()`, `MCS2Stage()` | pure (`connectionstatus=false`, handle 0) | none until `initialize` | executed (constructors), traced (initialize) | | `ThorcamDCXCamera()` | pure | none until `initialize` | traced | | `TCubeLaser(serialNo; daq=NIdaq(), daq_device=nothing, ao_channel=nothing, ...)` | pure; stores the Kinesis serial number, a DAQ handle and (from v0.2.3) the DAQ device/AO channel names `setupIO` should use. `mode` defaults to `ConstantCurrent()` (0.2.x behaviour; closed loop is `mode = ConstantPhotocurrent()`) and the result is a `TCubeLaser{M} <: DiodeLaser` (from 0.2.5). Power mode also requires `max_current` (the clamp `initialize` programs; no default there), `wa_calibration` (W/A at the laser output), `tia_range` (A, the TLD001 DIP switch: `10e-6`, `100e-6`, `1e-3`, `10e-3`), `tec_stabilised` (`Bool` or `missing`) and `properties`, whose `min_power`/`max_power` become enforced mW bounds; the constructor validates them (`max_power` must fit `tia_range * wa_calibration`). `threshold_current` (mA, `NaN` unknown) is optional in both modes. The pre-0.2.5 positional arities still construct, as `ConstantCurrent`. | none until `initialize` | traced | -| `SimDiodeLaser(; mode = ConstantCurrent(), ...)` | pure; the simulated `DiodeLaser`, constructed exactly as `TCubeLaser` (the same `mode` default, and in `ConstantPhotocurrent` the same required keywords including `max_current`; the ramp and lock keywords are stored, not simulated); every command and read throws before `initialize` and after `shutdown` | none | traced (0.2.5) | +| `SimDiodeLaser(; mode = ConstantCurrent(), ...)` | pure; the simulated `DiodeLaser`, takes the same keywords with the same requirements as `TCubeLaser`, minus the serial, and its default `properties` are labelled `"mA"` (the same `mode` default, and in `ConstantPhotocurrent` the same required keywords including `max_current`; the ramp and lock keywords are stored, not simulated); every command and read throws before `initialize` and after `shutdown` | none | traced (0.2.5) | | `Triggerscope4(; portname="COM3")` | creates a `LibSerialPort.SerialPort` object for the port; does not open it | `shutdown`, if `initialize` opened it | traced | | `LCC1620(; scope=Triggerscope4(), dac_channel=1)` | constructs its own `Triggerscope4` unless you pass `scope`; validates `dac_channel` against `scope.dacoutputs` with `@error` (does not throw) | as for the scope it holds | traced | | **`DCAM4Camera(dev_id=0)`** | **calls `dcamapi_init` and `dcamdev_open`, reads sensor size and exposure.** The camera is claimed before `initialize`; `initialize(::DCAM4Camera)` is then a no-op. **Fixed in 0.2.1:** both failure paths now throw an `ErrorException` naming the device id and the `DCAMERR` code, instead of returning a non-camera value; on each of those two checked paths the constructor calls `dcamapi_uninit()` before it throws. That is not a general guarantee: an exception raised after `dcamdev_open` succeeds has no cleanup guard, and on the two paths that do clean up the cleanup is only *attempted*: `dcamapi_uninit()` checks its own SDK result and logs an `@error` when it fails, but the constructor ignores that return and throws either way, so a failed uninitialize is visible in the log and nowhere else. (Before 0.2.1, neither failure path returned a camera: if `dcamapi_init` failed the constructor called `dcamapi_uninit` itself and returned the `DCAMERR` code; if init succeeded but `dcamdev_open` failed it `@error`d "Could not open camera" and returned `nothing`, **leaving the DCAM API initialized**.) | On success: `shutdown(cam)` (`dcamdev_close` + `dcamapi_uninit`) even if you never called `initialize`. On an installed copy older than 0.2.1, a `nothing` return needs a manual `MicroscopeControl.HardwareImplementations.DCAM4.dcamapi_uninit()` call, or the next `DCAM4Camera()` in the session starts from a half-initialized SDK; check `cam isa DCAM4Camera` before storing it. | traced (hardware verification: NOT DONE for the 0.2.1 fix — no Hamamatsu camera available) | diff --git a/skills/mc-testing/SKILL.md b/skills/mc-testing/SKILL.md index 6ce60ea..b85fd24 100644 --- a/skills/mc-testing/SKILL.md +++ b/skills/mc-testing/SKILL.md @@ -47,7 +47,7 @@ machine with no hardware or DLLs loads the package fine. | `SimCamera` | keyword, all defaulted | `exposure_time=0.1` (s), `roi=CameraROI(1, 1, 1024, 1024)`, `sequence_length=10`, `capture_mode=LIVE`, `is_running=false` | | `SimStage3d` / `SimStage2d` / `SimStage1d` | `Base.@kwdef` | `dimensions=3/2/1`, `range_*=(0, 100)`, `real_*=0.0`, `targ_*=0.0`, `connectionstatus=false`, `label` | | `SimLight` | keyword | `properties=LightSourceProperties("mW", 0.0, false, 0.0, 100.0)` | -| `SimDiodeLaser{M}` (0.2.5) | keyword, exactly as `TCubeLaser`: `mode` defaults to `ConstantCurrent()`; in `ConstantPhotocurrent` also `wa_calibration`, `tia_range`, `tec_stabilised`, `properties` and `max_current` are required, and the ramp and lock keywords are stored but not simulated | `threshold_current=65.0`, `max_current=160.0` (mA, open loop), `min_current=0.0`, `efficiency=1.2` (mW/mA above threshold), `responsivity=1/wa_calibration` (calibration starts true); fault fields `pd_blocked`, `responsivity_drift`, `tia_range_A` (the simulated DIP switch), `tia_over_fault`, `tia_under_fault`, `key`, `interlock`; `log` | +| `SimDiodeLaser{M}` (0.2.5) | keyword, the same keywords with the same requirements as `TCubeLaser` minus the serial, and its default `properties` are labelled `"mA"`: `mode` defaults to `ConstantCurrent()`; in `ConstantPhotocurrent` also `wa_calibration`, `tia_range`, `tec_stabilised`, `properties` and `max_current` are required, and the ramp and lock keywords are stored but not simulated | `threshold_current=65.0`, `max_current=160.0` (mA, open loop), `min_current=0.0`, `efficiency=1.2` (mW/mA above threshold), `responsivity=1/wa_calibration` (calibration starts true); fault fields `pd_blocked`, `responsivity_drift`, `tia_range_A` (the simulated DIP switch), `tia_over_fault`, `tia_under_fault`, `key`, `interlock`; `log` | All implement `initialize`, `shutdown`, `export_state` **[guarantee]**. There is no simulated DAQ, attenuator, Triggerscope or SLM; for those, write a fake behind diff --git a/src/hardware_implementations/simulated_diode_laser/SimulatedDiodeLaser.jl b/src/hardware_implementations/simulated_diode_laser/SimulatedDiodeLaser.jl index 8f531fd..1636e44 100644 --- a/src/hardware_implementations/simulated_diode_laser/SimulatedDiodeLaser.jl +++ b/src/hardware_implementations/simulated_diode_laser/SimulatedDiodeLaser.jl @@ -92,8 +92,10 @@ LightSourceInterface.regulation_mode(::Type{SimDiodeLaser{M}}) where {M} = M() """ SimDiodeLaser(; mode = ConstantCurrent(), kwargs...) -Constructed exactly as [`TCubeLaser`](@ref) is, so that one construction line -serves a system and its simulated twin. `mode` defaults to `ConstantCurrent()`. +Takes the same keywords with the same requirements as [`TCubeLaser`](@ref), +minus the serial (both apply them through `diode_loop_from_keywords`), so one +construction line serves a system and its simulated twin. `mode` defaults to +`ConstantCurrent()`. In `ConstantPhotocurrent` mode `wa_calibration`, `tia_range`, `tec_stabilised`, `properties` and `max_current` are required, and the loop keywords `ramp_step_mW`, `ramp_step_s`, `lock_check_s` and `lock_ratio` are accepted into the @@ -129,21 +131,9 @@ function SimDiodeLaser(; pd_blocked::Bool=false, settle_s::Float64=0.0, ) - pd = if mode isa ConstantPhotocurrent - absent = [kw for (kw, v) in (:wa_calibration => wa_calibration, :tia_range => tia_range, - :tec_stabilised => tec_stabilised, :properties => properties, - :max_current => max_current) if v === nothing] - isempty(absent) || throw(ArgumentError( - "SimDiodeLaser: a ConstantPhotocurrent laser needs these keywords, none of which has a default: $(join(absent, ", "))")) - loopkw = (; (k => v for (k, v) in (:ramp_step_mW => ramp_step_mW, :ramp_step_s => ramp_step_s, - :lock_check_s => lock_check_s, :lock_ratio => lock_ratio) if v !== nothing)...) - PhotodiodeLoop(; wa_calibration=wa_calibration, tia_range=tia_range, tec_stabilised=tec_stabilised, loopkw...) - else - any(!isnothing, (wa_calibration, tia_range, tec_stabilised, ramp_step_mW, ramp_step_s, lock_check_s, lock_ratio)) && - throw(ArgumentError("SimDiodeLaser: wa_calibration, tia_range, tec_stabilised and the ramp and lock keywords " * - "describe a photodiode loop, which only a ConstantPhotocurrent laser has")) - nothing - end + name = "SimDiodeLaser" + pd = LightSourceInterface.diode_loop_from_keywords(mode, name; wa_calibration, tia_range, + tec_stabilised, properties, max_current, ramp_step_mW, ramp_step_s, lock_check_s, lock_ratio) max_current = something(max_current, 160.0) props = something(properties, LightSourceProperties("mA", 0.0, false, min_current, max_current)) resp = something(responsivity, pd === nothing ? 1 / 224.2 : 1 / pd.wa_calibration) diff --git a/src/hardware_implementations/tcube_laser/types.jl b/src/hardware_implementations/tcube_laser/types.jl index 4e55976..5ca0d7d 100644 --- a/src/hardware_implementations/tcube_laser/types.jl +++ b/src/hardware_implementations/tcube_laser/types.jl @@ -223,28 +223,8 @@ function TCubeLaser(serialNo::String; lock_ratio::Union{Nothing,Real}=nothing, ) name = "TCubeLaser $serialNo" - pd = if mode isa ConstantPhotocurrent - absent = [kw for (kw, v) in (:wa_calibration => wa_calibration, :tia_range => tia_range, - :tec_stabilised => tec_stabilised, :properties => properties, - :max_current => max_current) if v === nothing] - isempty(absent) || throw(ArgumentError( - "$name: a ConstantPhotocurrent laser needs these keywords, none of which has a default: $(join(absent, ", ")). " * - "wa_calibration is W/A measured with a power meter at the laser output; tia_range is the rear-panel DIP switch in A; " * - "tec_stabilised is true, false or missing; properties carries the enforced min_power/max_power in mW; " * - "max_current is the clamp initialize programs into the controller, the only real protection in closed loop.")) - loop_kw = (; (kw => v for (kw, v) in (:ramp_step_mW => ramp_step_mW, :ramp_step_s => ramp_step_s, - :lock_check_s => lock_check_s, :lock_ratio => lock_ratio) if v !== nothing)...) - PhotodiodeLoop(; wa_calibration=wa_calibration, tia_range=tia_range, tec_stabilised=tec_stabilised, loop_kw...) - else - given = [kw for (kw, v) in (:wa_calibration => wa_calibration, :tia_range => tia_range, - :tec_stabilised => tec_stabilised, :ramp_step_mW => ramp_step_mW, - :ramp_step_s => ramp_step_s, :lock_check_s => lock_check_s, - :lock_ratio => lock_ratio) if v !== nothing] - isempty(given) || throw(ArgumentError( - "$name: $(join(given, ", ")) describe a photodiode loop, which only a ConstantPhotocurrent laser has; " * - "this one is $(nameof(typeof(mode)))")) - nothing - end + pd = LightSourceInterface.diode_loop_from_keywords(mode, name; wa_calibration, tia_range, + tec_stabilised, properties, max_current, ramp_step_mW, ramp_step_s, lock_check_s, lock_ratio) max_current = something(max_current, 160.0) props = something(properties, LightSourceProperties("mW", 0.0, false, 0.0, 100.0)) TCubeLaser{typeof(mode)}(unique_id, props, laser_color, min_current, max_current, diff --git a/src/hardware_interfaces/lightsource_interface/diode_laser.jl b/src/hardware_interfaces/lightsource_interface/diode_laser.jl index b99283b..604874c 100644 --- a/src/hardware_interfaces/lightsource_interface/diode_laser.jl +++ b/src/hardware_interfaces/lightsource_interface/diode_laser.jl @@ -121,6 +121,46 @@ function status_snapshot(word, current_mA::Float64, photocurrent_A::Float64; ) end +""" + diode_loop_from_keywords(mode::RegulationMode, name; wa_calibration, tia_range, + tec_stabilised, properties, max_current, ramp_step_mW, ramp_step_s, + lock_check_s, lock_ratio) + +The keyword rules a [`DiodeLaser`](@ref) constructor applies, in one place so +`TCubeLaser(serialNo; ...)` and `SimDiodeLaser(; ...)` cannot drift apart. Every +keyword is `nothing` when the caller did not pass it. In `ConstantPhotocurrent` +mode `wa_calibration`, `tia_range`, `tec_stabilised`, `properties` and +`max_current` are required and the [`PhotodiodeLoop`](@ref) is returned, built +from them and from whichever of the loop keywords (`ramp_step_mW`, `ramp_step_s`, +`lock_check_s`, `lock_ratio`) were passed. In any other mode passing a loop +keyword throws and `nothing` is returned. Throws `ArgumentError`s prefixed with +`name`. +""" +function diode_loop_from_keywords(mode::RegulationMode, name; wa_calibration, tia_range, + tec_stabilised, properties, max_current, ramp_step_mW, ramp_step_s, lock_check_s, lock_ratio) + if mode isa ConstantPhotocurrent + absent = [kw for (kw, v) in (:wa_calibration => wa_calibration, :tia_range => tia_range, + :tec_stabilised => tec_stabilised, :properties => properties, + :max_current => max_current) if v === nothing] + isempty(absent) || throw(ArgumentError( + "$name: a ConstantPhotocurrent laser needs these keywords, none of which has a default: $(join(absent, ", ")). " * + "wa_calibration is W/A measured with a power meter at the laser output; tia_range is the rear-panel DIP switch in A; " * + "tec_stabilised is true, false or missing; properties carries the enforced min_power/max_power in mW; " * + "max_current is the clamp initialize programs into the controller, the only real protection in closed loop.")) + loop_kw = (; (kw => v for (kw, v) in (:ramp_step_mW => ramp_step_mW, :ramp_step_s => ramp_step_s, + :lock_check_s => lock_check_s, :lock_ratio => lock_ratio) if v !== nothing)...) + return PhotodiodeLoop(; wa_calibration=wa_calibration, tia_range=tia_range, tec_stabilised=tec_stabilised, loop_kw...) + end + given = [kw for (kw, v) in (:wa_calibration => wa_calibration, :tia_range => tia_range, + :tec_stabilised => tec_stabilised, :ramp_step_mW => ramp_step_mW, + :ramp_step_s => ramp_step_s, :lock_check_s => lock_check_s, + :lock_ratio => lock_ratio) if v !== nothing] + isempty(given) || throw(ArgumentError( + "$name: $(join(given, ", ")) describe a photodiode loop, which only a ConstantPhotocurrent laser has; " * + "this one is $(nameof(typeof(mode)))")) + return nothing +end + """ check_diode_config(M, pd, properties, max_current, name) From fe5bb1060c41fbadfa925d3ebd04da538a097083 Mon Sep 17 00:00:00 2001 From: kalidke Date: Tue, 29 Sep 2026 12:47:35 -0600 Subject: [PATCH 33/37] Open loop never newly fails at the pot; setcurrent! reads the output 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 --- CHANGELOG.md | 11 +++-- .../tcube_laser/interface_methods.jl | 33 ++++++++++++--- test/runtests.jl | 42 ++++++++++++++++--- test/tcube_fake_sdk.jl | 11 ++++- 4 files changed, 81 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f034fe..90e85bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -210,14 +210,19 @@ setlevel!(laser, 0.4) on hardware; needs the 642 nm rig check. - **Open loop: a `max_current` below the potentiometer's floor** (about 17.25 mA) warns that the ceiling is enforced in software only and leaves the - potentiometer alone, as in 0.2.4. Not yet run on hardware; needs the 642 nm - rig check. + potentiometer alone, as in 0.2.4. If lowering the potentiometer fails (adjust + mode refused, or even the lowest position above `max_current`), `initialize` + warns the same way, records the limit the controller then reports, and goes + on: no configuration that initialized in 0.2.4 fails here. Not yet run on + hardware; needs the 642 nm rig check. - **`light_on` and `setcurrent!` wait for the controller's setpoint read-back** (up to `SETPOINT_CONFIRM_TIMEOUT_S`, 1 s) and throw if it does not confirm; `light_on` then zeroes and disables the output. Not yet run on hardware; needs the 642 nm rig check. - **`setcurrent!` with the output off sends nothing** (0.2.4 sent a setpoint the - controller ignored); `light_on` applies it after the enable. Not yet run on + controller ignored); `light_on` applies it after the enable. When the driver + recorded the output off, "off" is a fresh status read (one request/read round + trip), since the polled word can lag a `light_off`. Not yet run on hardware; needs the 642 nm rig check. - **`gui(laser)` on an open-loop `TCubeLaser` opens the diode-laser current panel** (mA). Not yet run on hardware; needs the 642 nm rig check. diff --git a/src/hardware_implementations/tcube_laser/interface_methods.jl b/src/hardware_implementations/tcube_laser/interface_methods.jl index f187aab..2ad58c8 100644 --- a/src/hardware_implementations/tcube_laser/interface_methods.jl +++ b/src/hardware_implementations/tcube_laser/interface_methods.jl @@ -259,6 +259,14 @@ If `max_current` is below the potentiometer's floor ([`DIGPOT_MIN_mA`](@ref), about 17.25 mA) no position can clamp to it: it warns, leaves the potentiometer alone, and `max_current` is enforced in software only, as in 0.2.4. The search runs with `raise = false`, so no position above the starting one is ever set. + +If the search fails -- adjust mode refused, a position that does not read back, +or even the lowest position reading above `max_current` -- it warns the same +way and records the limit the controller then reports, and `initialize` goes +on: the search has only ever lowered the potentiometer, so the controller is no +less safe than before it ran, and no configuration that initialized in 0.2.4 +fails here. If that re-read fails too, the limit read before the search is +kept, which is an upper bound for the same reason. """ function lower_open_loop_clamp!(light::TCubeLaser{ConstantCurrent}) light.controller_max_current > light.max_current || return nothing @@ -266,7 +274,18 @@ function lower_open_loop_clamp!(light::TCubeLaser{ConstantCurrent}) @warn "TCubeLaser $(light.serialNo): max_current = $(light.max_current) mA is below the lowest limit the controller's potentiometer can be set to (about $(round(DIGPOT_MIN_mA; digits=2)) mA). The potentiometer is left alone: the controller's own limit stays $(light.controller_max_current) mA and max_current is enforced in software only (setcurrent! refuses above it), as in 0.2.4. Anything outside this driver can still drive the diode to the controller's limit." return nothing end - light.controller_max_current = program_clamp!(light; raise = false) + try + light.controller_max_current = program_clamp!(light; raise = false) + catch err + err isa InterruptException && rethrow() + limit = try + read_limit_mA(light) + catch + light.controller_max_current # the pot was only lowered: the earlier reading is an upper bound + end + light.controller_max_current = limit + @warn "TCubeLaser $(light.serialNo): lowering the potentiometer to max_current = $(light.max_current) mA failed; the controller's limit now reads $(limit) mA and max_current is enforced in software only (setcurrent! refuses above it), as in 0.2.4. Anything outside this driver can still drive the diode to the controller's limit." exception = err + end return nothing end lower_open_loop_clamp!(light::TCubeLaser{ConstantPhotocurrent}) = nothing @@ -648,7 +667,9 @@ limit is above `max_current`, the potentiometer is then lowered until it is not that results; if it is at or below `max_current` the potentiometer is never touched, so a limit a rig set lower by hand stays. If `max_current` is below the potentiometer's floor ([`DIGPOT_MIN_mA`](@ref)) it warns and leaves the -potentiometer alone; the ceiling is then enforced in software only. `[limitation]` the open-loop +potentiometer alone; the ceiling is then enforced in software only. If lowering +fails, it warns the same way and goes on, so open loop never fails here where +0.2.4 did not. `[limitation]` the open-loop potentiometer lowering is unvalidated on hardware beyond the 642 nm rig's closed-loop sequence; not yet run on hardware in open loop. @@ -881,8 +902,8 @@ Set the diode drive current, in **mA**. Validates through [`check_current`](@ref), so an out-of-range request throws before anything reaches the controller, and encodes through [`setpoint_code`](@ref), whose guarantee is that the *decoded* current never -exceeds the requested one. With the output **on** -- the driver recorded it on, or the polled status word -reports it -- the setpoint is sent and confirmed from the controller's +exceeds the requested one. With the output **on** -- the driver recorded it on, or a fresh status read +reports it (not the polled word, which can lag a `light_off` by one poll) -- the setpoint is sent and confirmed from the controller's read-back. A stale status bit can no longer drop a request silently: the send is attempted and confirmed, or it throws. With the output **off**, it is recorded and `light_on` applies it right after enabling -- the controller would @@ -910,7 +931,9 @@ one. There is no cleanup: 0.2.4's `setpower` never disabled on a failed write. function LightSourceInterface.setcurrent!(light::TCubeLaser{ConstantCurrent}, current::Float64) check_current(light, current) code = setpoint_code(light, current) - on = light.properties.is_on || output_enabled(light.serialNo) + # Fresh when the driver recorded the output off: the polled word can still + # say on just after a light_off, and a send then would wait out its confirm. + on = light.properties.is_on || read_status_fresh(light.serialNo) & STATUS_BITS.output_enabled != 0 on && send_setpoint(light, code) light.drive_current = current light.properties.power = legacy_power(light, current) # deprecated; see legacy_power diff --git a/test/runtests.jl b/test/runtests.jl index 864bdbf..2200270 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -371,7 +371,7 @@ lab_summary("Core") do empty!(FakeKinesis.calls) setcurrent!(laser, 40.0) @test isempty(FakeKinesis.setpoints) - @test FakeKinesis.calls == ["LD_GetStatusBits"] + @test FakeKinesis.calls == ["LD_RequestStatusBits", "LD_GetStatusBits"] # a fresh read: the output was recorded off @test laser.drive_current == 40.0 # the accepted current, in mA # light_on enables, THEN sends it, and confirms it. empty!(FakeKinesis.calls) @@ -939,6 +939,24 @@ lab_summary("Core") do initialize(laser) @test all(<=(204), FK.digpot_sets) && FK.digpot[] == 204 @test isempty(FK.limit_raw_queue) + # Even the lowest position reads above the ceiling (220 mA at every position): the + # search walks the pot down and fails; initialize warns and goes on, the pot lowered. + FK.reset!() + FK.diode_limit_raw[] = 32767 + laser = cc(; max_current=100.0) + @test_logs (:warn, r"lowering the potentiometer.*software only") match_mode = :any initialize(laser) + @test FK.digpot[] == TCube.DIGPOT_MIN_POS && all(<=(204), FK.digpot_sets) + @test laser.controller_max_current ≈ 220.0 rtol = 1e-3 + @test TCube.effective_max_current(laser) == 100.0 + @test !laser.properties.is_on && !FK.output_on[] + @test_throws ArgumentError setcurrent!(laser, 150.0) # the ceiling holds in software + # Adjust mode refused: nothing moves, initialize warns and goes on. + FK.reset!() + FK.fail!("LD_EnableMaxCurrentAdjust") + laser = cc(; max_current=100.0) + @test_logs (:warn, r"lowering the potentiometer.*software only") match_mode = :any initialize(laser) + @test FK.digpot[] == 204 && isempty(FK.digpot_sets) + @test TCube.effective_max_current(laser) == 100.0 FK.reset!() end @@ -949,12 +967,24 @@ lab_summary("Core") do initialize(laser) setcurrent!(laser, 10.0) light_on(laser) - FK.setbits!(FK.ENABLED; on=false) # the polled status word no longer reports the output on + # The output is on, but the polled status word does not say so yet. + FK.stale_bits[] = FK.bits[] & ~FK.ENABLED n = length(FK.setpoints) - r = try; setcurrent!(laser, 20.0); nothing; catch e; e; end - @test length(FK.setpoints) == n + 1 # the send was attempted, not dropped - @test FK.setpoints[end] == TCube.setpoint_code(laser, 20.0) - @test r === nothing || r isa ErrorException # confirmed or thrown, never silent + setcurrent!(laser, 20.0) # sent and confirmed, not dropped + @test length(FK.setpoints) == n + 1 + @test FK.setpoints[end] == TCube.setpoint_code(laser, 20.0) == FK.setpoint_held[] + @test laser.drive_current == 20.0 + # The reverse, just after light_off: the output is off, but the polled word still + # says on. A fresh read decides, so nothing is sent and nothing waits or throws. + light_off(laser) + FK.stale_bits[] = FK.bits[] | FK.ENABLED + n, k = length(FK.setpoints), length(FK.calls) + setcurrent!(laser, 30.0) + @test length(FK.setpoints) == n + @test "LD_RequestStatusBits" in FK.calls[k+1:end] + @test laser.drive_current == 30.0 + light_on(laser) # applied after the enable + @test FK.setpoint_held[] == TCube.setpoint_code(laser, 30.0) FK.reset!() end diff --git a/test/tcube_fake_sdk.jl b/test/tcube_fake_sdk.jl index f3636f0..14e6797 100644 --- a/test/tcube_fake_sdk.jl +++ b/test/tcube_fake_sdk.jl @@ -94,6 +94,9 @@ const diode_limit_raw = Ref{Int}(23830) "Raw limit values the next `LD_GetLaserDiodeMaxCurrentLimit` reads return, first in first out, before `diode_limit_raw` applies again." const limit_raw_queue = Int[] +"A stale polled status word: while set, `LD_GetStatusBits` returns it instead of `bits`, until an `LD_RequestStatusBits` refreshes it (sets it back to `nothing`)." +const stale_bits = Ref{Union{Nothing,UInt32}}(nothing) + "Raw diode-current reading; `nothing` returns `diode_limit_raw`, as before 0.2.5." const current_raw = Ref{Union{Nothing,Int}}(nothing) @@ -169,6 +172,7 @@ function reset!(; limit_raw::Integer=23830, stored::Integer=0) empty!(enable_log) diode_limit_raw[] = limit_raw empty!(limit_raw_queue) + stale_bits[] = nothing current_raw[] = nothing photocurrent_raw[] = 0 bits[] = KEY | INTERLOCK | PSU_OK | TIA_1mA @@ -267,10 +271,13 @@ end Main.FakeKinesis.record!("LD_GetPhotoCurrentReading") return Main.FakeKinesis.photocurrent_raw[] end - LD_RequestStatusBits(serialNo) = Main.FakeKinesis.record!("LD_RequestStatusBits") + function LD_RequestStatusBits(serialNo) + Main.FakeKinesis.stale_bits[] = nothing + return Main.FakeKinesis.record!("LD_RequestStatusBits") + end function LD_GetStatusBits(serialNo) Main.FakeKinesis.record!("LD_GetStatusBits") - return Main.FakeKinesis.bits[] + return something(Main.FakeKinesis.stale_bits[], Main.FakeKinesis.bits[]) end function LD_EnableMaxCurrentAdjust(serialNo, enableAdjust, enableDiode) push!(Main.FakeKinesis.adjust_calls, (enableAdjust, enableDiode)) From 2ca73ebea9bb21362cb499e1d890cd5b9dff5443 Mon Sep 17 00:00:00 2001 From: kalidke Date: Tue, 29 Sep 2026 14:31:08 -0600 Subject: [PATCH 34/37] CHANGELOG: initialize(::N472) throwing on a failed setup step is not a break It changes behaviour only on a path that was already broken ("Stage initialized" was logged on a half-set-up stage), which main's versioning rule (CLAUDE.md, decision 0033) does not count as a break; #64's identical PIStage change is under Fixed. The entry stays under Changed. Ruled with #67 going into main at 0.2.5-DEV. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dc44324..4b8ccfa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,8 +70,9 @@ each on its own: `connectionstatus` and `id`, and throws with the step and its GCS error code. Before, failures were ignored and "Stage initialized" was logged on a half-initialized stage. Enumeration and connect failures still log `@error` - and return, as `initialize(::PIStage)` does. **Breaking** under this - package's versioning rule: what the call throws changed. + and return, as `initialize(::PIStage)` does. Not a break under this + package's versioning rule: it changes behaviour only on a path that was + already broken. - **Re-initializing after `shutdown` now re-zeroes the frame.** A second `initialize` on the same object was refused and did nothing; it now runs the full sequence: reference mode off, `PI_POS` redefining the current position From 8277631e3f7f2291e598306e3c954d9b1005d217 Mon Sep 17 00:00:00 2001 From: kalidke Date: Tue, 29 Sep 2026 14:39:08 -0600 Subject: [PATCH 35/37] Release 0.2.5: drop -DEV; one CHANGELOG section with the upgrade note Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 19 ++++++++++++++++++- Project.toml | 2 +- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 04bc9d2..1321843 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,24 @@ the README's Installation section: in `0.x.y`, `x` is the breaking component and `y` is the non-breaking one (releases are tagged; between them `main` carries the next version with `-DEV`). -## [Unreleased] +## [0.2.5] - 2026-09-29 + +A non-breaking release. It brings the TCube laser's closed-loop (power) mode and +the `DiodeLaser` interface (#66), the PI N-472 and PI stage fixes (#67, #64), and +the Kinesis boolean binding split (#70). Every 0.2.4 line keeps working and means +what it meant. An open-loop TCube rig does see new controller calls, listed +under Changed, and none of them has yet run on hardware. + +**UPGRADE WARNING, as for 0.2.4.** Before repinning a rig to 0.2.5: +- check every `setpower`/`setcurrent!` value that precedes a `light_on`; +- set `max_current` to the diode's rating; +- set the current limit stored in the controller (with its front-panel encoder + or by software) at or below that rating; +- run a hardware check of the new sequence. + +**Hardware verification.** Closed loop was run on the 642 nm rig on #66's +original head. The review changes on top of it, the open-loop changes and the PI +changes are exercised only against fake controllers. ### Fixed - **TCube laser: Kinesis boolean arguments are passed as four bytes.** Every diff --git a/Project.toml b/Project.toml index 50f08c3..bd83563 100644 --- a/Project.toml +++ b/Project.toml @@ -1,6 +1,6 @@ name = "MicroscopeControl" uuid = "aa70d9ae-4a1e-49fd-870a-8ccfd99f4c3e" -version = "0.2.5-DEV" +version = "0.2.5" authors = ["klidke@unm.edu"] [deps] From 7977efc3d38441125ebfd37911a3e3a39e4d75ab Mon Sep 17 00:00:00 2001 From: kalidke Date: Tue, 29 Sep 2026 14:44:28 -0600 Subject: [PATCH 36/37] TCube light_on: refuse above the stored current limit; roll back a failed enable; is_on from the enable Codex's post-merge review of 0.2.4, C1-C4, as Keith and the captain ruled for 0.2.5 (simplified: no separate C4 flag or acknowledge call). C1 and C4: between an enable and the setpoint that follows it the diode runs on the controller's stored setpoint, which software cannot clear while the output is off. The only bound is the current limit stored in the controller (front-panel encoder or software), so open-loop light_on reads it fresh (LD_RequestLaserDiodeMaxCurrentLimit, LD_GetLaserDiodeMaxCurrentLimit, in mA) before every enable and refuses above max_current. That bounds the stale pulse and the failed-zero case alike. Closed loop already refused above its clamp. The N1 warnings now say light_on will refuse. C2: LD_EnableOutput moves inside light_on's rollback, so an enable that reports failure (and may have taken) is zeroed and disabled. C3: properties.is_on is true from the moment the enable is sent. CHANGELOG: the C entries in 0.2.5's header as a deliberate safety change a rig may notice; the two "### Changed" headings get their own titles (PI N-472, TCube laser). One assertion changed: open-loop light_on's call list now starts with the limit read. Suite: 1813 pass, 3 broken, 0 fail. Not yet run on hardware. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 22 ++++++++++- .../tcube_laser/interface_methods.jl | 26 ++++++++++--- test/runtests.jl | 37 ++++++++++++++++++- 3 files changed, 77 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1321843..df08086 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,24 @@ under Changed, and none of them has yet run on hardware. or by software) at or below that rating; - run a hardware check of the new sequence. +**Deliberate safety change a rig may notice: `light_on` on an open-loop +`TCubeLaser` now REFUSES while the current limit stored in the controller (set +with its front-panel encoder or by software) is above `max_current`.** It reads +that limit fresh before every enable (Codex C1 and C4, from the post-merge review +of 0.2.4). Between an enable and the setpoint that follows it, the diode runs on +the controller's stored setpoint. The controller ignores setpoints while its +output is off, so software cannot clear that setpoint in advance, and the stored +limit is the only bound on that interval. `initialize` lowers the stored limit to +`max_current` when it can. When it cannot (a `max_current` below about 17.25 mA, +or a failed lowering), it warns, and `light_on` then refuses until the limit is +lowered. Closed loop already refused above its programmed clamp. Also from that +review: +- a failed enable is rolled back like a failed setpoint, with a zero and then + the disable (C2); +- `properties.is_on` is recorded as true from the moment the enable is sent, so + a failure during the setpoint never reports a lit diode as off (C3). +Not yet run on hardware. + **Hardware verification.** Closed loop was run on the 642 nm rig on #66's original head. The review changes on top of it, the open-loop changes and the PI changes are exercised only against fake controllers. @@ -91,7 +109,7 @@ connect-failure branch were not exercised on hardware. Julia always NUL-terminates at a `Ptr{Cchar}` boundary. The buffer grew from 128 to 1024 bytes to match the C-867 driver. -### Changed +### Changed (PI N-472) Behaviour a rig pinned to an earlier tag will see from the PI N-472 driver, each on its own: @@ -285,7 +303,7 @@ setlevel!(laser, 0.4) verify it, and the 642 nm rig's current calibration (224.2 W/A on the 1 mA range, measured before the fibre, with its closed-loop verification table). -### Changed +### Changed (TCube laser) - **`TCubeLaser.initialize` starts background polling** (`LD_StartPolling`, every `POLL_INTERVAL_MS`); `shutdown` and a failed `initialize` stop it (`LD_StopPolling`). Not yet run on hardware; needs the 642 nm rig check. diff --git a/src/hardware_implementations/tcube_laser/interface_methods.jl b/src/hardware_implementations/tcube_laser/interface_methods.jl index 2ad58c8..5b7a739 100644 --- a/src/hardware_implementations/tcube_laser/interface_methods.jl +++ b/src/hardware_implementations/tcube_laser/interface_methods.jl @@ -271,7 +271,7 @@ kept, which is an upper bound for the same reason. function lower_open_loop_clamp!(light::TCubeLaser{ConstantCurrent}) light.controller_max_current > light.max_current || return nothing if light.max_current < DIGPOT_MIN_mA - @warn "TCubeLaser $(light.serialNo): max_current = $(light.max_current) mA is below the lowest limit the controller's potentiometer can be set to (about $(round(DIGPOT_MIN_mA; digits=2)) mA). The potentiometer is left alone: the controller's own limit stays $(light.controller_max_current) mA and max_current is enforced in software only (setcurrent! refuses above it), as in 0.2.4. Anything outside this driver can still drive the diode to the controller's limit." + @warn "TCubeLaser $(light.serialNo): max_current = $(light.max_current) mA is below the lowest limit the controller's potentiometer can be set to (about $(round(DIGPOT_MIN_mA; digits=2)) mA). The potentiometer is left alone: the controller's own limit stays $(light.controller_max_current) mA and max_current is enforced in software only (setcurrent! refuses above it), light_on will refuse until the current limit stored in the controller is at or below max_current." return nothing end try @@ -284,7 +284,7 @@ function lower_open_loop_clamp!(light::TCubeLaser{ConstantCurrent}) light.controller_max_current # the pot was only lowered: the earlier reading is an upper bound end light.controller_max_current = limit - @warn "TCubeLaser $(light.serialNo): lowering the potentiometer to max_current = $(light.max_current) mA failed; the controller's limit now reads $(limit) mA and max_current is enforced in software only (setcurrent! refuses above it), as in 0.2.4. Anything outside this driver can still drive the diode to the controller's limit." exception = err + @warn "TCubeLaser $(light.serialNo): lowering the potentiometer to max_current = $(light.max_current) mA failed; the controller's limit now reads $(limit) mA and max_current is enforced in software only (setcurrent! refuses above it), light_on will refuse until the current limit stored in the controller is at or below max_current." exception = err end return nothing end @@ -860,12 +860,16 @@ function LightSourceInterface.light_on(light::TCubeLaser) serialNo = light.serialNo has_request(light) || @warn "TCubeLaser $(serialNo): light_on before any setpoint was requested; sending setpoint 0, since the controller's stored setpoint cannot be trusted" code = intended_code(light) - check_err(LD_EnableOutput(serialNo), "LD_EnableOutput", serialNo) + # On (or unknown) from the moment the enable is sent (Codex C3); a failed + # enable is rolled back like a failed setpoint, since the SDK may report a + # failure for an enable that took (C2). + light.properties.is_on = true try + check_err(LD_EnableOutput(serialNo), "LD_EnableOutput", serialNo) send_setpoint_ramped(light, code, 0) # the ramp starts from 0: every disable in this driver zeroes first check_lock(light, code) catch - disable_after_failure(light, "setpoint after enable") + disable_after_failure(light, "enable or setpoint after enable") rethrow() end light.properties.is_on = true @@ -873,7 +877,19 @@ function LightSourceInterface.light_on(light::TCubeLaser) return nothing end -require_clamp(::ConstantCurrent, light::TCubeLaser, op) = nothing +# Open loop (Codex C1 and C4): between the enable and the setpoint that follows +# it the diode runs on the controller's stored setpoint, which this driver cannot +# clear with the output off. The only bound on that interval is the current +# limit stored in the controller, so it is read fresh before every enable and +# the enable is refused if it is above max_current. +function require_clamp(::ConstantCurrent, light::TCubeLaser, op) + limit = read_limit_mA(light) + limit > light.max_current && error( + "TCubeLaser $(light.serialNo): $op refused: the current limit stored in the controller reads $(limit) mA, above max_current = $(light.max_current) mA. " * + "Between the enable and the setpoint the diode runs on the controller's stored setpoint, bounded only by that limit. " * + "Lower the controller's current limit (front-panel encoder or software) to max_current or below, or call initialize to lower it.") + return nothing +end function require_clamp(::ConstantPhotocurrent, light::TCubeLaser, op) isnan(light.pd.max_current_clamp) && error( "TCubeLaser $(light.serialNo): $op refused: the max-current clamp has not been programmed and verified. " * diff --git a/test/runtests.jl b/test/runtests.jl index 0f36978..893616a 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -380,7 +380,9 @@ lab_summary("Core") do # light_on enables, THEN sends it, and confirms it. empty!(FakeKinesis.calls) light_on(laser) - @test FakeKinesis.calls == ["LD_EnableOutput", "LD_SetLaserSetPoint", "LD_GetLaserSetPoint"] + # A fresh read of the stored current limit first (Codex C1): the enable is refused above max_current. + @test FakeKinesis.calls == ["LD_RequestLaserDiodeMaxCurrentLimit", "LD_GetLaserDiodeMaxCurrentLimit", + "LD_EnableOutput", "LD_SetLaserSetPoint", "LD_GetLaserSetPoint"] @test FakeKinesis.setpoints == [UInt16(5957)] @test FakeKinesis.setpoint_held[] == 5957 @test Float64(5957) / laser.max_setpoint * laser.max_setcurrent <= 40.0 @@ -964,6 +966,39 @@ lab_summary("Core") do FK.reset!() end + @testset "Codex C1-C3: light_on's stored limit, failed enable, is_on (fake SDK)" begin + FK = FakeKinesis + # C1+C4: a stored current limit above max_current refuses the enable, fresh at every light_on. + FK.reset!() + laser = cc(; max_current=100.0) + initialize(laser) # the fake's limit stays 160 mA: N1 warns + setcurrent!(laser, 10.0) + n = count(==("LD_EnableOutput"), FK.calls) + err = try; light_on(laser); nothing; catch e; e; end + @test err isa ErrorException && occursin("current limit stored in the controller", err.msg) + @test count(==("LD_EnableOutput"), FK.calls) == n && !laser.properties.is_on + FK.diode_limit_raw[] = floor(Int, 90 / 220 * 32767) # lowered to 90 mA: allowed + light_on(laser) + @test laser.properties.is_on + # C2: an enable that reports failure is rolled back: a zero, then the disable. + FK.reset!() + laser = cc(); initialize(laser); setcurrent!(laser, 10.0) + FK.fail!("LD_EnableOutput") + @test_logs (:error, r"output disabled") match_mode = :any @test_throws ErrorException light_on(laser) + e = findfirst(==("LD_EnableOutput"), FK.calls) + off = findnext(==("LD_DisableOutput"), FK.calls, e) + @test off !== nothing && FK.calls[off-1] == "LD_SetLaserSetPoint" && FK.setpoints[end] == 0 + @test !laser.properties.is_on + # C3: on (or unknown) from the moment the enable is sent: an enable that throws while + # the disable fails too leaves is_on true. + FK.reset!() + laser = cc(); initialize(laser); setcurrent!(laser, 10.0) + FK.throw!("LD_EnableOutput"); FK.fail!("LD_DisableOutput") + @test_logs (:error, r"may still be ON") match_mode = :any @test_throws Exception light_on(laser) + @test laser.properties.is_on + FK.reset!() + end + @testset "a stale status bit cannot drop a setcurrent! (fake SDK)" begin FK = FakeKinesis FK.reset!() From a4cff38e7c549ac1e38d3f81ad84b8bea19bb36f Mon Sep 17 00:00:00 2001 From: kalidke Date: Tue, 29 Sep 2026 14:47:19 -0600 Subject: [PATCH 37/37] Review nits on the C commit: the 0.2.5 note names the refusal; two docstrings; drop a redundant is_on The 0.2.5 header now says every 0.2.4 line keeps its meaning except, deliberately, an open-loop rig whose stored current limit is above max_current, which is refused at light_on (including a max_current below about 17.25 mA, or a limit that could not be lowered). lower_open_loop_clamp! and initialize no longer say max_current is enforced in software only as in 0.2.4; they point to light_on's refusal. light_on's second is_on = true after the try is gone (it is set before the enable). No logic change. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 9 ++++++--- .../tcube_laser/interface_methods.jl | 12 ++++++------ 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index df08086..9f621ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,9 +12,12 @@ the next version with `-DEV`). A non-breaking release. It brings the TCube laser's closed-loop (power) mode and the `DiodeLaser` interface (#66), the PI N-472 and PI stage fixes (#67, #64), and -the Kinesis boolean binding split (#70). Every 0.2.4 line keeps working and means -what it meant. An open-loop TCube rig does see new controller calls, listed -under Changed, and none of them has yet run on hardware. +the Kinesis boolean binding split (#70). Every 0.2.4 line keeps its meaning, +with one deliberate exception. An open-loop rig whose stored current limit is +above `max_current` is now refused at `light_on`: that includes a `max_current` +below about 17.25 mA, and a limit that could not be lowered (see below). An +open-loop TCube rig also sees new controller calls, listed under Changed, and +none of them has yet run on hardware. **UPGRADE WARNING, as for 0.2.4.** Before repinning a rig to 0.2.5: - check every `setpower`/`setcurrent!` value that precedes a `light_on`; diff --git a/src/hardware_implementations/tcube_laser/interface_methods.jl b/src/hardware_implementations/tcube_laser/interface_methods.jl index 5b7a739..c534938 100644 --- a/src/hardware_implementations/tcube_laser/interface_methods.jl +++ b/src/hardware_implementations/tcube_laser/interface_methods.jl @@ -256,8 +256,9 @@ Open loop only, run by `initialize` after the controller's limit is recorded: if keeps it. Closed loop programs its clamp in `enter_mode!` and this does nothing. If `max_current` is below the potentiometer's floor ([`DIGPOT_MIN_mA`](@ref), -about 17.25 mA) no position can clamp to it: it warns, leaves the potentiometer -alone, and `max_current` is enforced in software only, as in 0.2.4. The search +about 17.25 mA) no position can clamp to it: it warns and leaves the +potentiometer alone, and `light_on` then refuses while the current limit stored in +the controller is above `max_current` (see [`light_on`](@ref)). The search runs with `raise = false`, so no position above the starting one is ever set. If the search fails -- adjust mode refused, a position that does not read back, @@ -667,9 +668,9 @@ limit is above `max_current`, the potentiometer is then lowered until it is not that results; if it is at or below `max_current` the potentiometer is never touched, so a limit a rig set lower by hand stays. If `max_current` is below the potentiometer's floor ([`DIGPOT_MIN_mA`](@ref)) it warns and leaves the -potentiometer alone; the ceiling is then enforced in software only. If lowering -fails, it warns the same way and goes on, so open loop never fails here where -0.2.4 did not. `[limitation]` the open-loop +potentiometer alone. If lowering fails, it warns the same way and goes on, so +`initialize` never fails here where 0.2.4 did not; `light_on` then refuses while +the current limit stored in the controller is above `max_current`. `[limitation]` the open-loop potentiometer lowering is unvalidated on hardware beyond the 642 nm rig's closed-loop sequence; not yet run on hardware in open loop. @@ -872,7 +873,6 @@ function LightSourceInterface.light_on(light::TCubeLaser) disable_after_failure(light, "enable or setpoint after enable") rethrow() end - light.properties.is_on = true println("$(light.laser_color)" * "_laser is on") return nothing end