diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f621ec..0430604 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,71 @@ 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] + +### Fixed + +- `TCubeLaser`: every fresh read of the controller (the status word, the current limit, the + potentiometer position, the W/A read-back, `initialize`'s limit read and `tcube_get_current`) + now sends its request twice before reading. The 642 nm rig's TLD001 answers one request behind + (2026-09-29), so a single request could let `light_on`'s current-limit gate pass a + potentiometer raised since the previous read, and the enable then ran above `max_current` + until the setpoint landed. +- `TCubeLaser` power mode: `check_lock` makes its own readings and status requests and refuses + in both directions. Besides a photocurrent above `lock_ratio` times the request, it refuses when + the controller reports its current limit reached (status bit `0x400`) or the photocurrent is + below the request divided by `lock_ratio`; the output is then zeroed and disabled, as for a + suspected lock. It used to read the polled cache and test only the high side, so a loop driven + to the clamp -- by a request the clamp cannot reach, or by a photodiode giving fewer counts per + mW than at calibration -- went unnoticed. +- `PhotodiodeLoop` refuses a `lock_check_s` below 0.1 s (`LOCK_CHECK_MIN_S`): 0 was accepted and + disabled the lock check. A construction passing a smaller value now throws. +- `TCubeLaser`: `measured_current` (and so `loop_status`) accepts the raw reading -32768, which + the Kinesis header defines as -220 mA, instead of throwing. +- `TCubeLaser`: any failure during the calibration-reference re-check (a mismatch, a mode refusal or a + command error) latches until the next `initialize`, and the diode is not re-lit on every retried `light_on`. +- `TCubeLaser`: a safety check that refuses while the output may be on (the stored-limit + check, and in power mode also a mode, TIA-range or clamp change, or an over-range photodiode) now zeroes and disables + the output before it throws, instead of leaving the diode lit in the fault. +- `TCubeLaser` open-loop `initialize` confirms open loop with a fresh status read after + `LD_SetOpenLoopMode`, and refuses if the controller stays in closed loop. +- `TCubeLaser` power mode: `check_lock` also runs the current-limit test (`0x400`) at a zero request. +- `TCubeLaser` power mode: `setoutputpower!` decides on fresh status and photocurrent reads, and + `light_on` and `setoutputpower!` refuse a photodiode range that no longer matches `tia_range`. +- `TCubeLaser`: the header's potentiometer floor, 17.25 mA, no longer gates anything. Open-loop + `initialize` lowers the potentiometer for any `max_current` below the controller's limit, and + power-mode construction no longer refuses a `max_current` under 17.25 mA; the limit the + controller reports decides (the 642 nm rig's unit reads 16.74 mA at the lowest position). + +### Added + +- A calibration reference for power mode: `ref_current_mA`, `ref_photocurrent_A` and `ref_ratio` + (default 1.5) on `PhotodiodeLoop`, `TCubeLaser` and `SimDiodeLaser` (which stores them and does + not check). With a reference, the first power-mode `light_on` after each `initialize` runs the + diode in open loop at `ref_current_mA` for `REFERENCE_DWELL_S` (0.1 s) before reading the photodiode, + and refuses unless the photocurrent is within a factor `ref_ratio` of `ref_photocurrent_A`; the + output is off after the check either way, and a mismatch refuses every later `light_on` until the + next `initialize`. Without one, that `light_on` warns once that the check is skipped. See `CALIBRATION.md`. + **Every rig that uses power mode should record one** (`ref_current_mA`, `ref_photocurrent_A`), + with its next W/A measurement; `CALIBRATION.md` step 3b says how. + +### Changed + +- `TCubeLaser` timings, against 0.2.5, at the default `lock_check_s` (0.2 s) and `REQUEST_WAIT_S` + (0.1 s; a fresh read now sends its request twice, 0.2 s, where it was 0.1 s): + - a power-mode `light_on` takes about 0.6 s longer (`require_clamp` two fresh reads, +0.2 s; `check_lock` + adds its photocurrent and status reads, +0.4 s); + - a power-mode `setoutputpower!` with the output on takes about 0.8 s longer for a request above zero + and about 0.6 s for a zero request (`require_clamp` +0.2 s, the fresh photocurrent read +0.2 s, + `check_lock` +0.4 s, or +0.2 s at zero); with the output off, about 0.2 s longer; + - an open-loop `light_on` takes about 0.1 s longer (one fresh limit read), and a `setcurrent!` with + the output off about 0.1 s longer (one fresh status read); + - `initialize` takes about 0.8 s longer in power mode (eight fresh reads: status 2, potentiometer 2, limit 3, + W/A 1, for one potentiometer setting; each further setting adds 0.2 s) and about 0.3 s in open loop + (one limit read, more if the potentiometer is lowered, plus 0.2 s for the open-loop confirm); + - the first power-mode `light_on` after `initialize` also runs the calibration-reference re-check: + `REFERENCE_DWELL_S` (0.1 s), three fresh reads (0.6 s) and the setpoint confirm, about 0.7 s plus the confirm. + ## [0.2.5] - 2026-09-29 A non-breaking release. It brings the TCube laser's closed-loop (power) mode and diff --git a/Project.toml b/Project.toml index bd83563..f1f971d 100644 --- a/Project.toml +++ b/Project.toml @@ -1,6 +1,6 @@ name = "MicroscopeControl" uuid = "aa70d9ae-4a1e-49fd-870a-8ccfd99f4c3e" -version = "0.2.5" +version = "0.2.6-DEV" authors = ["klidke@unm.edu"] [deps] diff --git a/src/hardware_implementations/simulated_diode_laser/SimulatedDiodeLaser.jl b/src/hardware_implementations/simulated_diode_laser/SimulatedDiodeLaser.jl index 1636e44..167c78e 100644 --- a/src/hardware_implementations/simulated_diode_laser/SimulatedDiodeLaser.jl +++ b/src/hardware_implementations/simulated_diode_laser/SimulatedDiodeLaser.jl @@ -98,9 +98,10 @@ 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 +`ramp_step_s`, `lock_check_s`, `lock_ratio`, `ref_current_mA`, `ref_photocurrent_A` +and `ref_ratio` are accepted into the [`PhotodiodeLoop`](@ref). `[limitation]` the +simulation neither ramps, checks for a loop lock, nor re-checks a calibration +reference; 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. @@ -122,6 +123,9 @@ function SimDiodeLaser(; ramp_step_s::Union{Nothing,Real}=nothing, lock_check_s::Union{Nothing,Real}=nothing, lock_ratio::Union{Nothing,Real}=nothing, + ref_current_mA::Union{Nothing,Real}=nothing, + ref_photocurrent_A::Union{Nothing,Real}=nothing, + ref_ratio::Union{Nothing,Real}=nothing, efficiency::Float64=1.2, responsivity::Union{Nothing,Float64}=nothing, responsivity_drift::Float64=0.0, @@ -133,7 +137,8 @@ function SimDiodeLaser(; ) 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) + tec_stabilised, properties, max_current, ramp_step_mW, ramp_step_s, lock_check_s, lock_ratio, + ref_current_mA, ref_photocurrent_A, ref_ratio) max_current = something(max_current, 160.0) props = something(properties, LightSourceProperties("mA", 0.0, false, min_current, max_current)) resp = something(responsivity, pd === nothing ? 1 / 224.2 : 1 / pd.wa_calibration) diff --git a/src/hardware_implementations/tcube_laser/CALIBRATION.md b/src/hardware_implementations/tcube_laser/CALIBRATION.md index b187063..724b11c 100644 --- a/src/hardware_implementations/tcube_laser/CALIBRATION.md +++ b/src/hardware_implementations/tcube_laser/CALIBRATION.md @@ -8,6 +8,7 @@ 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) | +| Calibration reference | `ref_current_mA`, `ref_photocurrent_A` | an open-loop current and the photocurrent in A read at it, re-checked at the first `light_on` after every `initialize` | measured with the W/A factor (step 3b) | | 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 @@ -31,6 +32,7 @@ and the endpoints of `setlevel!` and the panel slider). | 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 | +| Calibration reference | none recorded yet (see step 3b) | Verification, in closed loop with those two factors: power commanded through the formula above versus power measured before the fibre, in mW. Single @@ -63,6 +65,7 @@ laser = TCubeLaser("00000000"; 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 + # ref_current_mA = 90.0, ref_photocurrent_A = , # step 3b; without it light_on warns 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 @@ -79,11 +82,13 @@ 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 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; the manual gives about 0.7 mA per step (p.28, p.38); position 203 read 159.9 mA, and the limit readback is stable across fresh reads, 2026-09-29. The limit readback is what the driver trusts; `DIGPOT_STEP_ESTIMATE_mA` keeps the header's 220/255 mA, the larger step, only to choose the next position, so moves approach `max_current` from below. - 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`). +- The controller answers one request behind: the first `LD_RequestStatusBits` after an enable returned the pre-enable bits even 0.5 s later; readings behave the same, 2026-09-29 (driver: `request_twice`). +- With no light the photocurrent reads raw 65532, i.e. -4 signed, 2026-09-29. - 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. @@ -161,7 +166,8 @@ plane**. Recalibrate when any of these changes: 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. +- the verification (step 5) drifts by more than you can accept; +- record a new calibration reference whenever the W/A factor is re-measured. It is also worth re-running step 5 every few months: it takes minutes and tells you whether the old factor still holds. @@ -250,6 +256,31 @@ 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. +### 3b. Record the calibration reference + +Do this in the same session, on the same range and gain as the W/A factor. Pick one +current from the sweep at least 20 mA above threshold and at most the power-mode +`max_current`, whose indicated power is at most `max_power` (the constructor refuses +otherwise). On the 642 nm rig that is 90 mA (about 99 uA, about 22 mW at 224.2 W/A). + +```julia +TCube = MicroscopeControl.HardwareImplementations.TCubeLaserControl +setcurrent!(laser, 90.0); sleep(1.0) +ref_photocurrent_A = TCube.photocurrent_from_raw(laser, TCube.read_photocurrent_word(laser), 1e-3) # decode with the tia_range the power-mode config will state +``` + +Then pass `ref_current_mA = 90.0, ref_photocurrent_A = ref_photocurrent_A` when you build +the power-mode laser (step 4). The first `light_on` after every `initialize` then drives the +diode in open loop at `ref_current_mA`, reads the photodiode, and refuses unless the reading +is within a factor `ref_ratio` (default 1.5) of `ref_photocurrent_A`, in either direction; the +output is off after the check. A range the reading does not follow, or a `tia_range` +relabelled with W/A kept, fails that check. Re-measure W/A and the reference whenever the +switch moves. + +`[limitation]` the 642 nm rig's words of 2026-09-29 (375 / 1802 / 6172 at 70 / 80 / 110 mA) +are **not** a reference for its W/A factor. That factor dates from Oct 2024, and the gain was +re-optimised on 2026-09-28 (see above). Record the reference at the next W/A verification. + ### 4. Build the laser in closed loop Construct `TCubeLaser(...; mode = ConstantPhotocurrent(), ...)` with the three diff --git a/src/hardware_implementations/tcube_laser/interface_methods.jl b/src/hardware_implementations/tcube_laser/interface_methods.jl index c534938..00e63b4 100644 --- a/src/hardware_implementations/tcube_laser/interface_methods.jl +++ b/src/hardware_implementations/tcube_laser/interface_methods.jl @@ -32,13 +32,34 @@ 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 +Seconds to wait between each `LD_Request*` call and the next request or 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) +""" + request_twice(request, name, serialNo) + +Send `request` (an `LD_Request*` wrapper) twice, waiting `REQUEST_WAIT_S` after each, so the +`LD_Get*` that follows returns the controller's state at the first request. The 642 nm rig's +TLD001 answers one request behind (2026-09-29): the first request after a change returned the +state before it, even 0.5 s later, and the next one was current. Every fresh read in this +driver goes through here. + +`[limitation]` whether this driver's polling already hides the lag for the limit and the status +word after a change is not measured (rig check R1); the second request is kept either way, at +`REQUEST_WAIT_S` per read. +""" +function request_twice(request, name::AbstractString, serialNo::AbstractString) + for _ in 1:2 + check_err(request(serialNo), name, serialNo) + sleep(REQUEST_WAIT_S[]) + end + return nothing +end + """ POLL_INTERVAL_MS @@ -50,7 +71,8 @@ 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. +request and is the fallback. `check_lock` does not depend on it: it makes its own +requests (0.2.6). Whether polling refreshes the readings is rig check R2. """ const POLL_INTERVAL_MS = 50 @@ -255,11 +277,11 @@ Open loop only, run by `initialize` after the controller's limit is recorded: if `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 no potentiometer position gives a limit at or below `max_current` (the 642 nm +rig's reads 16.74 mA at the lowest), the search fails and it warns as below; `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 @@ -271,10 +293,6 @@ 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 @@ -308,31 +326,23 @@ 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. +The TLD001's max-current potentiometer: positions 20..255. The manual gives a step of +about 0.863 mA per position (the header's step, 220/255), used only to choose the next +position. The header's scale, `position * 220 / 255` mA, is **not** the limit: 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. So no clamp value is ever computed from a position. The estimate is at least the +measured step, so estimated moves fall short and the search approaches `max_current` +from below: moves approach `max_current` from below. Since 0.863 mA is slightly more than +the measured ~0.83 mA/step, an upward search can stop one position short of the highest +position under `max_current`, which errs safe (a lower ceiling). The manual's ~0.7 mA per position (p.28, +p.38) would overshoot upward, so it is not used. [`program_clamp!`](@ref) reads the +controller's own limit after each setting, and that readback decides every position. """ 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 @@ -346,15 +356,13 @@ 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[]) + request_twice(LD_RequestLaserDiodeMaxCurrentLimit, "LD_RequestLaserDiodeMaxCurrentLimit", serialNo) 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[]) + request_twice(LD_RequestMaxCurrentDigPot, "LD_RequestMaxCurrentDigPot", serialNo) return Int(LD_GetMaxCurrentDigPot(serialNo)) end @@ -389,14 +397,16 @@ end program_clamp!(light::TCubeLaser; raise::Bool=true) Leave the controller's diode current limit at the highest potentiometer position +(or one position below it: see [`DIGPOT_STEP_ESTIMATE_mA`](@ref)) 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 +side. It reads the controller's limit after each setting and keeps the best +position under the ceiling and the lowest over it, so it settles in 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 @@ -409,6 +419,10 @@ return. It never raises the potentiometer in that mode. The default, `[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. + +`[limitation]` that the controller clamps to the limit it reports (manual p.41, p.50; +header :711), and not to the header's position scale, is inferred, not measured (rig +check R3). """ function program_clamp!(light::TCubeLaser; raise::Bool=true) ceiling = light.max_current @@ -500,13 +514,19 @@ 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." +"Request twice, 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[]) + request_twice(LD_RequestStatusBits, "LD_RequestStatusBits", serialNo) return UInt32(LD_GetStatusBits(serialNo)) end +"Fresh read of the raw photocurrent word, signed: two `LD_RequestReadings`, then the reading." +function read_photocurrent_word(light::TCubeLaser) + serialNo = light.serialNo + request_twice(LD_RequestReadings, "LD_RequestReadings", serialNo) + return Int(LD_GetPhotoCurrentReading(serialNo)) +end + """ SETPOINT_CONFIRM_TIMEOUT_S, SETPOINT_READBACK_TOLERANCE @@ -517,9 +537,6 @@ 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) @@ -600,27 +617,151 @@ 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. +After a setpoint, wait `pd.lock_check_s`, then make its own fresh reads: the photocurrent +word ([`read_photocurrent_word`](@ref)), then the status word ([`read_status_fresh`](@ref)), +and compare them with what `code` requests. It throws on the first of these that holds, in +this order: + +1. the measured photocurrent exceeds `pd.lock_ratio` times the request: the loop has + probably locked at a high current whatever is requested (the failure the ramp works + around; see [`PhotodiodeLoop`](@ref)). This is tested before the status read, so a lock + trips about 2 x `REQUEST_WAIT_S` sooner; +2. the status reports the current limit reached (`0x400`): the loop is at the clamp and the + power is not being held; +3. the measured photocurrent is below the request divided by `pd.lock_ratio`: the loop is + not holding its setpoint. + +The test is two-sided because a loop driven to the clamp, by a request the clamp cannot +reach or by a photodiode giving fewer counts per mW than at calibration, reads low. `code` +is the final code its caller confirmed (after any ramp); intermediate ramp steps are not +checked. At `code == 0` it waits `lock_check_s` and runs only the `0x400` test (no +photocurrent read): a zero request has no lock and no deficit, but a controller at its +current limit is still a fault. Its own reads cost about `lock_check_s + 4 x +REQUEST_WAIT_S` (`+ 2 x` at `code == 0`). It never disables the output by itself; its callers do. A no-op in open +loop. + +`[limitation]` the thresholds 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. + +`[limitation]` that `0x400` sets when the *closed* loop saturates is read from the Kinesis +header, not observed. + +`[limitation]` a loop slower than `lock_check_s` to reach 2/3 of an upward step false-trips, +which refuses (the safe direction). """ check_lock(light::TCubeLaser{ConstantCurrent}, code) = nothing function check_lock(light::TCubeLaser{ConstantPhotocurrent}, code) - pd = light.pd + pd, serialNo = light.pd, light.serialNo 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.") + measured = NaN + if code > 0 + measured = photocurrent_from_raw(light, read_photocurrent_word(light), pd.tia_range) + 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.") + end + bits = read_status_fresh(serialNo) + bits & STATUS_BITS.saturated != 0 && error( + "TCubeLaser $serialNo: the controller reports its current limit reached (status 0x400) holding a request of $(requested) A" * + "$(code > 0 ? "; the photodiode reads $(measured) A" : " (a zero request)"). The loop is at the clamp and the power is not being held: the request needs more " * + "current than the clamp allows, or the photodiode gives fewer counts per mW than at calibration (a moved DIP switch or " * + "a changed TIA gain; see CALIBRATION.md).") + code > 0 && measured < requested / pd.lock_ratio && error( + "TCubeLaser $serialNo: the photodiode reads $(measured) A for a request of $(requested) A, below 1/$(pd.lock_ratio) " * + "of it after $(pd.lock_check_s) s: the loop is not holding its setpoint. Check the photodiode and the calibration (CALIBRATION.md).") + return nothing +end + +""" + REFERENCE_DWELL_S + +How long the calibration-reference re-check holds the diode at `ref_current_mA` before it reads the photodiode. +Fixed and short, so the reference emission does not grow with `lock_check_s`. The diode current settles far faster. + +`[limitation]` unvalidated on hardware (rig check R2). +""" +const REFERENCE_DWELL_S = 0.1 + +""" + check_scale!(light::TCubeLaser{ConstantPhotocurrent}) + +The calibration-reference re-check: runs at most once per `initialize` +(`pd.scale_checked` and `pd.scale_refused`, both cleared by every `initialize`, successful +or failed), from +[`light_on`](@ref) right after `require_clamp`. A no-op in open loop. + +With no reference (`pd.ref_current_mA` is `NaN`) it logs one `@warn`, sets +`scale_checked` and makes no SDK calls. With one, it checks `ref_current_mA` against +the current ceiling (nothing is sent if that throws), zeroes and disables the output, +enters open loop ([`set_open_loop!`](@ref), which confirms it from a fresh status read +before the enable), enables, sends the setpoint for `ref_current_mA` (after the enable: a +setpoint sent with the output off is ignored), waits [`REFERENCE_DWELL_S`](@ref), reads the +photocurrent, zeroes and disables again, and restores closed loop +([`set_closed_loop!`](@ref)). It then compares the reading, decoded with `pd.tia_range`, +with `pd.ref_photocurrent_A`: it passes iff the reading is within a factor +`pd.ref_ratio` of it, either way. The output is off afterwards, and `light_on` continues +with its own enable. + +Any failure inside the re-check (a `set_open_loop!` refusal, a command error, or a mismatch) +latches the refusal until the next `initialize`: `scale_refused` is set before the check +starts and cleared only on a pass, so every later `light_on` refuses at once, without +lighting the diode again. A command failure is also cleaned up +([`disable_after_failure`](@ref)) and rethrown; a mismatch leaves the output off and +closed loop restored. + +The reference is in amps, decoded with `tia_range`. A range the reading does not follow, +or a `tia_range` relabelled with W/A kept (the 642 nm rig's fact 6), fails the re-check. A +correct range change, where the words do follow, passes. + +`[limitation]` the reference emission (open loop at `ref_current_mA` for about +`REFERENCE_DWELL_S + 2 x REQUEST_WAIT_S` plus the setpoint confirm, no longer tied to +`lock_check_s`) happens before the user's request at the first `light_on`. + +`[limitation]` unvalidated on hardware. +""" +check_scale!(light::TCubeLaser{ConstantCurrent}) = nothing +function check_scale!(light::TCubeLaser{ConstantPhotocurrent}) + pd, serialNo = light.pd, light.serialNo + pd.scale_refused && error( + "TCubeLaser $serialNo: light_on refused: the calibration-reference re-check failed since the last initialize (a mismatch, or a command or mode failure during it), " * + "and the diode is not lit again to re-check it. Fix the setup or recalibrate (CALIBRATION.md), then call initialize.") + pd.scale_checked && return nothing + if isnan(pd.ref_current_mA) + @warn "TCubeLaser $serialNo: no calibration reference (ref_current_mA, ref_photocurrent_A), so the photodiode scale re-check is skipped for this initialize; check_lock's two-sided test is the only guard against a scale change since calibration. See CALIBRATION.md." + pd.scale_checked = true + return nothing + end + pd.scale_refused = true # any failure from here to the pass latches until initialize + check_current(light, pd.ref_current_mA) # within the programmed clamp; nothing is sent if not + word = try + zero_then_disable(light) # the mode command is never sent while the diode is lit + set_open_loop!(light) # LD_SetOpenLoopMode, output off, open loop confirmed before the enable + light.properties.is_on = true + check_err(LD_EnableOutput(serialNo), "LD_EnableOutput", serialNo) + send_setpoint(light, setpoint_code(light, pd.ref_current_mA)) # after the enable: a setpoint sent with the output off is ignored + sleep(REFERENCE_DWELL_S) + w = read_photocurrent_word(light) + zero_then_disable(light) # off, and stored setpoint 0, before the mode command + set_closed_loop!(light) + w + catch + disable_after_failure(light, "the calibration-reference re-check") + rethrow() + end + measured = photocurrent_from_raw(light, word, pd.tia_range) + ref = pd.ref_photocurrent_A + if !(ref / pd.ref_ratio <= measured <= ref * pd.ref_ratio) + error( + "TCubeLaser $serialNo: light_on refused: at the calibration reference, $(pd.ref_current_mA) mA in open loop, the photodiode reads $(measured) A " * + "where $(ref) A was recorded (allowed: within a factor of $(pd.ref_ratio)). The photodiode's counts per mW are not those of the calibration: " * + "a moved DIP switch, a changed TIA gain, a tia_range that no longer matches the amplifier, or a changed diode or photodiode. " * + "Recalibrate and record a new reference (CALIBRATION.md). The output is off.") + end + pd.scale_refused = false + pd.scale_checked = true return nothing end @@ -662,13 +803,12 @@ is lit and `properties.is_on` is false afterwards. Polling starts first because write depends on only refreshes through it (see [`SETPOINT_CONFIRM_TIMEOUT_S`](@ref)). -`ConstantCurrent`: `LD_SetOpenLoopMode`, then the limit read. If the controller's +`ConstantCurrent`: `LD_SetOpenLoopMode`, then a fresh status read that confirms open loop +(it throws if the controller still reports closed loop), 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 +touched, so a limit a rig set lower by hand stays. 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 @@ -683,7 +823,10 @@ simplified away: 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. + error in every commanded power. `[limitation]` the check reads the status bits; + on the 642 nm rig the photodiode words did not follow a DIP switch move that the + bits did (2026-09-29, rig check R4). The calibration reference is the check that + does not depend on the bits. 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 @@ -698,6 +841,9 @@ simplified away: factor scales the controller's display only; the driver does its own conversion. +The calibration-reference re-check is **not** run here: `initialize` never emits. +It runs at the first power-mode [`light_on`](@ref) after each `initialize`. + 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. If the @@ -719,6 +865,7 @@ 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) + reset_loop_state!(light) serialNo = light.serialNo check_err(TLI_BuildDeviceList(), "TLI_BuildDeviceList", serialNo) numdev = TLI_GetDeviceListSize() @@ -738,17 +885,14 @@ function initialize(light::TCubeLaser) 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 `setcurrent!` enforces. Not + # a generic readings request does not stand in for it. Reading the limit + # without it 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 `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(REQUEST_WAIT_S[]) + request_twice(LD_RequestLaserDiodeMaxCurrentLimit, "LD_RequestLaserDiodeMaxCurrentLimit", serialNo) out = LD_GetLaserDiodeMaxCurrentLimit(serialNo) record_controller_limit!(light, out) lower_open_loop_clamp!(light) @@ -770,13 +914,45 @@ function initialize(light::TCubeLaser) return nothing end -enter_mode!(::ConstantCurrent, light::TCubeLaser) = - check_err(LD_SetOpenLoopMode(light.serialNo), "LD_SetOpenLoopMode", light.serialNo) +# Every initialize, successful or not, starts from no programmed clamp and an un-run reference re-check. +reset_loop_state!(::TCubeLaser{ConstantCurrent}) = nothing +function reset_loop_state!(light::TCubeLaser{ConstantPhotocurrent}) + pd = light.pd + pd.max_current_clamp = NaN + pd.scale_checked = false + pd.scale_refused = false + return nothing +end + +enter_mode!(::ConstantCurrent, light::TCubeLaser) = set_open_loop!(light) + +"`LD_SetClosedLoopMode`, then a fresh status read must report closed loop (`0x4`)." +function set_closed_loop!(light::TCubeLaser) + serialNo = light.serialNo + check_err(LD_SetClosedLoopMode(serialNo), "LD_SetClosedLoopMode", serialNo) + read_status_fresh(serialNo) & STATUS_BITS.closed_loop != 0 || error( + "TCubeLaser $serialNo: LD_SetClosedLoopMode returned success but the status word does not report closed loop (0x4)") + return nothing +end + +""" + set_open_loop!(light::TCubeLaser) + +`LD_SetOpenLoopMode`, then a fresh status read must report open loop (`0x4` clear). +Used by open-loop `initialize` and by the reference re-check. +""" +function set_open_loop!(light::TCubeLaser) + serialNo = light.serialNo + check_err(LD_SetOpenLoopMode(serialNo), "LD_SetOpenLoopMode", serialNo) + bits = read_status_fresh(serialNo) + bits & STATUS_BITS.closed_loop == 0 || error( + "TCubeLaser $serialNo: LD_SetOpenLoopMode returned success but the status word still reports closed loop (status 0x$(string(bits; base=16))); the diode is not enabled") + return nothing +end 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) @@ -797,14 +973,11 @@ function enter_mode!(::ConstantPhotocurrent, light::TCubeLaser) 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)") + set_closed_loop!(light) # 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[]) + request_twice(LD_RequestWACalibFactor, "LD_RequestWACalibFactor", serialNo) 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)") @@ -833,7 +1006,19 @@ the cleanup left: `false` if the disable succeeded, `true` if it failed too 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. +is disabled and the error rethrown. `check_lock` is two-sided: it also refuses when the +controller reports its current limit reached (`0x400`) or the photocurrent is below +1/`lock_ratio` of the request. + +The first power-mode `light_on` after each `initialize` also runs the calibration-reference +re-check ([`check_scale!`](@ref)): with a reference it drives the diode in open loop at +`ref_current_mA` for about `REFERENCE_DWELL_S + 2 x REQUEST_WAIT_S` plus the setpoint +confirm (not tied to `lock_check_s`), reads the photodiode and refuses on a mismatch, +leaving the output off; a mismatch latches until the next `initialize`, and later +`light_on` calls refuse without lighting the diode again. Without a reference it warns +once and carries on. + +A safety check that refuses while the output may be on zeroes and disables it first. `[limitation]` Between the enable and the setpoint the controller runs on its stored setpoint, bounded in hardware only by its current-limit potentiometer; @@ -847,17 +1032,18 @@ 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 +controller before it emits: a fresh status read must report closed loop and the photodiode range `tia_range` states, 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. +`[limitation]` those checks add two fresh reads (about 4 x `REQUEST_WAIT_S`), and +`check_lock` adds `lock_check_s` plus about 4 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") + check_scale!(light) serialNo = light.serialNo has_request(light) || @warn "TCubeLaser $(serialNo): light_on before any setpoint was requested; sending setpoint 0, since the controller's stored setpoint cannot be trusted" code = intended_code(light) @@ -882,7 +1068,7 @@ end # 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) +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. " * @@ -890,7 +1076,7 @@ function require_clamp(::ConstantCurrent, light::TCubeLaser, op) "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) +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.") @@ -900,6 +1086,12 @@ function require_clamp(::ConstantPhotocurrent, light::TCubeLaser, op) bits & STATUS_BITS.closed_loop != 0 || error( "TCubeLaser $(light.serialNo): $op refused: the controller is not in closed loop (status 0x$(string(bits; base=16))); " * "was the mode changed on the front panel? Call initialize again.") + reported = LightSourceInterface.tia_range_from_word(bits) + isnan(reported) && error( + "TCubeLaser $(light.serialNo): $op refused: the status word reports no single photodiode range (status 0x$(string(bits; base=16))). Call initialize again.") + isapprox(reported, light.pd.tia_range; rtol=1e-9) || error( + "TCubeLaser $(light.serialNo): $op refused: the controller's photodiode range is $(reported) A but tia_range states $(light.pd.tia_range) A. " * + "Check the rear-panel DIP switch; the calibration is only valid on the range it was measured on.") # 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. @@ -907,7 +1099,32 @@ function require_clamp(::ConstantPhotocurrent, light::TCubeLaser, op) (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 + return bits +end + +# Whether the output may be on: the driver's record, else a fresh status read. A failed read counts as on. +function output_may_be_on(light::TCubeLaser) + light.properties.is_on && return true + try + return read_status_fresh(light.serialNo) & STATUS_BITS.output_enabled != 0 + catch + return true + end +end + +""" + require_clamp(mode::RegulationMode, light::TCubeLaser, op) + +The pre-enable safety check of `mode` (`_require_clamp`). A safety check that refuses +while the output may be on zeroes and disables it first, then rethrows (#74 review M1). +""" +function require_clamp(mode::RegulationMode, light::TCubeLaser, op) + try + return _require_clamp(mode, light, op) + catch + output_may_be_on(light) && disable_after_failure(light, "$op (a safety check refused while the output may be on)") + rethrow() + end end """ @@ -964,31 +1181,35 @@ 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 + a fresh status read must report closed loop and the photodiode range `tia_range` states, 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. + a potentiometer step. `[limitation]` this adds two fresh + reads (about 4 x `REQUEST_WAIT_S`), and `check_lock` (step 6) adds `lock_check_s` + plus about 4 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 +3. Decide on the fresh status word step 1 returned: refuse unless it reports closed + loop, or if the photodiode amplifier is over range (the status flag, or, with the + output on, a fresh photocurrent word of -32768). 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 +5. With the output on (the driver recorded it on, or the fresh 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 + failure, a suspected lock, a current limit reached (`0x400`) or a photocurrent + below 1/`lock_ratio` of the request 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`. +A safety check that refuses while the output may be on zeroes and disables it first. + `[limitation]` the lock check's threshold and wait are unvalidated on hardware (see [`check_lock`](@ref)). @@ -997,16 +1218,21 @@ 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!") + bits = 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") + overrange = try + bits & STATUS_BITS.tia_over != 0 || (on && read_photocurrent_word(light) == PHOTOCURRENT_OVER_RANGE) + catch + on && disable_after_failure(light, "setoutputpower! (photocurrent read)") + rethrow() + end + if overrange + on && disable_after_failure(light, "setoutputpower! (photodiode amplifier over range)") + error( + "TCubeLaser $serialNo: setoutputpower! refused: the photodiode amplifier reports OVER range, so the loop's feedback is invalid") + end # 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. @@ -1149,12 +1375,10 @@ end 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. +is signed; -32768..32767 represents -220..+220 mA (Kinesis header). """ 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 @@ -1236,8 +1460,7 @@ cache. """ function tcube_get_current(light::TCubeLaser) serialNo = light.serialNo - check_err(LD_RequestReadings(serialNo), "LD_RequestReadings", serialNo) - sleep(REQUEST_WAIT_S[]) + request_twice(LD_RequestReadings, "LD_RequestReadings", serialNo) out = LD_GetLaserDiodeCurrentReading(serialNo) return setpoint_current(light, out) end diff --git a/src/hardware_implementations/tcube_laser/types.jl b/src/hardware_implementations/tcube_laser/types.jl index 5ca0d7d..4985aaf 100644 --- a/src/hardware_implementations/tcube_laser/types.jl +++ b/src/hardware_implementations/tcube_laser/types.jl @@ -117,9 +117,12 @@ mutable struct TCubeLaser{M<:RegulationMode} <: DiodeLaser "$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().")) + if !isnan(pd.ref_current_mA) + ref_mW = pd.ref_photocurrent_A * pd.wa_calibration * 1000 + ref_mW <= properties.max_power || throw(ArgumentError( + "$name: the calibration reference indicates $(ref_mW) mW (ref_photocurrent_A $(pd.ref_photocurrent_A) A at $(pd.wa_calibration) W/A), " * + "above properties.max_power = $(properties.max_power) mW; the re-check at the first light_on would emit it. Choose a lower reference current.")) + end end new{M}(unique_id, properties, laser_color, min_current, max_current, max_setcurrent, max_setpoint, serialNo, task_mod, daq, @@ -191,8 +194,9 @@ only in this mode, nothing enforces them (see the `properties` field). real protection while the loop raises the current by itself; `initialize` records the controller's own limit separately in `controller_max_current`, and `setcurrent!` enforces the smaller of the two. -- `ramp_step_mW`, `ramp_step_s`, `lock_check_s` and `lock_ratio` (closed loop - only; passing one in open loop throws) set the fields of the same names on +- `ramp_step_mW`, `ramp_step_s`, `lock_check_s`, `lock_ratio`, `ref_current_mA`, + `ref_photocurrent_A` and `ref_ratio` (closed loop + only; passing one in open loop throws; see [`PhotodiodeLoop`](@ref) and CALIBRATION.md) 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 @@ -221,10 +225,14 @@ function TCubeLaser(serialNo::String; ramp_step_s::Union{Nothing,Real}=nothing, lock_check_s::Union{Nothing,Real}=nothing, lock_ratio::Union{Nothing,Real}=nothing, + ref_current_mA::Union{Nothing,Real}=nothing, + ref_photocurrent_A::Union{Nothing,Real}=nothing, + ref_ratio::Union{Nothing,Real}=nothing, ) 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) + tec_stabilised, properties, max_current, ramp_step_mW, ramp_step_s, lock_check_s, lock_ratio, + ref_current_mA, ref_photocurrent_A, ref_ratio) max_current = something(max_current, 160.0) props = something(properties, LightSourceProperties("mW", 0.0, false, 0.0, 100.0)) TCubeLaser{typeof(mode)}(unique_id, props, laser_color, min_current, max_current, diff --git a/src/hardware_interfaces/lightsource_interface/diode_laser.jl b/src/hardware_interfaces/lightsource_interface/diode_laser.jl index 604874c..09bd2d3 100644 --- a/src/hardware_interfaces/lightsource_interface/diode_laser.jl +++ b/src/hardware_interfaces/lightsource_interface/diode_laser.jl @@ -124,7 +124,7 @@ 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) + lock_check_s, lock_ratio, ref_current_mA, ref_photocurrent_A, ref_ratio) The keyword rules a [`DiodeLaser`](@ref) constructor applies, in one place so `TCubeLaser(serialNo; ...)` and `SimDiodeLaser(; ...)` cannot drift apart. Every @@ -132,12 +132,13 @@ 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 +`lock_check_s`, `lock_ratio`, `ref_current_mA`, `ref_photocurrent_A`, `ref_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) + tec_stabilised, properties, max_current, ramp_step_mW, ramp_step_s, lock_check_s, lock_ratio, + ref_current_mA, ref_photocurrent_A, ref_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, @@ -148,13 +149,16 @@ function diode_loop_from_keywords(mode::RegulationMode, name; wa_calibration, ti "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)...) + :lock_check_s => lock_check_s, :lock_ratio => lock_ratio, + :ref_current_mA => ref_current_mA, :ref_photocurrent_A => ref_photocurrent_A, + :ref_ratio => ref_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] + :lock_ratio => lock_ratio, :ref_current_mA => ref_current_mA, + :ref_photocurrent_A => ref_photocurrent_A, :ref_ratio => ref_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)))")) @@ -175,7 +179,9 @@ at fault: 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. + clamp the loop is held under; +- in that mode a calibration reference, if given, has `ref_current_mA <= max_current`: + the re-check drives the diode at it in open loop. """ function check_diode_config(::Type{M}, pd, properties, max_current::Float64, name) where {M<:RegulationMode} if M === ConstantPhotocurrent @@ -191,6 +197,8 @@ function check_diode_config(::Type{M}, pd, properties, max_current::Float64, nam "$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.")) + isnan(pd.ref_current_mA) || pd.ref_current_mA <= max_current || throw(ArgumentError( + "$name: ref_current_mA = $(pd.ref_current_mA) mA is above max_current = $(max_current) mA; the reference re-check drives the diode at it in open loop")) else pd === nothing || throw(ArgumentError( "$name: only a ConstantPhotocurrent laser carries a PhotodiodeLoop; a $(nameof(M)) laser must have pd = nothing")) diff --git a/src/hardware_interfaces/lightsource_interface/interface_functions.jl b/src/hardware_interfaces/lightsource_interface/interface_functions.jl index f9b06db..56dd43a 100644 --- a/src/hardware_interfaces/lightsource_interface/interface_functions.jl +++ b/src/hardware_interfaces/lightsource_interface/interface_functions.jl @@ -194,7 +194,8 @@ One consistent snapshot of the controller, as a `NamedTuple`: 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. +system decides. The TCube driver also refuses at each power-mode setpoint when `0x400` +is set (`check_lock`, 0.2.6); after that check, nothing watches it. """ function loop_status(laser::DiodeLaser) error("loop_status not implemented for $(typeof(laser))") diff --git a/src/hardware_interfaces/lightsource_interface/interface_types.jl b/src/hardware_interfaces/lightsource_interface/interface_types.jl index 63e0ba6..ea891a0 100644 --- a/src/hardware_interfaces/lightsource_interface/interface_types.jl +++ b/src/hardware_interfaces/lightsource_interface/interface_types.jl @@ -82,6 +82,16 @@ anywhere, and the name would say it did. """ struct ConstantPhotocurrent <: RegulationMode end +""" + LOCK_CHECK_MIN_S + +The shortest `lock_check_s` a [`PhotodiodeLoop`](@ref) accepts, in s: 0.1. The floor gives the +loop time to settle before `check_lock` reads; `check_lock` makes its own reads and does not +depend on polling. Below it the photodiode is read before the loop has answered a new setpoint. At 0, before 0.2.6, the lock check read the polled cache before the loop moved +and could never trip; with the two-sided check it would trip on every upward step. +""" +const LOCK_CHECK_MIN_S = 0.1 + """ PhotodiodeLoop @@ -130,14 +140,35 @@ delivered power drifts while photocurrent is held steady. That is why (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`. + before it compares the measured photocurrent with the request. Default `0.2`; + at least [`LOCK_CHECK_MIN_S`](@ref). - `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. +- `ref_current_mA::Float64`, `ref_photocurrent_A::Float64`: the calibration + reference. The open-loop drive current, and the photocurrent in A the controller + read at it, decoded with the same `tia_range` as the config states, recorded in + the same session and on the same range and gain as `wa_calibration` + (CALIBRATION.md). With one, the first power-mode `light_on` after each + `initialize` re-measures it and refuses on a mismatch. `NaN` (both) means none: + that `light_on` warns once and `check_lock` is the only guard. The reference is + in amps, decoded with `tia_range`, so a range the reading does not follow, or a + `tia_range` relabelled with W/A kept (the 642 nm rig's observation of + 2026-09-29), fails the re-check. Re-measure W/A and the reference whenever the + DIP switch moves. +- `ref_ratio::Float64`: the re-measured photocurrent must be within this factor + of `ref_photocurrent_A`, either way. Default `1.5`: a 10x change in counts is + caught with a wide margin, a scale drop under 1.5x passes, and the reading is + proportional to (I - I_th), so a small margin above threshold is sensitive to + ordinary drift. Choose the reference at least 20 mA above threshold. +- `scale_checked::Bool`: state, `true` once this initialize's re-check passed or + was skipped for want of a reference. +- `scale_refused::Bool`: state, `true` once this initialize's re-check found a mismatch. + Every later power-mode `light_on` refuses without lighting the diode until the next `initialize`. + +Construct it with the keyword form, which fills the last twelve fields. """ mutable struct PhotodiodeLoop wa_calibration::Float64 @@ -150,29 +181,49 @@ mutable struct PhotodiodeLoop ramp_step_s::Float64 lock_check_s::Float64 lock_ratio::Float64 + ref_current_mA::Float64 + ref_photocurrent_A::Float64 + ref_ratio::Float64 + scale_checked::Bool + scale_refused::Bool 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) + ramp_step_mW=Inf, ramp_step_s=0.01, lock_check_s=0.2, lock_ratio=1.5, + ref_current_mA=nothing, ref_photocurrent_A=nothing, ref_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). +(possibly `missing`) to whether the diode's temperature is stabilised. The rest +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) + lock_check_s::Real=0.2, lock_ratio::Real=1.5, + ref_current_mA::Union{Nothing,Real}=nothing, + ref_photocurrent_A::Union{Nothing,Real}=nothing, ref_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_check_s) && lock_check_s >= LOCK_CHECK_MIN_S) || throw(ArgumentError("PhotodiodeLoop: lock_check_s must be finite and at least $(LOCK_CHECK_MIN_S) s, got $(lock_check_s)")) (isfinite(lock_ratio) && lock_ratio > 1) || throw(ArgumentError("PhotodiodeLoop: lock_ratio must be finite and above 1, got $(lock_ratio)")) + (ref_current_mA === nothing) == (ref_photocurrent_A === nothing) || throw(ArgumentError( + "PhotodiodeLoop: ref_current_mA and ref_photocurrent_A are one calibration reference; give both or neither, got ref_current_mA = $(repr(ref_current_mA)), ref_photocurrent_A = $(repr(ref_photocurrent_A))")) + if ref_current_mA !== nothing + (isfinite(ref_current_mA) && ref_current_mA > 0) || throw(ArgumentError( + "PhotodiodeLoop: ref_current_mA is the open-loop drive current of the calibration reference in mA and must be finite and positive, got $(ref_current_mA)")) + (isfinite(ref_photocurrent_A) && 0 < ref_photocurrent_A <= tia_range) || throw(ArgumentError( + "PhotodiodeLoop: ref_photocurrent_A is the photocurrent in A measured at ref_current_mA, decoded with tia_range, and must be finite with 0 < ref_photocurrent_A <= tia_range ($(tia_range) A), got $(ref_photocurrent_A)")) + end + (isfinite(ref_ratio) && ref_ratio > 1) || throw(ArgumentError("PhotodiodeLoop: ref_ratio must be finite and above 1, got $(ref_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)) + Float64(ramp_step_mW), Float64(ramp_step_s), Float64(lock_check_s), Float64(lock_ratio), + ref_current_mA === nothing ? NaN : Float64(ref_current_mA), + ref_photocurrent_A === nothing ? NaN : Float64(ref_photocurrent_A), + Float64(ref_ratio), false, false) end diff --git a/test/gui.jl b/test/gui.jl index 6d4a9c1..be70dc4 100644 --- a/test/gui.jl +++ b/test/gui.jl @@ -142,7 +142,7 @@ MicroscopeControl.light_off(light::RecordingLight) = (push!(light.log, :light_of 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)) + lock_check_s=0.1)) gui(laser) GLMakie.closeall() end diff --git a/test/runtests.jl b/test/runtests.jl index 893616a..5e67353 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -132,7 +132,7 @@ lab_summary("Core") do 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 + lock_check_s=0.1, ramp_step_s=0.0, kw...) # the shortest allowed (LOCK_CHECK_MIN_S) @testset "Constructor defaults" begin # Closed loop has no default clamp: it is the only real protection. @@ -238,8 +238,9 @@ lab_summary("Core") do # 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))) + # The potentiometer's floor is the controller's readback, which + # `initialize` checks; construction no longer refuses on the header's 17.25 mA. + @test 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) @@ -359,7 +360,8 @@ lab_summary("Core") do # 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 FakeKinesis.calls[8:10] == ["LD_RequestStatusBits", "LD_RequestStatusBits", "LD_GetStatusBits"] # open loop confirmed by a fresh status read + @test FakeKinesis.calls[11:13] == ["LD_RequestLaserDiodeMaxCurrentLimit", "LD_RequestLaserDiodeMaxCurrentLimit", "LD_GetLaserDiodeMaxCurrentLimit"] @test laser.max_current == 80.0 # survived initialize @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 @@ -375,13 +377,13 @@ lab_summary("Core") do empty!(FakeKinesis.calls) setcurrent!(laser, 40.0) @test isempty(FakeKinesis.setpoints) - @test FakeKinesis.calls == ["LD_RequestStatusBits", "LD_GetStatusBits"] # a fresh read: the output was recorded off + @test FakeKinesis.calls == ["LD_RequestStatusBits", "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", + @test FakeKinesis.calls == ["LD_RequestLaserDiodeMaxCurrentLimit", "LD_RequestLaserDiodeMaxCurrentLimit", "LD_GetLaserDiodeMaxCurrentLimit", "LD_EnableOutput", "LD_SetLaserSetPoint", "LD_GetLaserSetPoint"] @test FakeKinesis.setpoints == [UInt16(5957)] @test FakeKinesis.setpoint_held[] == 5957 @@ -519,26 +521,27 @@ lab_summary("Core") do laser.properties.is_on = true initialize(laser) - clamp_read = "LD_RequestMaxCurrentDigPot", "LD_GetMaxCurrentDigPot" - limit_read = "LD_RequestLaserDiodeMaxCurrentLimit", "LD_GetLaserDiodeMaxCurrentLimit" + clamp_read = "LD_RequestMaxCurrentDigPot", "LD_RequestMaxCurrentDigPot", "LD_GetMaxCurrentDigPot" + limit_read = "LD_RequestLaserDiodeMaxCurrentLimit", "LD_RequestLaserDiodeMaxCurrentLimit", "LD_GetLaserDiodeMaxCurrentLimit" + status_read = "LD_RequestStatusBits", "LD_RequestStatusBits", "LD_GetStatusBits" + pot_set = ("LD_EnableMaxCurrentAdjust", "LD_SetMaxCurrentDigPot", clamp_read..., "LD_EnableMaxCurrentAdjust", limit_read...) @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", + status_read..., # 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..., + pot_set..., # 4: closed loop, confirmed from the status word - "LD_SetClosedLoopMode", "LD_RequestStatusBits", "LD_GetStatusBits", + "LD_SetClosedLoopMode", status_read..., # 5: the display calibration, confirmed - "LD_SetWACalibFactor", "LD_RequestWACalibFactor", "LD_GetWACalibFactor", + "LD_SetWACalibFactor", "LD_RequestWACalibFactor", "LD_RequestWACalibFactor", "LD_GetWACalibFactor", # shared tail: the controller's limit - "LD_RequestReadings", limit_read...] + limit_read...] @test "LD_EnableOutput" ∉ FakeKinesis.calls # initialize never emits @test FakeKinesis.bits[] & FakeKinesis.ENABLED == 0 @test laser.properties.is_on == false @@ -704,7 +707,6 @@ lab_summary("Core") do # 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) @@ -717,16 +719,275 @@ lab_summary("Core") do @test cp().pd.lock_ratio == 1.5 && cp().pd.ramp_step_mW == Inf end + @testset "fresh reads request twice: the fake answers one request behind (fake SDK)" begin + FK = FakeKinesis + LSI = MicroscopeControl.HardwareInterfaces.LightSourceInterface + ready_cp(; kw...) = (FK.reset!(); FK.limit_follows_pot[] = true; l = cp(; kw...); initialize(l); l) + enabled() = FK.bits[] & FK.ENABLED != 0 + # N1: the fake models fact 2, and readings are signed. + FK.reset!() + TCube.LD_RequestStatusBits("0") + FK.setbits!(FK.ENABLED) + TCube.LD_RequestStatusBits("0") + @test TCube.LD_GetStatusBits("0") & FK.ENABLED == 0 # one behind + TCube.LD_RequestStatusBits("0") + @test TCube.LD_GetStatusBits("0") & FK.ENABLED != 0 + FK.reset!() + @test TCube.read_photocurrent_word(cc()) == FK.PD_DARK_RAW # -4: dark, signed + # N2 (open loop): a pot raised between two `light_on`s makes the second refuse. + FK.reset!(limit_raw = floor(Int, 90 / 220 * 32767)) + l = cc(; max_current=100.0) + initialize(l) + setcurrent!(l, 10.0); light_on(l); light_off(l) + FK.diode_limit_raw[] = 23830 # the pot raised to 160 mA at the front panel + n = count(==("LD_EnableOutput"), FK.calls) + m = length(FK.calls) + @test_throws r"current limit stored in the controller" light_on(l) + @test count(==("LD_EnableOutput"), FK.calls) == n && !enabled() && !l.properties.is_on + @test "LD_DisableOutput" ∉ FK.calls[m+1:end] # M1: a refusal with the output off makes no disable + # M1: clamp drift while lit, open loop: the refusal also turns the diode off. + FK.reset!(limit_raw = floor(Int, 90 / 220 * 32767)) + l = cc(; max_current=100.0) + initialize(l) + setcurrent!(l, 50.0); light_on(l) + @test enabled() && l.properties.is_on + FK.diode_limit_raw[] = 23830 + @test_throws r"current limit stored in the controller" light_on(l) + @test !enabled() && !l.properties.is_on + # N3: the same in power mode. + l = ready_cp() + setoutputpower!(l, 10.0); light_on(l); light_off(l) + FK.digpot[] = 204 + n = count(==("LD_EnableOutput"), FK.calls) + @test_throws "clamp" light_on(l) + @test count(==("LD_EnableOutput"), FK.calls) == n + # N4: `initialize` records a limit that changed just before it. + FK.reset!() + l = cc() + TCube.LD_RequestLaserDiodeMaxCurrentLimit("0") # the cache now holds 160 mA + FK.diode_limit_raw[] = floor(Int, 90 / 220 * 32767) + initialize(l) + @test l.controller_max_current ≈ 90.0 atol = 0.01 + end + + @testset "check_lock is two-sided and makes its own reads (fake SDK)" begin + FK = FakeKinesis + LSI = MicroscopeControl.HardwareInterfaces.LightSourceInterface + ready_cp(; kw...) = (FK.reset!(); FK.limit_follows_pot[] = true; l = cp(; kw...); initialize(l); l) + enabled() = FK.bits[] & FK.ENABLED != 0 + code50(l) = Int(TCube.photocurrent_code(l, 50.0 / 1000 / 224.2)) + # N5: a photodiode scale drop between two `light_on`s trips `check_lock`. + l = ready_cp(); setoutputpower!(l, 50.0); light_on(l) + FK.pd_scale[] = 0.2 + @test_throws r"0x400" light_on(l) + @test !enabled() && !l.properties.is_on + i = findlast(==("LD_DisableOutput"), FK.calls) + @test FK.calls[i-1] == "LD_SetLaserSetPoint" && FK.setpoints[end] == 0 + # N6: the limit reached alone (0x400, photocurrent above 1/lock_ratio of the request). + l = ready_cp(); setoutputpower!(l, 50.0) + FK.pd_scale[] = 0.5 + @test_throws r"0x400" light_on(l) + @test !enabled() + # N7: the low side alone, by ratio. + l = ready_cp(); setoutputpower!(l, 50.0) + FK.photocurrent_raw[] = code50(l) ÷ 2 + @test_throws r"below 1/" light_on(l) + @test !enabled() + # N8: the low side for a dark photodiode. + l = ready_cp(); setoutputpower!(l, 50.0) + FK.photocurrent_raw[] = FK.PD_DARK_RAW + @test_throws r"below 1/" light_on(l) + @test !enabled() + # N9: 1.2x and 1/1.2x of the request pass. + l = ready_cp(); setoutputpower!(l, 50.0) + c = code50(l) + for w in (round(Int, 1.2c), round(Int, c / 1.2)) + FK.photocurrent_raw[] = w + light_on(l) + @test enabled() && l.properties.is_on + light_off(l) + end + # N10: at code 0 `check_lock` reads no photocurrent. + l = ready_cp(); empty!(FK.calls) + @test_logs (:warn, r"no calibration reference") (:warn, r"before any setpoint") match_mode = :any light_on(l) + @test "LD_RequestReadings" ∉ FK.calls[findfirst(==("LD_EnableOutput"), FK.calls):end] + # N11: the floor. + @test_throws ArgumentError PhotodiodeLoop(; wa_calibration=224.2, tia_range=1e-3, tec_stabilised=missing, lock_check_s=0.0) + @test_throws ArgumentError PhotodiodeLoop(; wa_calibration=224.2, tia_range=1e-3, tec_stabilised=missing, lock_check_s=0.099) + @test PhotodiodeLoop(; wa_calibration=224.2, tia_range=1e-3, tec_stabilised=missing, lock_check_s=0.1).lock_check_s == 0.1 + @test_throws ArgumentError cp(; lock_check_s=0.0) + FK.reset!() + end + + @testset "the calibration reference (fake SDK)" begin + FK = FakeKinesis + LSI = MicroscopeControl.HardwareInterfaces.LightSourceInterface + ready_cp(; kw...) = (FK.reset!(); FK.limit_follows_pot[] = true; l = cp(; kw...); initialize(l); l) + enabled() = FK.bits[] & FK.ENABLED != 0 + FK.reset!() + ref90A = FK.pd_word_at(90.0) / 32767 * 1e-3 # the fake's photocurrent at 90 mA, decoded with tia_range = 1 mA + cpr(; kw...) = cp(; ref_current_mA=90.0, ref_photocurrent_A=ref90A, kw...) + readyr(; kw...) = (FK.reset!(); FK.limit_follows_pot[] = true; l = cpr(; kw...); initialize(l); + setoutputpower!(l, 10.0); empty!(FK.calls); empty!(FK.setpoints); empty!(FK.enable_log); l) + guard = ["LD_RequestStatusBits", "LD_RequestStatusBits", "LD_GetStatusBits", + "LD_RequestLaserDiodeMaxCurrentLimit", "LD_RequestLaserDiodeMaxCurrentLimit", "LD_GetLaserDiodeMaxCurrentLimit"] + lock_tail = ["LD_RequestReadings", "LD_RequestReadings", "LD_GetPhotoCurrentReading", "LD_RequestStatusBits", "LD_RequestStatusBits", "LD_GetStatusBits"] + loopkw = (; wa_calibration=224.2, tia_range=1e-3, tec_stabilised=missing) + # N12: construction. + @test_throws ArgumentError cp(; ref_current_mA=90.0) + @test_throws ArgumentError cp(; ref_photocurrent_A=ref90A) + @test_throws ArgumentError PhotodiodeLoop(; loopkw..., ref_current_mA=90.0) + @test_throws ArgumentError PhotodiodeLoop(; loopkw..., ref_photocurrent_A=ref90A) + @test_throws ArgumentError cc(; ref_current_mA=90.0, ref_photocurrent_A=ref90A) + @test_throws r"max_current" cpr(; max_current=80.0) + for bad in (0.0, -1e-6, 2e-3, NaN) + @test_throws ArgumentError cpr(; ref_photocurrent_A=bad) + end + @test_throws ArgumentError cpr(; ref_ratio=1.0) + @test_throws r"max_power" cpr(; ref_photocurrent_A=0.5e-3) # about 112 mW > 70 + @test cpr().pd.ref_ratio == 1.5 && !cpr().pd.scale_checked + @test isnan(cp().pd.ref_current_mA) + @test 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), + ref_current_mA=90.0, ref_photocurrent_A=ref90A).pd.ref_photocurrent_A == ref90A + # N13: a matching reference: the exact sequence, then closed-loop emission. + l = readyr(); light_on(l) + @test FK.calls == [guard..., "LD_SetLaserSetPoint", "LD_DisableOutput", "LD_SetOpenLoopMode", "LD_RequestStatusBits", "LD_RequestStatusBits", "LD_GetStatusBits", + "LD_EnableOutput", "LD_SetLaserSetPoint", "LD_GetLaserSetPoint", + "LD_RequestReadings", "LD_RequestReadings", "LD_GetPhotoCurrentReading", "LD_SetLaserSetPoint", "LD_DisableOutput", + "LD_SetClosedLoopMode", "LD_RequestStatusBits", "LD_RequestStatusBits", "LD_GetStatusBits", + "LD_EnableOutput", "LD_SetLaserSetPoint", "LD_GetLaserSetPoint", lock_tail...] + @test FK.setpoints == UInt16[0, TCube.setpoint_code(l, 90.0), 0, TCube.photocurrent_code(l, 10.0 / 1000 / 224.2)] + @test length(FK.enable_log) == 2 && last(FK.enable_log) == 0 + @test enabled() && FK.bits[] & FK.CLOSED != 0 && l.properties.is_on && l.pd.scale_checked + # N14: once per initialize. + light_off(l); empty!(FK.calls); light_on(l) + @test "LD_SetOpenLoopMode" ∉ FK.calls + initialize(l); empty!(FK.calls); light_on(l) + @test "LD_SetOpenLoopMode" ∈ FK.calls + # N15: a mismatch low refuses, off and in closed loop, and latches until `initialize`. + l = readyr(); FK.pd_scale[] = 0.5 + @test_throws r"calibration reference" light_on(l) + @test !enabled() && FK.bits[] & FK.CLOSED != 0 && !l.properties.is_on && !l.pd.scale_checked && l.pd.scale_refused + n = count(==("LD_SetOpenLoopMode"), FK.calls) + @test_throws r"not lit again" light_on(l) + @test count(==("LD_SetOpenLoopMode"), FK.calls) == n + FK.pd_scale[] = 1.0; initialize(l); light_on(l) + @test enabled() && l.pd.scale_checked + # N16: a mismatch high refuses too. + l = readyr(); FK.pd_scale[] = 2.0 + @test_throws r"calibration reference" light_on(l) + @test !enabled() + # N17: a command failure during the re-check is cleaned up, and the laser needs `initialize`. + l = readyr(); FK.setpoint_readback[] = UInt16(3) + @test_logs (:error, r"calibration-reference re-check") match_mode = :any @test_throws ErrorException light_on(l) + @test !enabled() && !l.properties.is_on && !l.pd.scale_checked && FK.bits[] & FK.CLOSED == 0 + FK.setpoint_readback[] = nothing + @test_throws "closed loop" light_on(l) + # N18: a reference above the programmed clamp refuses before anything is sent. + l = readyr(; ref_current_mA=159.95) + @test_throws ArgumentError light_on(l) + @test "LD_SetOpenLoopMode" ∉ FK.calls && "LD_EnableOutput" ∉ FK.calls + # N19: no reference: one warning per initialize, and no extra commands. + l = ready_cp(); setoutputpower!(l, 10.0) + @test_logs (:warn, r"no calibration reference") match_mode = :any light_on(l) + @test "LD_SetOpenLoopMode" ∉ FK.calls + light_off(l) + @test_logs light_on(l) + initialize(l) + @test_logs (:warn, r"no calibration reference") match_mode = :any light_on(l) + # Fact 6: a relabelled tia_range fails the re-check. + FK.reset!(); FK.limit_follows_pot[] = true + FK.bits[] = (FK.bits[] & ~FK.TIA_1mA) | FK.TIA_10mA # the amplifier reports the 10 mA range; its words are unchanged + l = cpr(; tia_range=1e-2) # the reference was recorded on 1 mA; the config now states 10 mA + initialize(l); setoutputpower!(l, 10.0) + @test_throws r"calibration reference" light_on(l) + @test !enabled() && !l.pd.scale_checked + # L7: a range change the words follow passes. + FK.reset!(); FK.limit_follows_pot[] = true + FK.bits[] = (FK.bits[] & ~FK.TIA_1mA) | FK.TIA_10mA + FK.pd_scale[] = 0.1 # ten times the range, a tenth of the words: the photocurrent in amps is the reference's + l = cp(; tia_range=1e-2, ref_current_mA=90.0, ref_photocurrent_A=ref90A) + initialize(l); setoutputpower!(l, 10.0) + light_on(l) + @test enabled() && l.pd.scale_checked && !l.pd.scale_refused + setoutputpower!(l, 10.0) + # L1: the re-check confirms open loop before it enables. + l = readyr(); FK.open_loop_ignored[] = true + @test_throws r"still reports closed loop" light_on(l) + @test "LD_EnableOutput" ∉ FK.calls && !l.properties.is_on && !l.pd.scale_checked && !enabled() + # L2: every initialize starts from a cleared clamp and re-check state, failed or not. + l = readyr(); light_on(l) + @test l.pd.scale_checked + FK.fail!("LD_Open") + @test_throws Exception initialize(l) + @test !l.pd.scale_checked && !l.pd.scale_refused && isnan(l.pd.max_current_clamp) + # L10: a mismatch latches until `initialize`, and the diode is not lit again to re-check it. + l = readyr(); FK.pd_scale[] = 0.2 + @test_throws r"calibration reference" light_on(l) + @test l.pd.scale_refused + empty!(FK.calls) + @test_throws r"not lit again" light_on(l) + @test "LD_EnableOutput" ∉ FK.calls && "LD_SetOpenLoopMode" ∉ FK.calls + FK.pd_scale[] = 1.0; initialize(l); light_on(l) + @test l.pd.scale_checked && !l.pd.scale_refused + # M5: a refusal from set_open_loop! inside the re-check latches too. + l = readyr(); FK.open_loop_ignored[] = true + @test_throws r"still reports closed loop" light_on(l) + @test l.pd.scale_refused + empty!(FK.calls) + @test_throws r"not lit again" light_on(l) + @test "LD_SetOpenLoopMode" ∉ FK.calls && "LD_EnableOutput" ∉ FK.calls + # M2: open-loop initialize confirms open loop. + FK.reset!(); FK.bits[] |= FK.CLOSED; FK.open_loop_ignored[] = true + l = cc() + @test_throws r"still reports closed loop" initialize(l) + @test "LD_EnableOutput" ∉ FK.calls && "LD_SetOpenLoopMode" ∈ FK.calls + @test last(FK.calls, 2) == ["LD_StopPolling", "LD_Close"] + # L3: `light_on` checks the photodiode range against the fresh status word. + l = ready_cp(); setoutputpower!(l, 10.0) + FK.bits[] = (FK.bits[] & ~FK.TIA_1mA) | FK.TIA_10mA; empty!(FK.calls) + @test_throws r"photodiode range" light_on(l) + @test "LD_EnableOutput" ∉ FK.calls + # M1: DIP relabel while lit: the refusal zeroes and disables the output. + l = ready_cp(); setoutputpower!(l, 10.0); light_on(l); setoutputpower!(l, 10.0) + @test enabled() && l.properties.is_on + FK.bits[] = (FK.bits[] & ~FK.TIA_1mA) | FK.TIA_10mA; empty!(FK.calls); empty!(FK.setpoints) + @test_throws r"photodiode range" setoutputpower!(l, 10.0) + @test !enabled() && !l.properties.is_on + iD = findlast(==("LD_DisableOutput"), FK.calls) + @test iD !== nothing && findlast(==("LD_SetLaserSetPoint"), FK.calls[1:iD]) !== nothing && last(FK.setpoints) == 0 + # L6: `setoutputpower!` decides on fresh reads: the polled word is healthy, the fresh one says over range. + l = ready_cp(); setoutputpower!(l, 10.0) + @test_logs (:warn, r"no calibration reference") match_mode = :any light_on(l) + FK.poll!(); FK.stale_bits[] = FK.bits[]; FK.setbits!(FK.TIA_OVER) + @test_throws r"OVER range" setoutputpower!(l, 20.0) + # L4: at code 0 `check_lock` still tests the current limit. + l = ready_cp(; properties=LightSourceProperties("mW", 0.0, false, 0.0, 70.0)); setoutputpower!(l, 10.0) + @test_logs (:warn, r"no calibration reference") match_mode = :any light_on(l) + setoutputpower!(l, 0.0) + setoutputpower!(l, 10.0) + FK.force_limit_bit[] = true + @test_throws r"0x400" setoutputpower!(l, 0.0) + # L5: a high-side trip makes no status request after its last photocurrent read. + l = readyr(); light_on(l) + FK.photocurrent_raw[] = 30000; empty!(FK.calls) + @test_throws r"loop lock" setoutputpower!(l, 20.0) + @test "LD_RequestStatusBits" ∉ FK.calls[findlast(==("LD_GetPhotoCurrentReading"), FK.calls):end] + FK.reset!() + 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"] + guard = ["LD_RequestStatusBits", "LD_RequestStatusBits", "LD_GetStatusBits", + "LD_RequestLaserDiodeMaxCurrentLimit", "LD_RequestLaserDiodeMaxCurrentLimit", "LD_GetLaserDiodeMaxCurrentLimit"] + lock_tail = ["LD_RequestReadings", "LD_RequestReadings", "LD_GetPhotoCurrentReading", "LD_RequestStatusBits", "LD_RequestStatusBits", "LD_GetStatusBits"] # check_lock's own reads FakeKinesis.reset!() laser = cp() @test_throws "clamp" setoutputpower!(laser, 10.0) @test_throws "clamp" light_on(laser) - @test isempty(FakeKinesis.calls) + @test "LD_EnableOutput" ∉ FakeKinesis.calls && "LD_SetLaserSetPoint" ∉ FakeKinesis.calls # only the may-be-on status read initialize(laser) # Out of the declared [1, 70] mW is refused before anything is sent. @@ -745,7 +1006,7 @@ lab_summary("Core") do 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 FakeKinesis.calls == [guard...] @test isempty(FakeKinesis.setpoints) @test laser.pd.output_power_requested == 50.0 empty!(FakeKinesis.calls) @@ -754,7 +1015,7 @@ lab_summary("Core") do # 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 + lock_tail...] # then check_lock's own reads code = FakeKinesis.setpoints[end] @test code == UInt16(floor(i_pd / 1e-3 * 32767)) @test FakeKinesis.setpoint_held[] == code @@ -766,10 +1027,11 @@ lab_summary("Core") do 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]) + g, t = length(guard), length(lock_tail) + @test FakeKinesis.calls[1:g+1] == [guard..., "LD_EnableOutput"] + @test FakeKinesis.calls[end-t+1:end] == lock_tail + @test FakeKinesis.calls[end-t] == "LD_GetLaserSetPoint" + @test all(==("LD_SetLaserSetPoint"), FakeKinesis.calls[g+2:end-t-1]) @test FakeKinesis.setpoints[end] == code step = round(Int, 3.0 / 1000 / 224.2 / 1e-3 * 32767) @test 15 <= length(FakeKinesis.setpoints) <= 18 @@ -782,8 +1044,8 @@ lab_summary("Core") do # 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"] + @test FakeKinesis.calls == [guard..., "LD_RequestReadings", "LD_RequestReadings", "LD_GetPhotoCurrentReading", "LD_SetLaserSetPoint", "LD_GetLaserSetPoint", + lock_tail...] # Downward steps are sent directly, no ramp. empty!(FakeKinesis.calls); empty!(FakeKinesis.setpoints) setoutputpower!(laser, 10.0) @@ -794,7 +1056,7 @@ lab_summary("Core") do @test_throws "OVER" setoutputpower!(laser, 20.0) @test measured_photocurrent(laser) == Inf @test loop_status(laser).tia_over - FakeKinesis.photocurrent_raw[] = 0 + FakeKinesis.photocurrent_raw[] = nothing 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 @@ -826,7 +1088,9 @@ lab_summary("Core") do # ... 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) + @test !laser.properties.is_on && !FakeKinesis.enabled() # M1: the refusal turned the lit output off FakeKinesis.setbits!(FakeKinesis.CLOSED) + light_on(laser) # lit again for the readback test below # A setpoint the controller does not confirm is not recorded. FakeKinesis.setpoint_readback[] = UInt16(3) @test_throws ErrorException setoutputpower!(laser, 30.0) @@ -931,17 +1195,18 @@ lab_summary("Core") do @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. + # A ceiling no position reaches (the fake's limit is 160 mA at every position): the + # search walks the pot down, fails, and warns; the pot is only ever lowered. 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_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 > 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)]) + append!(FK.limit_raw_queue, [23830, 23830, floor(Int, 90 / 220 * 32767), floor(Int, 90 / 220 * 32767)]) # each read is two requests and answers the first: every value twice initialize(laser) @test all(<=(204), FK.digpot_sets) && FK.digpot[] == 204 @test isempty(FK.limit_raw_queue) @@ -963,6 +1228,12 @@ lab_summary("Core") do @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 + # A ceiling the removed 17.25 mA gate refused: on the fake's pot it settles at position 31 (~16.98 mA). + FK.reset!(); FK.limit_follows_pot[] = true + l = cc(; max_current=17.0); initialize(l) + @test l.controller_max_current <= 17.0 + setcurrent!(l, 10.0); light_on(l) + @test l.properties.is_on FK.reset!() end @@ -1033,8 +1304,8 @@ lab_summary("Core") do # 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[] = -32768 + @test measured_current(laser) == TCube.setpoint_current(laser, -32768) FakeKinesis.current_raw[] = 5957 @test measured_current(laser) == TCube.setpoint_current(laser, 5957) diff --git a/test/tcube_fake_sdk.jl b/test/tcube_fake_sdk.jl index 14e6797..f31ee0e 100644 --- a/test/tcube_fake_sdk.jl +++ b/test/tcube_fake_sdk.jl @@ -52,6 +52,19 @@ # 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. +# +# Since 0.2.6 the fake answers ONE REQUEST BEHIND, as the 642 nm rig's TLD001 +# does (2026-09-29): a request captures the controller's state, and the getters +# return the state captured at the PREVIOUS request of the same kind, so two +# requests in a row answer the state at the first. This holds for the status +# word, the readings, the current limit, the potentiometer position and the W/A +# factor. The exception is the setpoint read-back, which stays live: the driver +# never requests it and the rig verified that polling refreshes it. The fake +# also models the photodiode on the rig's numbers (threshold 67.6 mA, ~145 +# words per mA above it on the 1 mA range) with a `pd_scale` knob, and sets the +# current-limit status bit (0x400) whenever the model saturates. +# `photocurrent_raw` is an override of the model. `poll!` makes the status word +# and the readings current, as a background poll that had caught up would. """ FakeKinesis @@ -91,7 +104,7 @@ driver's default scale. """ const diode_limit_raw = Ref{Int}(23830) -"Raw limit values the next `LD_GetLaserDiodeMaxCurrentLimit` reads return, first in first out, before `diode_limit_raw` applies again." +"Raw limit values, first in first out, before `diode_limit_raw` applies again: consumed one per capture (each `LD_RequestLaserDiodeMaxCurrentLimit`, or a limit read before any request). A driver read is two requests and answers the first, so give each value twice." 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`)." @@ -100,13 +113,33 @@ 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) +"Raw photocurrent reading (`LD_GetPhotoCurrentReading`): an override of the photodiode model; `nothing` uses the model." +const photocurrent_raw = Ref{Union{Nothing,Int}}(nothing) + +"What each kind's getters return (set by its requests), and the state captured at its last request." +const answered = Dict{Symbol,Any}() +const pending = Dict{Symbol,Any}() + +"The status bit the controller sets when the drive current is at its limit." +const LIMIT = 0x00000400 +"The photocurrent word with no light: raw 65532 read signed (642 nm rig, 2026-09-29)." +const PD_DARK_RAW = -4 +"The 642 nm rig's photodiode (2026-09-29): threshold 67.6 mA, ~145 words per mA above it on the 1 mA range." +const pd_threshold_mA = Ref(67.6) +const pd_words_per_mA = Ref(145.0) +"Counts per unit light relative to calibration: 1.0 is the calibrated setup; 0.5 gives half the words for the same light." +const pd_scale = Ref(1.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 +"While `true`, `LD_SetOpenLoopMode` records the call and returns success but leaves the closed-loop bit set." +const open_loop_ignored = Ref(false) + +"While `true`, the captured status word has the current-limit bit (`LIMIT`, 0x400) set whatever the model says." +const force_limit_bit = Ref(false) + "The status word: key, interlock, PSU OK and the 1 mA range by default." const bits = Ref{UInt32}(KEY | INTERLOCK | PSU_OK | TIA_1mA) @@ -174,9 +207,16 @@ function reset!(; limit_raw::Integer=23830, stored::Integer=0) empty!(limit_raw_queue) stale_bits[] = nothing current_raw[] = nothing - photocurrent_raw[] = 0 + photocurrent_raw[] = nothing + empty!(answered) + empty!(pending) + pd_scale[] = 1.0 + pd_threshold_mA[] = 67.6 + pd_words_per_mA[] = 145.0 bits[] = KEY | INTERLOCK | PSU_OK | TIA_1mA closed_loop_takes[] = true + open_loop_ignored[] = false + force_limit_bit[] = false setpoint_held[] = stored setpoint_readback[] = nothing digpot[] = 204 @@ -210,6 +250,36 @@ throw!(op::AbstractString, msg::AbstractString="fake Kinesis failure in $op") = "Set or clear status bits." setbits!(mask; on::Bool=true) = (bits[] = on ? (bits[] | UInt32(mask)) : (bits[] & ~UInt32(mask)); nothing) +enabled() = bits[] & ENABLED != 0 +closed() = bits[] & CLOSED != 0 +"The controller's limit in mA, ignoring `limit_raw_queue` (the queue shapes what reads return, not the physics)." +limit_mA_live() = limit_follows_pot[] ? limit_mA_for(digpot[]) : diode_limit_raw[] / 32767 * 220 +setpoint_mA() = setpoint_held[] / 32767 * 220 +"The drive current the closed loop needs to hold the held setpoint word." +needed_mA() = pd_threshold_mA[] + setpoint_held[] / (pd_scale[] * pd_words_per_mA[]) +pd_word_at(mA) = mA <= pd_threshold_mA[] ? PD_DARK_RAW : + round(Int, pd_scale[] * pd_words_per_mA[] * (mA - pd_threshold_mA[])) +saturated_now() = enabled() && (closed() ? needed_mA() > limit_mA_live() : setpoint_mA() > limit_mA_live()) +function photocurrent_now() + photocurrent_raw[] === nothing || return photocurrent_raw[] + enabled() || return PD_DARK_RAW + (closed() && !saturated_now()) && return Int(setpoint_held[]) # the loop holds its setpoint + return pd_word_at(min(closed() ? needed_mA() : setpoint_mA(), limit_mA_live())) +end +function capture(kind::Symbol) + kind === :status && return bits[] | (saturated_now() || force_limit_bit[] ? LIMIT : 0x00000000) + kind === :readings && return (current = something(current_raw[], diode_limit_raw[]), photocurrent = photocurrent_now()) + kind === :limit && return (isempty(limit_raw_queue) ? (limit_follows_pot[] ? + floor(Int, limit_mA_for(digpot[]) / 220 * 32767) : diode_limit_raw[]) : popfirst!(limit_raw_queue)) + kind === :digpot && return digpot[] + kind === :wa && return something(wa_readback[], wa[]) + error("FakeKinesis: unknown kind $kind") +end +request!(kind::Symbol) = (now = capture(kind); answered[kind] = get(pending, kind, now); pending[kind] = now; nothing) +answer(kind::Symbol) = haskey(answered, kind) ? answered[kind] : capture(kind) +"A poll that has caught up: `:status` and `:readings` answer the live state (what the header says polling requests)." +poll!() = (stale_bits[] = nothing; for k in (:status, :readings); now = capture(k); answered[k] = now; pending[k] = now; end; nothing) + end @eval MicroscopeControl.HardwareImplementations.TCubeLaserControl begin @@ -219,7 +289,7 @@ end LD_Close(serialNo) = (Main.FakeKinesis.record!("LD_Close"); nothing) function LD_SetOpenLoopMode(serialNo) s = Main.FakeKinesis.record!("LD_SetOpenLoopMode") - s == 0 && Main.FakeKinesis.setbits!(Main.FakeKinesis.CLOSED; on=false) + (s == 0 && !Main.FakeKinesis.open_loop_ignored[]) && Main.FakeKinesis.setbits!(Main.FakeKinesis.CLOSED; on=false) return s end function LD_SetClosedLoopMode(serialNo) @@ -227,9 +297,9 @@ end (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_RequestReadings(serialNo) = (s = Main.FakeKinesis.record!("LD_RequestReadings"); Main.FakeKinesis.request!(:readings); s) LD_RequestLaserDiodeMaxCurrentLimit(serialNo) = - Main.FakeKinesis.record!("LD_RequestLaserDiodeMaxCurrentLimit") + (s = Main.FakeKinesis.record!("LD_RequestLaserDiodeMaxCurrentLimit"); Main.FakeKinesis.request!(:limit); s) function LD_EnableOutput(serialNo) s = Main.FakeKinesis.record!("LD_EnableOutput") if s == 0 @@ -259,25 +329,25 @@ end end function LD_GetLaserDiodeMaxCurrentLimit(serialNo) Main.FakeKinesis.record!("LD_GetLaserDiodeMaxCurrentLimit") - isempty(Main.FakeKinesis.limit_raw_queue) || return popfirst!(Main.FakeKinesis.limit_raw_queue) - Main.FakeKinesis.limit_follows_pot[] || return Main.FakeKinesis.diode_limit_raw[] - return floor(Int, Main.FakeKinesis.limit_mA_for(Main.FakeKinesis.digpot[]) / 220 * 32767) + return Main.FakeKinesis.answer(:limit) end function LD_GetLaserDiodeCurrentReading(serialNo) Main.FakeKinesis.record!("LD_GetLaserDiodeCurrentReading") - return something(Main.FakeKinesis.current_raw[], Main.FakeKinesis.diode_limit_raw[]) + return Main.FakeKinesis.answer(:readings).current end function LD_GetPhotoCurrentReading(serialNo) Main.FakeKinesis.record!("LD_GetPhotoCurrentReading") - return Main.FakeKinesis.photocurrent_raw[] + return Main.FakeKinesis.answer(:readings).photocurrent end function LD_RequestStatusBits(serialNo) Main.FakeKinesis.stale_bits[] = nothing - return Main.FakeKinesis.record!("LD_RequestStatusBits") + s = Main.FakeKinesis.record!("LD_RequestStatusBits") + Main.FakeKinesis.request!(:status) + return s end function LD_GetStatusBits(serialNo) Main.FakeKinesis.record!("LD_GetStatusBits") - return something(Main.FakeKinesis.stale_bits[], Main.FakeKinesis.bits[]) + return something(Main.FakeKinesis.stale_bits[], Main.FakeKinesis.answer(:status)) end function LD_EnableMaxCurrentAdjust(serialNo, enableAdjust, enableDiode) push!(Main.FakeKinesis.adjust_calls, (enableAdjust, enableDiode)) @@ -289,20 +359,20 @@ end (s == 0 && Main.FakeKinesis.digpot_takes[]) && (Main.FakeKinesis.digpot[] = Int(maxCurrent)) return s end - LD_RequestMaxCurrentDigPot(serialNo) = Main.FakeKinesis.record!("LD_RequestMaxCurrentDigPot") + LD_RequestMaxCurrentDigPot(serialNo) = (s = Main.FakeKinesis.record!("LD_RequestMaxCurrentDigPot"); Main.FakeKinesis.request!(:digpot); s) function LD_GetMaxCurrentDigPot(serialNo) Main.FakeKinesis.record!("LD_GetMaxCurrentDigPot") - return UInt16(Main.FakeKinesis.digpot[]) + return UInt16(Main.FakeKinesis.answer(: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") + LD_RequestWACalibFactor(serialNo) = (s = Main.FakeKinesis.record!("LD_RequestWACalibFactor"); Main.FakeKinesis.request!(:wa); s) function LD_GetWACalibFactor(serialNo) Main.FakeKinesis.record!("LD_GetWACalibFactor") - return something(Main.FakeKinesis.wa_readback[], Main.FakeKinesis.wa[]) + return Main.FakeKinesis.answer(:wa) end function LD_StartPolling(serialNo, milliseconds) Main.FakeKinesis.record!("LD_StartPolling")