From 7acee6cdc99b110f4cf0babe1124f4def82899ed Mon Sep 17 00:00:00 2001 From: kalidke Date: Tue, 29 Sep 2026 14:53:09 -0600 Subject: [PATCH 1/5] main: 0.2.6-DEV after the 0.2.5 release; an empty [Unreleased] section Decision 0033: after X.Y.Z, main carries X.Y.(Z+1)-DEV. TagOnMerge skips a -DEV version. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 ++ Project.toml | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f621ec..0bb5c07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ the README's Installation section: in `0.x.y`, `x` is the breaking component and `y` is the non-breaking one (releases are tagged; between them `main` carries the next version with `-DEV`). +## [Unreleased] + ## [0.2.5] - 2026-09-29 A non-breaking release. It brings the TCube laser's closed-loop (power) mode and 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] From ddaa9b7c5c32b660414f56af3d6fe84c524f5dc3 Mon Sep 17 00:00:00 2001 From: kalidke Date: Tue, 29 Sep 2026 16:38:07 -0600 Subject: [PATCH 2/5] TCube: request twice before every fresh read; two-sided check_lock; calibration-reference re-check at the first light_on - (i) request_twice: every request-then-get site (limit, digpot, status, initialize's limit read, W/A read, tcube_get_current, new read_photocurrent_word) sends its request twice, since the TLD001 answers one request behind. - (ii) check_lock reads its own photocurrent and status, and refuses on a high ratio, on 0x400, or on a photocurrent below 1/lock_ratio of the request; returns at code 0. PhotodiodeLoop refuses lock_check_s below LOCK_CHECK_MIN_S = 0.1. - (iii) PhotodiodeLoop, TCubeLaser and SimDiodeLaser take ref_current_mA, ref_photocurrent_A, ref_ratio; the first power-mode light_on after each initialize re-measures the reference in open loop (check_scale!) and refuses on a mismatch, or warns once when there is none. - Small fixes: measured_current accepts -32768; the header's 17.25 mA potentiometer floor gate is removed; the step estimate is the manual's 0.7 mA. - The fake answers one request behind, models the photodiode and the 0x400 bit; tests N1-N21 added. Co-Authored-By: Claude Sonnet 5.5 --- .../SimulatedDiodeLaser.jl | 13 +- .../tcube_laser/interface_methods.jl | 297 +++++++++++++----- .../tcube_laser/types.jl | 20 +- .../lightsource_interface/diode_laser.jl | 20 +- .../interface_functions.jl | 3 +- .../lightsource_interface/interface_types.jl | 66 +++- test/gui.jl | 2 +- test/runtests.jl | 250 +++++++++++++-- test/tcube_fake_sdk.jl | 96 +++++- 9 files changed, 611 insertions(+), 156 deletions(-) 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/interface_methods.jl b/src/hardware_implementations/tcube_laser/interface_methods.jl index c534938..cffab1c 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,30 +326,19 @@ 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.7 mA per position (p.28, p.38), used only to choose the next position. The +Kinesis header gives the scale as `position * 220 / 255` mA, and **the controller does +not follow it**; it is not used anywhere. 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. +[`program_clamp!`](@ref) reads the controller's own limit after each setting. Since +0.7 is below the measured step, a move can overshoot by a position or two, and the +search corrects it with the output off. """ 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 +const DIGPOT_STEP_ESTIMATE_mA = 0.7 """ CLAMP_WAIT_S @@ -346,15 +353,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 @@ -392,10 +397,10 @@ Leave the controller's diode current limit at the highest potentiometer position whose limit, **as the controller reports it**, does not exceed `light.max_current`, and return that limit in mA. -The search starts from the current position. It moves by the header's step -estimate ([`DIGPOT_STEP_ESTIMATE_mA`](@ref)), which is larger than the real step -on the rig's controller, so moves fall short and approach the ceiling from one -side. It never needs more than a few settings, and none if the present position +The search starts from the current position. It moves by the manual's step +estimate ([`DIGPOT_STEP_ESTIMATE_mA`](@ref)), then 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`). @@ -409,6 +414,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 +509,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 @@ -600,27 +615,127 @@ 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)); +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 returns at once, with no wait and no reads: a zero request has no +lock to catch and no deficit to measure. Its own reads cost about `lock_check_s + 4 x +REQUEST_WAIT_S`. 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 + code == 0 && return nothing # nothing requested: no lock to catch, no deficit to measure + pd, serialNo = light.pd, light.serialNo sleep(pd.lock_check_s) - measured = measured_photocurrent(light) + measured = photocurrent_from_raw(light, read_photocurrent_word(light), pd.tia_range) requested = photocurrent_from_code(light, code, pd.tia_range) - (code > 0 && measured > pd.lock_ratio * requested) && error( + bits = read_status_fresh(serialNo) + 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.") + bits & STATUS_BITS.saturated != 0 && error( + "TCubeLaser $serialNo: the controller reports its current limit reached (status 0x400) holding a request of $(requested) A; " * + "the photodiode reads $(measured) A. 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).") + 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 + +""" + check_scale!(light::TCubeLaser{ConstantPhotocurrent}) + +The calibration-reference re-check: runs at most once per `initialize` +(`pd.scale_checked`, 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, enables, sends the setpoint for `ref_current_mA` (after the enable: a +setpoint sent with the output off is ignored), waits `pd.lock_check_s`, 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. + +A command failure inside the check is cleaned up ([`disable_after_failure`](@ref)) and +rethrown; the mode is then unknown, so the next `light_on` refuses ("not in closed loop") +until `initialize`. A mismatch leaves the output off and closed loop restored, and +`scale_checked` false: the next `light_on` re-checks, which emits at the reference again, +and refuses again while the mismatch holds. + +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 +`lock_check_s + 2 x REQUEST_WAIT_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_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 + 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 + enter_mode!(ConstantCurrent(), light) # LD_SetOpenLoopMode, output off + 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(pd.lock_check_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 + 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.") + pd.scale_checked = true return nothing end @@ -666,9 +781,7 @@ write depends on only refreshes through it (see limit is above `max_current`, the potentiometer is then lowered until it is not ([`lower_open_loop_clamp!`](@ref)) and `controller_max_current` is the limit that results; if it is at or below `max_current` the potentiometer is never -touched, so a limit a rig set lower by hand stays. 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 +796,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 +814,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 @@ -746,9 +865,7 @@ function initialize(light::TCubeLaser) # 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) @@ -773,10 +890,20 @@ end enter_mode!(::ConstantCurrent, light::TCubeLaser) = check_err(LD_SetOpenLoopMode(light.serialNo), "LD_SetOpenLoopMode", light.serialNo) +"`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 + 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 + pd.scale_checked = false # every initialize re-arms the calibration-reference re-check # 1. key switch and interlock bits = read_status_fresh(serialNo) @@ -797,14 +924,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 +957,14 @@ 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`, reads the photodiode and refuses on a mismatch, leaving the output off; +without one it warns once and carries on. `[limitation]` Between the enable and the setpoint the controller runs on its stored setpoint, bounded in hardware only by its current-limit potentiometer; @@ -852,12 +983,13 @@ 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 two 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) @@ -966,9 +1098,9 @@ Command the optical power at the laser output, in mW -- the plane where 1. Refuse unless `initialize` programmed the clamp, and re-check the controller: a fresh status read must report closed loop and a fresh limit read must not exceed `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 @@ -984,7 +1116,8 @@ Command the optical power at the laser output, in mW -- the plane where 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`. @@ -1149,12 +1282,13 @@ 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), and a raw value +outside it throws. """ function LightSourceInterface.measured_current(light::TCubeLaser) raw = Int(LD_GetLaserDiodeCurrentReading(light.serialNo)) - abs(raw) <= SETPOINT_PROTOCOL_MAX || error( - "TCubeLaser $(light.serialNo): diode current reading $(raw) is outside the protocol's ±$(SETPOINT_PROTOCOL_MAX)") + -SETPOINT_PROTOCOL_MAX - 1 <= raw <= SETPOINT_PROTOCOL_MAX || error( + "TCubeLaser $(light.serialNo): diode current reading $(raw) is outside the protocol's -32768..32767") return setpoint_current(light, raw) end @@ -1236,8 +1370,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..aa96d8e 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, twice the TCube +driver's 50 ms polling period. 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,33 @@ 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. + +Construct it with the keyword form, which fills the last eleven fields. """ mutable struct PhotodiodeLoop wa_calibration::Float64 @@ -150,29 +179,48 @@ 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 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) 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..c91f797 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,7 @@ 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:11] == ["LD_RequestReadings", "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 +376,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,24 +520,26 @@ 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. + # over the 160 mA ceiling. The manual's 0.7 mA step estimate moves it to 202 + # (159.08 mA), then up one to 203 (159.91 mA), where it stops. clamp_read..., limit_read..., - "LD_EnableMaxCurrentAdjust", "LD_SetMaxCurrentDigPot", clamp_read..., "LD_EnableMaxCurrentAdjust", - limit_read..., + pot_set..., 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...] @test "LD_EnableOutput" ∉ FakeKinesis.calls # initialize never emits @@ -546,8 +549,8 @@ lab_summary("Core") do @test FakeKinesis.setpoints == [UInt16(0)] @test FakeKinesis.setpoint_held[] == 0 # The diode flag is never raised: passed as false both times. - @test FakeKinesis.adjust_calls == [(true, false), (false, false)] - @test FakeKinesis.digpot_sets == [203] + @test FakeKinesis.adjust_calls == [(true, false), (false, false), (true, false), (false, false)] + @test FakeKinesis.digpot_sets == [202, 203] # The clamp is the controller's own reported limit, under the ceiling. reported = TCube.setpoint_current(laser, floor(Int, FakeKinesis.limit_mA_for(203) / 220 * 32767)) @test laser.pd.max_current_clamp == reported @@ -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,11 +719,188 @@ 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) + @test_throws r"current limit stored in the controller" light_on(l) + @test count(==("LD_EnableOutput"), FK.calls) == n && !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 nothing. + 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 LSI.LOCK_CHECK_MIN_S >= 2 * TCube.POLL_INTERVAL_MS / 1000 + @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_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 the next `light_on` re-checks. + 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 + n = count(==("LD_SetOpenLoopMode"), FK.calls) + @test_throws r"calibration reference" light_on(l) + @test count(==("LD_SetOpenLoopMode"), FK.calls) == n + 1 + FK.pd_scale[] = 1.0; 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 + 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) @@ -754,7 +933,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 +945,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 @@ -783,7 +963,7 @@ lab_summary("Core") do empty!(FakeKinesis.calls) setoutputpower!(laser, 50.0) @test FakeKinesis.calls == [guard..., "LD_GetStatusBits", "LD_GetPhotoCurrentReading", "LD_SetLaserSetPoint", "LD_GetLaserSetPoint", - "LD_GetPhotoCurrentReading"] + lock_tail...] # Downward steps are sent directly, no ramp. empty!(FakeKinesis.calls); empty!(FakeKinesis.setpoints) setoutputpower!(laser, 10.0) @@ -791,10 +971,11 @@ lab_summary("Core") do setoutputpower!(laser, 50.0) # 0x8000 is the rig controller's over-range reading: refuse, and report it. FakeKinesis.photocurrent_raw[] = -32768 + FakeKinesis.poll!() # the polled cache has caught up: the pre-check reads it @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 @@ -931,17 +1112,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 +1145,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 @@ -1035,6 +1223,8 @@ lab_summary("Core") do @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..7f0923b 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,8 +113,22 @@ 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 @@ -174,7 +201,12 @@ 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 setpoint_held[] = stored @@ -210,6 +242,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() ? 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 @@ -227,9 +289,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 +321,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 +351,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") From 896fda8523c7a66e870c3db11cd7f3257a1683cc Mon Sep 17 00:00:00 2001 From: kalidke Date: Tue, 29 Sep 2026 16:38:27 -0600 Subject: [PATCH 3/5] TCube 0.2.6: record the calibration reference; CHANGELOG Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 43 +++++++++++++++++++ .../tcube_laser/CALIBRATION.md | 35 ++++++++++++++- 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bb5c07..47902e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,49 @@ 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`: 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`, reads 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. 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` power mode: `light_on` and `setoutputpower!` each take about 0.4 s longer (the + doubled requests and `check_lock`'s own reads). +- `TCubeLaser`: the potentiometer search steps by the manual's ~0.7 mA per position instead of the + header's 220/255 mA, and may take one more setting to settle. The controller's readback still + decides every position. + ## [0.2.5] - 2026-09-29 A non-breaking release. It brings the TCube laser's closed-loop (power) mode and diff --git a/src/hardware_implementations/tcube_laser/CALIBRATION.md b/src/hardware_implementations/tcube_laser/CALIBRATION.md index b187063..a9317cf 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 (driver: `DIGPOT_STEP_ESTIMATE_mA`). - The potentiometer position does not survive a controller power cycle, 2026-09-29; `initialize` re-programs it every time and power-mode `light_on` re-checks it. - The controller ignores a setpoint sent while its output is off and then runs on a stale stored setpoint (on this rig the diode went to its ~160 mA limit); the setpoint read-back is stale with the output off (65530), 2026-09-28 (driver: `send_setpoint`). - The photodiode UNDER-range flag was set at 1 mW (4.5 uA on the 1 mA range) while the loop regulated correctly, 2026-09-29; the driver warns and does not refuse. - The photocurrent reading is signed and `0x8000` means over range (seen at 110 mA open loop on the 1 mA range), 2026-09-28 (driver: `measured_photocurrent`). +- 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 From afd817ab123dc6f553f2f90b1c0cb2f7178f9f46 Mon Sep 17 00:00:00 2001 From: kalidke Date: Tue, 29 Sep 2026 17:11:20 -0600 Subject: [PATCH 4/5] Review fixes on #74 (L1-L10): confirm open loop before the reference enable, latch a reference mismatch, fresh reads in setoutputpower! set_open_loop! confirms open loop before the re-check enables. initialize resets the loop state first (scale_refused added); a mismatch latches until initialize and the re-check dwells REFERENCE_DWELL_S. require_clamp checks the TIA range and returns the fresh status word; check_lock tests the high side before the status read and runs 0x400 at code 0. The pot step estimate returns to 220/255. Deletes output_enabled, initialize's LD_RequestReadings and measured_current's range check. CHANGELOG timings recomputed. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 31 ++- .../tcube_laser/CALIBRATION.md | 2 +- .../tcube_laser/interface_methods.jl | 181 +++++++++++------- .../lightsource_interface/interface_types.jl | 13 +- test/runtests.jl | 86 +++++++-- test/tcube_fake_sdk.jl | 12 +- 6 files changed, 221 insertions(+), 104 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47902e6..0970784 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,11 @@ the next version with `-DEV`). 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`: the calibration-reference re-check latches a mismatch until `initialize`, and does not + re-light the diode on every retried `light_on`. +- `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 @@ -39,19 +44,29 @@ the next version with `-DEV`). - 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`, reads 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. Without one, that `light_on` warns once that the check is skipped. See `CALIBRATION.md`. + 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` power mode: `light_on` and `setoutputpower!` each take about 0.4 s longer (the - doubled requests and `check_lock`'s own reads). -- `TCubeLaser`: the potentiometer search steps by the manual's ~0.7 mA per position instead of the - header's 220/255 mA, and may take one more setting to settle. The controller's readback still - decides every position. +- `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.1 s in open loop + (one limit read, more if the potentiometer is lowered); + - 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 diff --git a/src/hardware_implementations/tcube_laser/CALIBRATION.md b/src/hardware_implementations/tcube_laser/CALIBRATION.md index a9317cf..724b11c 100644 --- a/src/hardware_implementations/tcube_laser/CALIBRATION.md +++ b/src/hardware_implementations/tcube_laser/CALIBRATION.md @@ -82,7 +82,7 @@ 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; 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 (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. diff --git a/src/hardware_implementations/tcube_laser/interface_methods.jl b/src/hardware_implementations/tcube_laser/interface_methods.jl index cffab1c..a47b51d 100644 --- a/src/hardware_implementations/tcube_laser/interface_methods.jl +++ b/src/hardware_implementations/tcube_laser/interface_methods.jl @@ -327,18 +327,19 @@ 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 manual gives a step of -about 0.7 mA per position (p.28, p.38), used only to choose the next position. The -Kinesis header gives the scale as `position * 220 / 255` mA, and **the controller does -not follow it**; it is not used anywhere. 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. -[`program_clamp!`](@ref) reads the controller's own limit after each setting. Since -0.7 is below the measured step, a move can overshoot by a position or two, and the -search corrects it with the output off. +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; a move never sets the pot above it. 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 = 0.7 +const DIGPOT_STEP_ESTIMATE_mA = 220.0 / 255 """ CLAMP_WAIT_S @@ -397,11 +398,12 @@ Leave the controller's diode current limit at the highest potentiometer position whose limit, **as the controller reports it**, does not exceed `light.max_current`, and return that limit in mA. -The search starts from the current position. It moves by the manual's step -estimate ([`DIGPOT_STEP_ESTIMATE_mA`](@ref)), then 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 +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 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 @@ -532,9 +534,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) @@ -622,7 +621,8 @@ 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)); + 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 @@ -631,9 +631,10 @@ this order: 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 returns at once, with no wait and no reads: a zero request has no -lock to catch and no deficit to measure. Its own reads cost about `lock_check_s + 4 x -REQUEST_WAIT_S`. It never disables the output by itself; its callers do. A no-op in open +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 @@ -648,39 +649,53 @@ which refuses (the safe direction). """ check_lock(light::TCubeLaser{ConstantCurrent}, code) = nothing function check_lock(light::TCubeLaser{ConstantPhotocurrent}, code) - code == 0 && return nothing # nothing requested: no lock to catch, no deficit to measure pd, serialNo = light.pd, light.serialNo sleep(pd.lock_check_s) - measured = photocurrent_from_raw(light, read_photocurrent_word(light), pd.tia_range) requested = photocurrent_from_code(light, code, pd.tia_range) + 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) - 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.") bits & STATUS_BITS.saturated != 0 && error( - "TCubeLaser $serialNo: the controller reports its current limit reached (status 0x400) holding a request of $(requested) A; " * - "the photodiode reads $(measured) A. The loop is at the clamp and the power is not being held: the request needs more " * + "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).") - measured < requested / pd.lock_ratio && error( + 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`, cleared by every `initialize`, successful or failed), from +(`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, enables, sends the setpoint for `ref_current_mA` (after the enable: a -setpoint sent with the output off is ignored), waits `pd.lock_check_s`, reads the +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 @@ -689,23 +704,26 @@ with its own enable. A command failure inside the check is cleaned up ([`disable_after_failure`](@ref)) and rethrown; the mode is then unknown, so the next `light_on` refuses ("not in closed loop") -until `initialize`. A mismatch leaves the output off and closed loop restored, and -`scale_checked` false: the next `light_on` re-checks, which emits at the reference again, -and refuses again while the mismatch holds. +until `initialize`. A mismatch leaves the output off and closed loop restored, `scale_checked` false and +`scale_refused` true: every later `light_on` refuses at once, without lighting the diode +again, until the next `initialize`. 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 -`lock_check_s + 2 x REQUEST_WAIT_S`) happens before the user's request at the first -`light_on`. +`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 found a mismatch since the last initialize, " * + "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." @@ -715,11 +733,11 @@ function check_scale!(light::TCubeLaser{ConstantPhotocurrent}) 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 - enter_mode!(ConstantCurrent(), light) # LD_SetOpenLoopMode, output off + 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(pd.lock_check_s) + 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) @@ -730,11 +748,14 @@ function check_scale!(light::TCubeLaser{ConstantPhotocurrent}) end measured = photocurrent_from_raw(light, word, pd.tia_range) ref = pd.ref_photocurrent_A - 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.") + if !(ref / pd.ref_ratio <= measured <= ref * pd.ref_ratio) + pd.scale_refused = true + 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_checked = true return nothing end @@ -838,6 +859,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() @@ -857,12 +879,11 @@ 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. request_twice(LD_RequestLaserDiodeMaxCurrentLimit, "LD_RequestLaserDiodeMaxCurrentLimit", serialNo) @@ -887,6 +908,16 @@ function initialize(light::TCubeLaser) return nothing end +# 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) = check_err(LD_SetOpenLoopMode(light.serialNo), "LD_SetOpenLoopMode", light.serialNo) @@ -899,11 +930,19 @@ function set_closed_loop!(light::TCubeLaser) return nothing end +"`LD_SetOpenLoopMode`, then a fresh status read must report open loop (`0x4` clear)." +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 - pd.scale_checked = false # every initialize re-arms the calibration-reference re-check # 1. key switch and interlock bits = read_status_fresh(serialNo) @@ -963,8 +1002,11 @@ controller reports its current limit reached (`0x400`) or the photocurrent is be 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`, reads the photodiode and refuses on a mismatch, leaving the output off; -without one it warns once and carries on. +`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. `[limitation]` Between the enable and the setpoint the controller runs on its stored setpoint, bounded in hardware only by its current-limit potentiometer; @@ -978,12 +1020,12 @@ 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 fresh reads (about 4 x `REQUEST_WAIT_S`), and +`[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. """ @@ -1032,6 +1074,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. @@ -1039,7 +1087,7 @@ 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 """ @@ -1096,20 +1144,21 @@ 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 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 @@ -1130,15 +1179,14 @@ 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( + (bits & STATUS_BITS.tia_over != 0 || (on && read_photocurrent_word(light) == PHOTOCURRENT_OVER_RANGE)) && error( "TCubeLaser $serialNo: setoutputpower! refused: the photodiode amplifier reports OVER range, so the loop's feedback is invalid") # UNDER range means the photocurrent is small for the selected range, not # that it is invalid: on the 642 nm rig the flag was set at 1 mW (4.5 µA on @@ -1282,13 +1330,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; -32768..32767 represents -220..+220 mA (Kinesis header), and a raw value -outside it throws. +is signed; -32768..32767 represents -220..+220 mA (Kinesis header). """ function LightSourceInterface.measured_current(light::TCubeLaser) raw = Int(LD_GetLaserDiodeCurrentReading(light.serialNo)) - -SETPOINT_PROTOCOL_MAX - 1 <= raw <= SETPOINT_PROTOCOL_MAX || error( - "TCubeLaser $(light.serialNo): diode current reading $(raw) is outside the protocol's -32768..32767") return setpoint_current(light, raw) end diff --git a/src/hardware_interfaces/lightsource_interface/interface_types.jl b/src/hardware_interfaces/lightsource_interface/interface_types.jl index aa96d8e..ea891a0 100644 --- a/src/hardware_interfaces/lightsource_interface/interface_types.jl +++ b/src/hardware_interfaces/lightsource_interface/interface_types.jl @@ -85,9 +85,9 @@ struct ConstantPhotocurrent <: RegulationMode end """ LOCK_CHECK_MIN_S -The shortest `lock_check_s` a [`PhotodiodeLoop`](@ref) accepts, in s: 0.1, twice the TCube -driver's 50 ms polling period. 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 +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 @@ -165,8 +165,10 @@ delivered power drifts while photocurrent is held steady. That is why 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 eleven fields. +Construct it with the keyword form, which fills the last twelve fields. """ mutable struct PhotodiodeLoop wa_calibration::Float64 @@ -183,6 +185,7 @@ mutable struct PhotodiodeLoop ref_photocurrent_A::Float64 ref_ratio::Float64 scale_checked::Bool + scale_refused::Bool end """ @@ -221,6 +224,6 @@ function PhotodiodeLoop(; wa_calibration::Real, tia_range::Real, tec_stabilised: 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) + Float64(ref_ratio), false, false) end diff --git a/test/runtests.jl b/test/runtests.jl index c91f797..2ac7f84 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -360,7 +360,7 @@ 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:11] == ["LD_RequestReadings", "LD_RequestLaserDiodeMaxCurrentLimit", "LD_RequestLaserDiodeMaxCurrentLimit", "LD_GetLaserDiodeMaxCurrentLimit"] + @test FakeKinesis.calls[8:10] == ["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 @@ -532,16 +532,15 @@ lab_summary("Core") do # 1-2: key, interlock and the amplifier range, from a fresh read status_read..., # 3: the clamp. At position 204 the controller reports 160.74 mA, - # over the 160 mA ceiling. The manual's 0.7 mA step estimate moves it to 202 - # (159.08 mA), then up one to 203 (159.91 mA), where it stops. + # over the 160 mA ceiling, so it steps to 203 (159.91 mA) and stops. clamp_read..., limit_read..., - pot_set..., pot_set..., + pot_set..., # 4: closed loop, confirmed from the status word "LD_SetClosedLoopMode", status_read..., # 5: the display calibration, confirmed "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 @@ -549,8 +548,8 @@ lab_summary("Core") do @test FakeKinesis.setpoints == [UInt16(0)] @test FakeKinesis.setpoint_held[] == 0 # The diode flag is never raised: passed as false both times. - @test FakeKinesis.adjust_calls == [(true, false), (false, false), (true, false), (false, false)] - @test FakeKinesis.digpot_sets == [202, 203] + @test FakeKinesis.adjust_calls == [(true, false), (false, false)] + @test FakeKinesis.digpot_sets == [203] # The clamp is the controller's own reported limit, under the ceiling. reported = TCube.setpoint_current(laser, floor(Int, FakeKinesis.limit_mA_for(203) / 220 * 32767)) @test laser.pd.max_current_clamp == reported @@ -796,7 +795,7 @@ lab_summary("Core") do @test enabled() && l.properties.is_on light_off(l) end - # N10: at code 0 `check_lock` reads nothing. + # 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] @@ -804,7 +803,6 @@ lab_summary("Core") do @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 LSI.LOCK_CHECK_MIN_S >= 2 * TCube.POLL_INTERVAL_MS / 1000 @test_throws ArgumentError cp(; lock_check_s=0.0) FK.reset!() end @@ -842,7 +840,8 @@ lab_summary("Core") do 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_EnableOutput", "LD_SetLaserSetPoint", "LD_GetLaserSetPoint", + @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...] @@ -854,14 +853,14 @@ lab_summary("Core") do @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 the next `light_on` re-checks. + # 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 + @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"calibration reference" light_on(l) - @test count(==("LD_SetOpenLoopMode"), FK.calls) == n + 1 - FK.pd_scale[] = 1.0; light_on(l) + @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 @@ -892,6 +891,56 @@ lab_summary("Core") do 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 + # 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 + # 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 @@ -924,7 +973,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) @@ -962,7 +1011,7 @@ 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", + @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) @@ -971,7 +1020,6 @@ lab_summary("Core") do setoutputpower!(laser, 50.0) # 0x8000 is the rig controller's over-range reading: refuse, and report it. FakeKinesis.photocurrent_raw[] = -32768 - FakeKinesis.poll!() # the polled cache has caught up: the pre-check reads it @test_throws "OVER" setoutputpower!(laser, 20.0) @test measured_photocurrent(laser) == Inf @test loop_status(laser).tia_over @@ -1221,8 +1269,6 @@ 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 diff --git a/test/tcube_fake_sdk.jl b/test/tcube_fake_sdk.jl index 7f0923b..f31ee0e 100644 --- a/test/tcube_fake_sdk.jl +++ b/test/tcube_fake_sdk.jl @@ -134,6 +134,12 @@ const KEY, CLOSED, INTERLOCK, ENABLED = 0x00000002, 0x00000004, 0x00000008, 0x00 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) @@ -209,6 +215,8 @@ function reset!(; limit_raw::Integer=23830, stored::Integer=0) 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 @@ -259,7 +267,7 @@ function photocurrent_now() return pd_word_at(min(closed() ? needed_mA() : setpoint_mA(), limit_mA_live())) end function capture(kind::Symbol) - kind === :status && return bits[] | (saturated_now() ? LIMIT : 0x00000000) + 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)) @@ -281,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) From 4e73adf3a8bc37ec06e9a063ed647e84cfbbb94b Mon Sep 17 00:00:00 2001 From: kalidke Date: Tue, 29 Sep 2026 17:35:26 -0600 Subject: [PATCH 5/5] Fix-check items on #74 (M1-M5): a refusal while lit also disables, open-loop initialize confirms open loop, latch any re-check failure M1 wraps require_clamp so a refusal found while the output may be on zeroes and disables it; M2 makes open-loop initialize confirm open loop with a fresh status read; M3 deletes setoutputpower!'s dead closed-loop check; M4 corrects the step-estimate docstrings; M5 latches any failure inside the reference re-check. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 13 ++- .../tcube_laser/interface_methods.jl | 83 ++++++++++++++----- test/runtests.jl | 39 ++++++++- 3 files changed, 110 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0970784..0430604 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,8 +29,13 @@ the next version with `-DEV`). 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`: the calibration-reference re-check latches a mismatch until `initialize`, and does not - re-light the diode on every retried `light_on`. +- `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`. @@ -63,8 +68,8 @@ the next version with `-DEV`). - 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.1 s in open loop - (one limit read, more if the potentiometer is lowered); + 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. diff --git a/src/hardware_implementations/tcube_laser/interface_methods.jl b/src/hardware_implementations/tcube_laser/interface_methods.jl index a47b51d..00e63b4 100644 --- a/src/hardware_implementations/tcube_laser/interface_methods.jl +++ b/src/hardware_implementations/tcube_laser/interface_methods.jl @@ -333,7 +333,9 @@ 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; a move never sets the pot above it. The manual's ~0.7 mA per position (p.28, +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. """ @@ -395,6 +397,7 @@ 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. @@ -702,11 +705,12 @@ 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. -A command failure inside the check is cleaned up ([`disable_after_failure`](@ref)) and -rethrown; the mode is then unknown, so the next `light_on` refuses ("not in closed loop") -until `initialize`. A mismatch leaves the output off and closed loop restored, `scale_checked` false and -`scale_refused` true: every later `light_on` refuses at once, without lighting the diode -again, until the next `initialize`. +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 @@ -722,7 +726,7 @@ 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 found a mismatch since the last initialize, " * + "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) @@ -730,6 +734,7 @@ function check_scale!(light::TCubeLaser{ConstantPhotocurrent}) 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 @@ -749,13 +754,13 @@ function check_scale!(light::TCubeLaser{ConstantPhotocurrent}) measured = photocurrent_from_raw(light, word, pd.tia_range) ref = pd.ref_photocurrent_A if !(ref / pd.ref_ratio <= measured <= ref * pd.ref_ratio) - pd.scale_refused = true 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 @@ -798,7 +803,8 @@ 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 @@ -918,8 +924,7 @@ function reset_loop_state!(light::TCubeLaser{ConstantPhotocurrent}) return nothing end -enter_mode!(::ConstantCurrent, light::TCubeLaser) = - check_err(LD_SetOpenLoopMode(light.serialNo), "LD_SetOpenLoopMode", light.serialNo) +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) @@ -930,7 +935,12 @@ function set_closed_loop!(light::TCubeLaser) return nothing end -"`LD_SetOpenLoopMode`, then a fresh status read must report open loop (`0x4` clear)." +""" + 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) @@ -1008,6 +1018,8 @@ leaving the output off; a mismatch latches until the next `initialize`, and late `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; see [`TCubeLaser`](@ref). @@ -1056,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. " * @@ -1064,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.") @@ -1090,6 +1102,31 @@ function require_clamp(::ConstantPhotocurrent, light::TCubeLaser, op) 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 + """ setcurrent!(light::TCubeLaser{ConstantCurrent}, current::Float64) @@ -1171,6 +1208,8 @@ Command the optical power at the laser output, in mW -- the plane where 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)). @@ -1183,11 +1222,17 @@ function LightSourceInterface.setoutputpower!(light::TCubeLaser{ConstantPhotocur check_power(light, power_mW) pd, serialNo = light.pd, light.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 && read_photocurrent_word(light) == 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. diff --git a/test/runtests.jl b/test/runtests.jl index 2ac7f84..5e67353 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -360,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:10] == ["LD_RequestLaserDiodeMaxCurrentLimit", "LD_RequestLaserDiodeMaxCurrentLimit", "LD_GetLaserDiodeMaxCurrentLimit"] + @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 @@ -740,8 +741,19 @@ lab_summary("Core") do 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) @@ -919,11 +931,32 @@ lab_summary("Core") do @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) @@ -954,7 +987,7 @@ lab_summary("Core") do 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. @@ -1055,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)