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..9f621ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,62 @@ 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 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`; +- 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. + +**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. ### 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"). In this release `LD_EnableMaxCurrentAdjust` is + called by the current-limit programming (#66); the other two are not called. +- **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 @@ -22,6 +75,68 @@ the next version with `-DEV`). unchanged. Reported by the MicroscopeAdapt rig; not yet run on hardware (#64). +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. 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 (PI N-472) + +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. 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 + 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. + ### Changed (release process) - **`main` is the development branch**, carrying the next version with `-DEV`; every pull request goes into it. The `0.3rc1` release-candidate @@ -29,6 +144,236 @@ 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"). +### 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 +`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 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`, 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 + 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 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 +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"). + +### 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 +# 0.2.4 line, still works +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("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("00000000"; mode = ConstantPhotocurrent(), + wa_calibration = 224.2, tia_range = 1e-3, tec_stabilised = missing, + 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 +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 + 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. + `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 + 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. 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 + range, measured before the fibre, with its closed-loop verification table). + +### 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. +- **`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; 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. 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. 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. +- **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` + 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 + `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 + (`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 2-argument interface stub `light_on(::LightSource, ipower::Float64)`, + 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: deferred + to a breaking release. It touches every light driver, which the same plan + otherwise leaves untouched, and is independent of the laser modes. + ## [0.2.4] - 2026-09-29 Safety patch for the TCube laser driver. **v0.2.3 and every earlier tag are diff --git a/CLAUDE.md b/CLAUDE.md index 31f4b85..9ca9a71 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!` @@ -119,6 +122,23 @@ 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, 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. +- 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 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 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 **Convention:** Image data is stored and displayed as column-major `(H, W, N)` arrays where `data[row, col]` = `data[y, x]`. @@ -186,3 +206,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/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] diff --git a/README.md b/README.md index a917137..269a347 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 diff --git a/skills/mc-api-map/SKILL.md b/skills/mc-api-map/SKILL.md index ea8d9c2..ec546c2 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.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 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`. + +**[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.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}) -# -> 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 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} + +# 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} is the +# deprecated forwarder, kept in 0.2.5) 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 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.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. + 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,16 @@ 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.4 the interface + declared `light_on(::LightSource, ipower::Float64)`, which no driver implemented + 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` 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. - 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..d6e117c 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.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.2.5) + +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 98490da..8692579 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.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 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.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: @@ -148,9 +151,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.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` @@ -220,6 +224,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.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 + 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` 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`, +`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. @@ -241,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 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.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) @@ -273,7 +304,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.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 about what it does not model. **[limitation]** upstream CLAUDE.md calls interface GUIs optional while `test/contract.jl` requires every non-exempt device to @@ -325,8 +358,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` 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`, `_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..91dc468 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.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` +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.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 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..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]`: 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.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 23359e2..3bae470 100644 --- a/skills/mc-extend/references/rig-causes.md +++ b/skills/mc-extend/references/rig-causes.md @@ -9,15 +9,22 @@ 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` 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. | | `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. | +| `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); 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`. | | `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. | -| `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. | +| 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. | | `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 2e0112b..04690c0 100644 --- a/skills/mc-system-design/SKILL.md +++ b/skills/mc-system-design/SKILL.md @@ -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.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? @@ -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.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}`, +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` 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. + ## 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.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 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.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). | **[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.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` 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 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.2.5 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.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.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 + 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..02bd694 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; 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`, 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) | @@ -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.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 @@ -68,9 +69,19 @@ 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.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`, +`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` @@ -93,16 +104,35 @@ 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.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.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). `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.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: 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). + Use `light_off`. ## Shared GUI facts @@ -115,7 +145,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.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]**. ## `save_h5` facts diff --git a/skills/mc-testing/SKILL.md b/skills/mc-testing/SKILL.md index cc66d41..b85fd24 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.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 @@ -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.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.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 + `::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)` 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.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 +`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.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 +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.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/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/pi_n472/helper.jl b/src/hardware_implementations/pi_n472/helper.jl index 2ee4e7e..bbcc4c3 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 ? FALSE : qsuccess +end diff --git a/src/hardware_implementations/pi_n472/interface_methods.jl b/src/hardware_implementations/pi_n472/interface_methods.jl index 7ea9d25..ca33411 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,55 +14,62 @@ function initialize(stage::N472) return end - # Create a buffer string - buffersize = 128 - devstring = zeros(UInt8, buffersize) + # 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(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 (absent, unpowered, or held by another process)" stage.connectionstatus = false return end + # 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 "PI device: " * String(devstring) @info "Device ID: " * string(stage.id) + if stage.id < 0 + # 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 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 @@ -67,6 +83,9 @@ function shutdown(stage::N472) else @info "Stage not connected" end + # 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 @@ -95,7 +114,9 @@ function StageInterface.home(stage::N472) end function StageInterface.stopmotion(stage::N472) - success = PI_HLT(stage.id, stage.axes) + # GCS2 axes arguments are one space-separated string. + axes = join(stage.axes, " ") + success = PI_HLT(stage.id, axes) return success end 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/src/hardware_implementations/simulated_diode_laser/SimulatedDiodeLaser.jl b/src/hardware_implementations/simulated_diode_laser/SimulatedDiodeLaser.jl new file mode 100644 index 0000000..1636e44 --- /dev/null +++ b/src/hardware_implementations/simulated_diode_laser/SimulatedDiodeLaser.jl @@ -0,0 +1,362 @@ +""" + 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 = ConstantCurrent(), kwargs...) + +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 +[`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. + +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(), + unique_id::String="SimDiodeLaser", + properties::Union{Nothing,LightSourceProperties}=nothing, + min_current::Float64=0.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, + tia_range_A::Union{Nothing,Float64}=nothing, + key::Bool=true, + interlock::Bool=true, + pd_blocked::Bool=false, + settle_s::Float64=0.0, +) + 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) + 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..b187063 --- /dev/null +++ b/src/hardware_implementations/tcube_laser/CALIBRATION.md @@ -0,0 +1,288 @@ +# 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("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, 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 +light_on(laser) +loop_status(laser) # photocurrent, drive current, saturated / TIA flags +``` + +## 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 + +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). + +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 (see "Controller facts" above), not the photodiode channel. + +## 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 2bb7baa..e31e7a9 100644 --- a/src/hardware_implementations/tcube_laser/TCubeLaserControl.jl +++ b/src/hardware_implementations/tcube_laser/TCubeLaserControl.jl @@ -15,42 +15,13 @@ 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" -# 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: -# -# 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 -# -# `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: -# -# 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`. - include("constants_Tlaser.jl") include("functions_Tlaser.jl") include("types.jl") @@ -58,7 +29,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 d5f67ca..c534938 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. """ @@ -15,16 +18,52 @@ function check_err(err, operation::AbstractString, serialNo::AbstractString) return nothing end +""" + check_flag(ok, operation::AbstractString, serialNo::AbstractString) + +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 + +""" + REQUEST_WAIT_S + +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) + +""" + 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 + """ 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 in 0.3.0** -- read `light.drive_current` instead. +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. @@ -41,7 +80,7 @@ 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 +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 @@ -51,7 +90,7 @@ end """ 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 +98,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 +130,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 +179,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 +208,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 +245,478 @@ 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. + +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 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, +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 + 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), light_on will refuse until the current limit stored in the controller is at or below max_current." + return nothing + end + 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), light_on will refuse until the current limit stored in the controller is at or below max_current." exception = err + end + return nothing +end +lower_open_loop_clamp!(light::TCubeLaser{ConstantPhotocurrent}) = nothing + +# --------------------------------------------------------------------------- +# 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 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 + +""" + 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; 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 +`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`). + +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`, 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; 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 + # 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, upper) + 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 + +""" + 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 +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). +""" +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 + +""" + 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{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(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(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) + +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. +""" +function intended_code(light::TCubeLaser{ConstantCurrent}) + isnan(light.drive_current) && return UInt16(0) + check_current(light, light.drive_current) # the ceiling may have dropped since setcurrent! + return setpoint_code(light, light.drive_current) +end +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) + +"Whether a setpoint has been requested since construction: what `light_on` sends if not is 0." +has_request(light::TCubeLaser{ConstantCurrent}) = !isnan(light.drive_current) +has_request(light::TCubeLaser{ConstantPhotocurrent}) = !isnan(light.pd.output_power_requested) + +# --------------------------------------------------------------------------- +# 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, 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. 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. If `max_current` is below the +potentiometer's floor ([`DIGPOT_MIN_mA`](@ref)) it warns and leaves the +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. + +`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. 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)). +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. 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. 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. 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,26 +725,40 @@ 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[]) + # Whatever else left the output on, it is off before the mode command + # is sent, and `is_on` is false afterwards. + 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 # `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) + 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 # 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,119 +766,324 @@ 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" + 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) + 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. the clamp, as the controller itself reports it (the output is off: + # `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) + 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)") + + # 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[]) + 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 """ light_on(light::TCubeLaser) -Enable the controller's output, then send the requested setpoint. - -The controller ignores a setpoint sent while its output is off (see -[`TCubeLaser`](@ref)). So `drive_current` is validated first against the -current ceiling (nothing is sent if that throws), the output is enabled, and -only then is its setpoint code sent. Called while the output is already on, -this re-sends `drive_current`, replacing any lower value set from Kinesis or -the front panel. If no [`setpower`](@ref) has been called, setpoint 0 is sent, with a -warning. If the setpoint fails after the enable, the output is disabled again -and the error rethrown; `properties.is_on` is written only once both steps -have succeeded (or to what the failed disable left the output as). +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 ([`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` +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 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 +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; 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 +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!`; +unvalidated on hardware. """ function LightSourceInterface.light_on(light::TCubeLaser) + require_clamp(regulation_mode(light), light, "light_on") serialNo = light.serialNo - if isnan(light.drive_current) - code = UInt16(0) - @warn "TCubeLaser $(serialNo): light_on before any setpower; sending setpoint 0, since the controller's stored setpoint cannot be trusted" - else - check_current(light, light.drive_current) - code = setpoint_code(light, light.drive_current) - end - check_err(LD_EnableOutput(serialNo), "LD_EnableOutput", 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) + # 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_SetLaserSetPoint(serialNo, code), "LD_SetLaserSetPoint", serialNo) + 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 - disabled = false - try - disabled = LD_DisableOutput(serialNo) == 0 - catch - 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; the output may still be ON at the controller's stored setpoint" - end + 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 +# 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. " * + "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.") + # 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.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 +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 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 +ignore it now ([`send_setpoint`](@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. - -# The controller ignores setpoints while the output is off - -The setpoint is sent on every call, but the controller ignores it while the -output is off (see [`TCubeLaser`](@ref)); it then takes effect only because -[`light_on`](@ref) sends it again after enabling. Sending it anyway covers an -output enabled outside this object. - -# 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, 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) - current_setpoint = setpoint_code(light, current) - check_err(LD_SetLaserSetPoint(light.serialNo, current_setpoint), "LD_SetLaserSetPoint", light.serialNo) + code = setpoint_code(light, current) + # 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 - println("Laser current set to $current mA" * (light.properties.is_on ? "" : " (output off: applied at light_on)")) + 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, and re-check the controller: + a fresh status read must report closed loop and a fresh limit read must not + 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`. +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. +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)). +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 +(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 +[`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 = 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.") + (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") + # 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) + 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)) + try + send_setpoint_ramped(light, code, from) + check_lock(light, code) + catch + disable_after_failure(light, "setoutputpower! (setpoint or lock check)") + 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)", + on ? "" : " (output off: applied at light_on)") return nothing end +""" + zero_then_disable(light::TCubeLaser) + +Send setpoint 0, then disable the output, then record `properties.is_on = +false`. The controller keeps its setpoint across a disable and ignores +setpoints while its output is off, so the zero has to come first: the next +enable then starts dark rather than on a stale value. The zero is one write +([`zero_setpoint`](@ref)) with no status read and no read-back wait: commands +to the controller are handled in order, so it lands before the disable. A +failed zero is logged, before the disable, and never prevents it; a failed +disable throws and leaves `properties.is_on` unchanged. The driver's recorded +request is kept, and `light_on` re-applies it. +""" +function zero_then_disable(light::TCubeLaser) + serialNo = light.serialNo + zeroed = zero_setpoint(light) + isnothing(zeroed) || @error "TCubeLaser $serialNo: zeroing the setpoint before disable failed ($zeroed); the controller's stored setpoint was not cleared" + check_err(LD_DisableOutput(serialNo), "LD_DisableOutput", serialNo) + light.properties.is_on = false + 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) @@ -387,21 +1104,14 @@ end """ light_off(light::TCubeLaser) -Zero the controller's setpoint, then disable its output, then record it. - -The controller keeps its setpoint across a disable (see [`TCubeLaser`](@ref)), -so the setpoint is zeroed while the output is still on: the next enable starts -dark. A failed zero is logged, before the disable, and does not throw, since -the output is still turned off; a failed disable throws and leaves -`properties.is_on` unchanged. -`drive_current` is not changed: it is the request the next [`light_on`](@ref) -re-sends. +Zero the setpoint while the output is still on, then disable the output, then +record it ([`zero_then_disable`](@ref)). A failed zero is logged and does not +throw, since the output is still turned off; a failed disable throws and leaves +`properties.is_on` unchanged. The recorded request (`drive_current`, or +`pd.output_power_requested`) is kept, so `light_on` returns to it. """ function LightSourceInterface.light_off(light::TCubeLaser) - zeroed = zero_setpoint(light) - isnothing(zeroed) || @error "TCubeLaser $(light.serialNo): zeroing the setpoint before disable failed ($zeroed); the controller's stored setpoint was not cleared" - 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 @@ -409,34 +1119,125 @@ end """ shutdown(light::TCubeLaser) -Zero the setpoint, disable the output and close the connection. The zero -comes first, as in [`light_off`](@ref) (see [`TCubeLaser`](@ref)). A failed zero is logged; 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 zero is logged; 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 - zeroed = zero_setpoint(light) - isnothing(zeroed) || @error "TCubeLaser $serialNo: zeroing the setpoint before disable failed ($zeroed); the controller's stored setpoint was not cleared" 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 @@ -487,30 +1288,64 @@ 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. - -`"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. +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. + +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`. + +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) - 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, + # 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 + 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 ) @@ -538,8 +1373,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 @@ -549,7 +1384,7 @@ function export_state(light::TCubeLaser, ignored) # 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 + "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 @@ -566,15 +1401,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 e03519c..5ca0d7d 100644 --- a/src/hardware_implementations/tcube_laser/types.jl +++ b/src/hardware_implementations/tcube_laser/types.jl @@ -1,22 +1,45 @@ """ - `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(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`: 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 `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, 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`: 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,40 +53,32 @@ `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.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 The Thorlabs TLD001 ignores `LD_SetLaserSetPoint` while its output is disabled and, on the next `LD_EnableOutput`, runs on whatever setpoint it had stored -(observed on the 642 nm rig's TLD001 64849775, 2026-09-28). So a `setpower` -value reaches the diode only because `light_on` sends it again right after -enabling, and `light_off` and `shutdown` zero the setpoint before disabling, -so the next enable starts dark. +(observed on the 642 nm rig's TLD001 64849775, 2026-09-28). So a requested +setpoint (`setcurrent!`, `setoutputpower!`) reaches the diode only because +`light_on` sends it right after enabling, and `light_off` and `shutdown` zero +the setpoint before disabling, so the next enable starts dark. `[limitation]` Between the enable and the setpoint that follows it (one USB round trip) the controller runs on its stored setpoint: 0 after this driver's @@ -71,7 +86,7 @@ round trip) the controller runs on its stored setpoint: 0 after this driver's other software left it there. In that window the current-limit potentiometer is the only hardware bound; set it at or below the diode's rating. """ -mutable struct TCubeLaser <: LightSource +mutable struct TCubeLaser{M<:RegulationMode} <: DiodeLaser unique_id::String properties::LightSourceProperties laser_color::String @@ -86,56 +101,110 @@ 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, - 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, + 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, - NaN, nothing, nothing, NaN) + controller_max_current, daq_device, ao_channel, drive_current, + threshold_current, pd) end end +# 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. +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...) 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. - -The keywords match the field names documented on [`TCubeLaser`](@ref). The two -worth stating here: - -- `max_current` is **your** ceiling for this diode and is never overwritten; +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` 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. +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, 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("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`, +`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, an answer to the temperature question and an explicit +clamp. In +`ConstantCurrent` mode passing any of the first three throws, and `properties` +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 + 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 `setpower` enforces the smaller of the two. + `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. - -`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::RegulationMode=ConstantCurrent(), 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 + 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, @@ -143,9 +212,23 @@ 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, + 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, ) - TCubeLaser(unique_id, properties, laser_color, min_current, max_current, + name = "TCubeLaser $serialNo" + 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, 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..604874c --- /dev/null +++ b/src/hardware_interfaces/lightsource_interface/diode_laser.jl @@ -0,0 +1,199 @@ +""" + 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 + +""" + 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) + +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..9ca1e10 --- /dev/null +++ b/src/hardware_interfaces/lightsource_interface/diode_laser_gui.jl @@ -0,0 +1,229 @@ +""" + 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. + +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) + 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, status) +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..f9b06db 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.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`. + # 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,160 @@ function light_off(lightsource::LightSource) # turn off the lightsource error("light_off not implemented for $(typeof(lightsource))") end + +""" + setpower(laser::DiodeLaser, x::Float64) + +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 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..63e0ba6 100644 --- a/src/hardware_interfaces/lightsource_interface/interface_types.jl +++ b/src/hardware_interfaces/lightsource_interface/interface_types.jl @@ -21,4 +21,158 @@ 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(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 +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. +- `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 + tia_range::Float64 + tec_stabilised::Union{Bool,Missing} + 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, + ramp_step_mW=Inf, ramp_step_s=0.01, lock_check_s=0.2, lock_ratio=1.5) + +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}, + 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)")) + 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/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..26950b3 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, kept until the next breaking release. 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,54 @@ 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.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) + @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 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 + 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 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/gui.jl b/test/gui.jl index da226f2..6d4a9c1 100644 --- a/test/gui.jl +++ b/test/gui.jl @@ -118,4 +118,58 @@ 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(), max_current=160.0, 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(), max_current=160.0, + lock_check_s=0.0)) + gui(laser) + GLMakie.closeall() + 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 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 15b7ec5..893616a 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") + # Writes the lab test record summary when LAB_TEST_SUMMARY is set; see the file. include("lab_summary.jl") @@ -119,12 +123,37 @@ 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, 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 + # 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(), max_current=160.0, + lock_check_s=0.0, ramp_step_s=0.0, kw...) # no sleeping in the suite @testset "Constructor defaults" begin - laser = TCubeLaser("00000000") + # 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` 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} + @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 @@ -132,26 +161,26 @@ lab_summary("Core") do # `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 isnan(laser.threshold_current) + @test laser.pd === nothing + # The default properties are 0.2.4's, labels only in open loop. @test laser.properties.power_unit == "mW" - @test laser.properties.power == 0.0 + @test laser.properties.min_power == 0.0 && laser.properties.max_power == 100.0 @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.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) 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 @@ -160,24 +189,64 @@ lab_summary("Core") do @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 @@ -197,12 +266,12 @@ lab_summary("Core") do @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 @@ -211,10 +280,9 @@ lab_summary("Core") do # 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) @@ -226,99 +294,170 @@ lab_summary("Core") do @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 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!() + 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) + @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. - FakeKinesis.reset!() # controller reports a 160 mA limit - laser = TCubeLaser("00000000"; max_current=80.0) + # assignment must fail this testset. + # 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_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 - # 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_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) + light_on(laser) + # 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 - @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) + # 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 + # 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 isempty(FakeKinesis.setpoints) + @test TCube.effective_max_current(weak) == weak.controller_max_current + @test_throws ArgumentError setcurrent!(weak, 70.0) # between the two ceilings + @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!() + FakeKinesis.polling_ok[] = false + @test_throws "LD_StartPolling" initialize(cc()) + @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 @@ -327,7 +466,7 @@ lab_summary("Core") do # 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 @@ -338,26 +477,23 @@ 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_SetOpenLoopMode", "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. 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 @@ -369,19 +505,612 @@ 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_SetOpenLoopMode", "LD_Close"] # and the close was still attempted + "LD_Open", "LD_StartPolling", "LD_SetLaserSetPoint", "LD_DisableOutput", + "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", + # 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: 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..., + # 4: closed loop, confirmed from the status word + "LD_SetClosedLoopMode", "LD_RequestStatusBits", "LD_GetStatusBits", + # 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 + # 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] + # 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 "power mode re-checks the controller before emitting (fake SDK)" begin + 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) + @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) + # ... 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 + 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. + guard = ["LD_RequestStatusBits", "LD_GetStatusBits", + "LD_RequestLaserDiodeMaxCurrentLimit", "LD_GetLaserDiodeMaxCurrentLimit"] + 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) + # (`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 + # ... 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) + 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(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. It starts from 0, not from a read-back. + light_off(laser) + laser.pd.ramp_step_mW = 3.0 + try + empty!(FakeKinesis.calls); empty!(FakeKinesis.setpoints) + light_on(laser) + @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 + 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", + "LD_GetPhotoCurrentReading"] + # 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) + @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) + # 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) + @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 == 30.0 # the last confirmed request + 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) + 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)] + @test laser.properties.is_on + setcurrent!(laser, 80.0) + empty!(FakeKinesis.calls); empty!(FakeKinesis.setpoints) + light_off(laser) + # One write of 0, no status read and no read-back wait, then the disable. + @test FakeKinesis.calls == ["LD_SetLaserSetPoint", "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 fails never prevents light_off's disable. + light_on(laser) + FakeKinesis.fail!("LD_SetLaserSetPoint") + @test_logs (:error, r"zeroing the setpoint") match_mode = :any light_off(laser) + @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 "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 "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) + # 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 + + @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!() + laser = cc() + initialize(laser) + setcurrent!(laser, 10.0) + light_on(laser) + # 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) + 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 + + @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!(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) + 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 atol = 0.02 + setlevel!(laser, 0.25) + @test laser.drive_current ≈ 90.0 atol = 0.02 + @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_SetLaserSetPoint", "LD_DisableOutput", "LD_Close"] + # The zero is one write, with no status read and no read-back wait (a + # setpoint sent with the output off is ignored), then the disable. + @test FakeKinesis.calls == ["LD_SetLaserSetPoint", "LD_DisableOutput", "LD_StopPolling", "LD_Close"] @test laser.properties.is_on == false # A failed disable throws -- and the handle is closed anyway, @@ -389,7 +1118,7 @@ lab_summary("Core") do # 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) @@ -399,10 +1128,24 @@ lab_summary("Core") do end @test err isa ErrorException @test occursin("LD_DisableOutput", err.msg) - @test FakeKinesis.calls == ["LD_SetLaserSetPoint", "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_SetLaserSetPoint", "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_SetLaserSetPoint", "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 @@ -411,150 +1154,71 @@ lab_summary("Core") do # 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 @@ -562,73 +1226,224 @@ lab_summary("Core") do 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" + # 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"] == "mW" + @test attrs["max_power"] == 100.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") - # `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 deprecated 2-argument forwarder still works. + @test export_state(laser, nothing)[1]["serialNo"] == "00000000" + 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(), 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 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() + 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 - # 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 "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 "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 + 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 + + @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), 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 end include("tcube_output_order.jl") @@ -708,6 +1523,8 @@ lab_summary("Core") do end end + include("pi_n472.jl") + include("contract.jl") include("skills.jl") include("gui.jl") diff --git a/test/tcube_fake_sdk.jl b/test/tcube_fake_sdk.jl index f7767f6..14e6797 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.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 +# 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,14 +91,74 @@ driver's default scale. """ const diode_limit_raw = Ref{Int}(23830) -"Whether the fake controller's output is currently enabled." -const output_on = Ref{Bool}(false) +"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) + +"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 controller's stored setpoint. Like the real TLD001 it changes only when -`LD_SetLaserSetPoint` succeeds while the output is on. +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 stored = Ref{Int}(0) +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) + +""" +0.2.4's names for the same controller state, kept so its tests +(`tcube_output_order.jl`) run unchanged: `stored` IS `setpoint_held`, and +`output_on[]` reads the `ENABLED` status bit. +""" +const stored = setpoint_held +struct OutputOn end +Base.getindex(::OutputOn) = bits[] & ENABLED != 0 +const output_on = OutputOn() "The stored setpoint at each successful `LD_EnableOutput`, i.e. what it ran on." const enable_log = Int[] @@ -100,9 +170,23 @@ function reset!(; limit_raw::Integer=23830, stored::Integer=0) empty!(setpoints) empty!(throws) empty!(enable_log) - output_on[] = false - FakeKinesis.stored[] = stored 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 + closed_loop_takes[] = true + setpoint_held[] = stored + 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 @@ -123,6 +207,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 @@ -130,35 +217,102 @@ 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") function LD_EnableOutput(serialNo) - st = Main.FakeKinesis.record!("LD_EnableOutput") - if st == 0 - Main.FakeKinesis.output_on[] = true - push!(Main.FakeKinesis.enable_log, Main.FakeKinesis.stored[]) + s = Main.FakeKinesis.record!("LD_EnableOutput") + if s == 0 + Main.FakeKinesis.setbits!(Main.FakeKinesis.ENABLED) + push!(Main.FakeKinesis.enable_log, Int(Main.FakeKinesis.setpoint_held[])) end - return st + return s end function LD_DisableOutput(serialNo) - st = Main.FakeKinesis.record!("LD_DisableOutput") - st == 0 && (Main.FakeKinesis.output_on[] = false) - return st + 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) - st = Main.FakeKinesis.record!("LD_SetLaserSetPoint") - st == 0 && Main.FakeKinesis.output_on[] && (Main.FakeKinesis.stored[] = Int(laserDiodeCurrent)) - return st + 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[] + 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 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 + 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 something(Main.FakeKinesis.stale_bits[], 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