From ca5c2f7df1cce9b9b28f36c5b6c9ba980a27377d Mon Sep 17 00:00:00 2001 From: kalidke Date: Tue, 29 Sep 2026 14:33:52 -0600 Subject: [PATCH 01/20] PIStage: route every GCS2 DLL call through a wrapper seam No behaviour change: each inline @ccall moves into pi_stage/gcs2.jl unchanged, so a test can replace the wrappers. Co-Authored-By: Claude Sonnet 5.5 --- src/hardware_implementations/pi_stage/PI.jl | 1 + .../pi_stage/config_methods.jl | 26 +++++++++---------- src/hardware_implementations/pi_stage/gcs2.jl | 19 ++++++++++++++ .../pi_stage/move_methods.jl | 12 ++++----- .../pi_stage/query_methods.jl | 12 ++++----- 5 files changed, 45 insertions(+), 25 deletions(-) create mode 100644 src/hardware_implementations/pi_stage/gcs2.jl diff --git a/src/hardware_implementations/pi_stage/PI.jl b/src/hardware_implementations/pi_stage/PI.jl index f047c1c..f13072b 100644 --- a/src/hardware_implementations/pi_stage/PI.jl +++ b/src/hardware_implementations/pi_stage/PI.jl @@ -5,6 +5,7 @@ module PI global const gcs2path = "C:\\Program Files (x86)\\Physik Instrumente (PI)\\Software Suite\\Development\\C++\\API\\PI_GCS2_DLL_x64.dll" + include("gcs2.jl") include("types.jl") include("move_methods.jl") include("query_methods.jl") diff --git a/src/hardware_implementations/pi_stage/config_methods.jl b/src/hardware_implementations/pi_stage/config_methods.jl index 0d98669..1318329 100644 --- a/src/hardware_implementations/pi_stage/config_methods.jl +++ b/src/hardware_implementations/pi_stage/config_methods.jl @@ -11,7 +11,7 @@ function initialize_original(stage::PIStage) #TODO: Error handling bufferstring = Vector{UInt8}(undef, 1024) #Find number of connected USB devices, specifically the PI C-867 controller - numconnected = @ccall gcs2path.PI_EnumerateUSB(bufferstring::Ptr{UInt8}, 1024::Cint, "PI C-867"::Ptr{UInt8})::Cint + numconnected = PI_EnumerateUSB(bufferstring, 1024, "PI C-867") @info "Number of connected devices: " * string(numconnected) @@ -27,7 +27,7 @@ function initialize_original(stage::PIStage) #TODO: Error handling return end #Connect to usb device - stage.id = @ccall gcs2path.PI_ConnectUSB(bufferstring::Ptr{UInt8})::Cint + stage.id = PI_ConnectUSB(bufferstring) @info "Device ID: " * string(stage.id) @@ -78,12 +78,12 @@ Possibly must use PiMikroMove to calibrate, but this is not ideal, however there """ function referencemove(stage::PIStage) - ismoved = @ccall gcs2path.PI_FRF(stage.id::Cint, "1 2"::Ptr{UInt8})::Cint + ismoved = PI_FRF(stage.id, "1 2") return ismoved end # PI_GetError returns and clears the controller's last GCS error code (0 = none). -_pi_geterror(stage::PIStage) = @ccall gcs2path.PI_GetError(stage.id::Cint)::Cint +_pi_geterror(stage::PIStage) = PI_GetError(stage.id) """ Poll `PI_qFRF` until both axes report referenced; throw if that has not happened within @@ -94,7 +94,7 @@ function _waitforreference(stage::PIStage; timeout::Real = 60.0) referenced = zeros(Cint, 2) deadline = time() + timeout while true - ok = @ccall gcs2path.PI_qFRF(stage.id::Cint, "1 2"::Ptr{UInt8}, referenced::Ptr{Cint})::Cint + ok = PI_qFRF(stage.id, "1 2", referenced) ok == 1 || error("PI_qFRF failed (GCS error $(_pi_geterror(stage)))") all(!=(0), referenced) && return nothing time() > deadline && error("PI stage not referenced after $(timeout) s: " * @@ -108,11 +108,11 @@ end Function to disconnect PI Stage """ function shutdown_original(stage::PIStage) - isconnected = @ccall gcs2path.PI_IsConnected(stage.id::Cint)::Cint + isconnected = PI_IsConnected(stage.id) if isconnected == 1 - @ccall gcs2path.PI_CloseConnection(stage.id::Cint)::Cvoid - isconnected = @ccall gcs2path.PI_IsConnected(stage.id::Cint)::Cint + PI_CloseConnection(stage.id) + isconnected = PI_IsConnected(stage.id) if isconnected == 1 @error "Stage failed to disconnect" @@ -133,7 +133,7 @@ function servo(stage::PIStage, xtoggle::Bool, ytoggle::Bool) # PI_SVO takes `const BOOL*` = 32-bit ints, one per axis. Passing two UInt8 made the DLL # read axis 2's flag from whatever byte followed the array: servo silently OFF on Y, # every PI_MOV refused with GCS error 5 (worked by luck on Julia 1.10, failed on 1.13). - istoggled = @ccall gcs2path.PI_SVO(stage.id::Cint, "1 2"::Ptr{UInt8}, Cint[xtoggle, ytoggle]::Ptr{Cint})::Cint + istoggled = PI_SVO(stage.id, "1 2", Cint[xtoggle, ytoggle]) stage.servostatus = (xtoggle, ytoggle) if istoggled == 1 @@ -147,7 +147,7 @@ end Sets the servo state of the x axis """ function servox(stage::PIStage, xtoggle::Bool) - @ccall gcs2path.PI_SVO(stage.id::Cint, "1"::Ptr{UInt8}, Cint[xtoggle]::Ptr{Cint})::Cint + PI_SVO(stage.id, "1", Cint[xtoggle]) stage.servostatus = (xtoggle, stage.servostatus[2]) end @@ -156,20 +156,20 @@ end Sets the servo state of the y axis """ function servoy(stage::PIStage, ytoggle::Bool) - @ccall gcs2path.PI_SVO(stage.id::Cint, "2"::Ptr{UInt8}, Cint[ytoggle]::Ptr{Cint})::Cint + PI_SVO(stage.id, "2", Cint[ytoggle]) stage.servostatus = (stage.servostatus[1], ytoggle) end function setvel(stage::PIStage,vel::Vector{Float64}) - success = @ccall gcs2path.PI_VEL(stage.id::Cint, "1 2"::Ptr{UInt8}, vel::Ptr{Cdouble})::Cint + success = PI_VEL(stage.id, "1 2", vel) if success == 0 @error "Failed to set velocity" end velocity = Vector{Cdouble}(undef, 2) - success = @ccall gcs2path.PI_qVEL(stage.id::Cint, "1 2"::Ptr{UInt8}, velocity::Ptr{Cdouble})::Cint + success = PI_qVEL(stage.id, "1 2", velocity) if success == 0 @error "Failed to query velocity" diff --git a/src/hardware_implementations/pi_stage/gcs2.jl b/src/hardware_implementations/pi_stage/gcs2.jl new file mode 100644 index 0000000..481ac5c --- /dev/null +++ b/src/hardware_implementations/pi_stage/gcs2.jl @@ -0,0 +1,19 @@ +# One wrapper per PI GCS2 DLL function: the seam test/pi_stage_fake_sdk.jl replaces. +PI_EnumerateUSB(buffer, bufsize, filter) = @ccall gcs2path.PI_EnumerateUSB(buffer::Ptr{UInt8}, bufsize::Cint, filter::Ptr{UInt8})::Cint +PI_ConnectUSB(description) = @ccall gcs2path.PI_ConnectUSB(description::Ptr{UInt8})::Cint +PI_IsConnected(ID) = @ccall gcs2path.PI_IsConnected(ID::Cint)::Cint +PI_CloseConnection(ID) = @ccall gcs2path.PI_CloseConnection(ID::Cint)::Cvoid +PI_GetError(ID) = @ccall gcs2path.PI_GetError(ID::Cint)::Cint +PI_IsControllerReady(ID, piControllerReady) = @ccall gcs2path.PI_IsControllerReady(ID::Cint, piControllerReady::Ptr{Cint})::Cint +PI_FRF(ID, axes) = @ccall gcs2path.PI_FRF(ID::Cint, axes::Ptr{UInt8})::Cint +PI_qFRF(ID, axes, referenced) = @ccall gcs2path.PI_qFRF(ID::Cint, axes::Ptr{UInt8}, referenced::Ptr{Cint})::Cint +PI_SVO(ID, axes, values) = @ccall gcs2path.PI_SVO(ID::Cint, axes::Ptr{UInt8}, values::Ptr{Cint})::Cint +PI_VEL(ID, axes, values) = @ccall gcs2path.PI_VEL(ID::Cint, axes::Ptr{UInt8}, values::Ptr{Cdouble})::Cint +PI_qVEL(ID, axes, values) = @ccall gcs2path.PI_qVEL(ID::Cint, axes::Ptr{UInt8}, values::Ptr{Cdouble})::Cint +PI_MOV(ID, axes, values) = @ccall gcs2path.PI_MOV(ID::Cint, axes::Ptr{UInt8}, values::Ptr{Cdouble})::Cint +PI_HLT(ID, axes) = @ccall gcs2path.PI_HLT(ID::Cint, axes::Ptr{UInt8})::Cint +PI_STP(ID) = @ccall gcs2path.PI_STP(ID::Cint)::Cint +PI_qPOS(ID, axes, values) = @ccall gcs2path.PI_qPOS(ID::Cint, axes::Ptr{UInt8}, values::Ptr{Cdouble})::Cint +PI_IsMoving(ID, axes, values) = @ccall gcs2path.PI_IsMoving(ID::Cint, axes::Ptr{UInt8}, values::Ptr{UInt32})::Cint +PI_qTMN(ID, axes, values) = @ccall gcs2path.PI_qTMN(ID::Cint, axes::Ptr{UInt8}, values::Ptr{Cdouble})::Cint +PI_qTMX(ID, axes, values) = @ccall gcs2path.PI_qTMX(ID::Cint, axes::Ptr{UInt8}, values::Ptr{Cdouble})::Cint diff --git a/src/hardware_implementations/pi_stage/move_methods.jl b/src/hardware_implementations/pi_stage/move_methods.jl index 38c87a6..4a3d54d 100644 --- a/src/hardware_implementations/pi_stage/move_methods.jl +++ b/src/hardware_implementations/pi_stage/move_methods.jl @@ -3,7 +3,7 @@ Function to move PI Stage to a specific position """ function move(stage::PIStage, x::Float64, y::Float64) - ok = @ccall gcs2path.PI_MOV(stage.id::Cint, "1 2"::Ptr{UInt8}, [Cdouble(x),Cdouble(y)]::Ptr{Cdouble})::Cint + ok = PI_MOV(stage.id, "1 2", [Cdouble(x),Cdouble(y)]) ok == 1 || @error "PI_MOV refused — stage not connected, not referenced, or servo off" stage.targ_x = x stage.targ_y = y @@ -14,7 +14,7 @@ end Function to move PI Stage, and wait for completion """ function moveandwait(stage::PIStage, x::Float64, y::Float64) - @ccall gcs2path.PI_MOV(stage.id::Cint, "1 2"::Ptr{UInt8}, [Cdouble(x),Cdouble(y)]::Ptr{Cdouble})::Cint + PI_MOV(stage.id, "1 2", [Cdouble(x),Cdouble(y)]) stage.targ_x = x stage.targ_y = y ismoving(stage) @@ -28,7 +28,7 @@ end Function to move PI Stage X axis to a specific position """ function movex(stage::PIStage, x::Float64) - @ccall gcs2path.PI_MOV(stage.id::Cint, "1"::Ptr{UInt8}, [Cdouble(x)]::Ptr{Cdouble})::Cint + PI_MOV(stage.id, "1", [Cdouble(x)]) stage.targ_x = x end @@ -36,7 +36,7 @@ end Function to move PI Stage Y axis to a specific position """ function movey(stage::PIStage, y::Float64) - @ccall gcs2path.PI_MOV(stage.id::Cint, "2"::Ptr{UInt8}, [Cdouble(y)]::Ptr{Cdouble})::Cint + PI_MOV(stage.id, "2", [Cdouble(y)]) stage.targ_y = y end @@ -44,7 +44,7 @@ end Function call to smoothly stop motion of the PI Stage """ function stopmotion(stage::PIStage) - isstopped = @ccall gcs2path.PI_HLT(stage.id::Cint, "1 2"::Ptr{UInt8})::Cint + isstopped = PI_HLT(stage.id, "1 2") if isstopped == 1 @info "Motion successfully stopped" else @@ -56,7 +56,7 @@ end Function call to immediately stop the PI Stage """ function immediatestop(stage::PIStage) - isstopped = @ccall gcs2path.PI_STP(stage.id::Cint)::Cint + isstopped = PI_STP(stage.id) if isstopped == 1 @info "Motion successfully stopped" else diff --git a/src/hardware_implementations/pi_stage/query_methods.jl b/src/hardware_implementations/pi_stage/query_methods.jl index 3356f29..66e1b04 100644 --- a/src/hardware_implementations/pi_stage/query_methods.jl +++ b/src/hardware_implementations/pi_stage/query_methods.jl @@ -5,7 +5,7 @@ Function to update the position of the PI Stage """ function getposition(stage::PIStage) position = Vector{Cdouble}(undef, 2) - @ccall gcs2path.PI_qPOS(stage.id::Cint, "1 2"::Ptr{UInt8}, position::Ptr{Cdouble})::Cint + PI_qPOS(stage.id, "1 2", position) @info "Stage position: " * string(position[1]) * ", " * string(position[2]) stage.real_x = position[1] @@ -17,7 +17,7 @@ Function to update the x position of the PI Stage """ function getxposition(stage::PIStage) xposition = Cdouble(0.0) - @ccall gcs2path.PI_qPOS(stage.id::Cint, "1"::Ptr{UInt8}, [xposition]::Ptr{Cdouble})::Cint + PI_qPOS(stage.id, "1", [xposition]) stage.real_x = xposition end @@ -26,7 +26,7 @@ Function to update the y position of the PI Stage """ function getyposition(stage::PIStage) yposition = Cdouble(0.0) - @ccall gcs2path.PI_qPOS(stage.id::Cint, "2"::Ptr{UInt8}, [yposition]::Ptr{Cdouble})::Cint + PI_qPOS(stage.id, "2", [yposition]) stage.real_y = yposition end @@ -37,7 +37,7 @@ Function to check if the PI Stage is moving, both the x and y axis are checked """ function ismoving(stage::PIStage) ismoving = Vector{UInt32}(undef, 2) - @ccall gcs2path.PI_IsMoving(stage.id::Cint, "1 2"::Ptr{UInt8}, ismoving::Ptr{UInt32})::Cint + PI_IsMoving(stage.id, "1 2", ismoving) stage.ismoving = (Bool(ismoving[1]), Bool(ismoving[2])) end @@ -51,7 +51,7 @@ end """ function findmin(stage::PIStage) minpositions = Vector{Cdouble}(undef, 2) - @ccall gcs2path.PI_qTMN(stage.id::Cint, "1 2"::Ptr{UInt8}, minpositions::Ptr{Cdouble})::Cint + PI_qTMN(stage.id, "1 2", minpositions) stage.range_x = (minpositions[1], stage.range_x[2]) stage.range_y = (minpositions[2], stage.range_y[2]) end @@ -61,7 +61,7 @@ end """ function findmax(stage::PIStage) maxpositions = Vector{Cdouble}(undef, 2) - @ccall gcs2path.PI_qTMX(stage.id::Cint, "1 2"::Ptr{UInt8}, maxpositions::Ptr{Cdouble})::Cint + PI_qTMX(stage.id, "1 2", maxpositions) stage.range_x = (stage.range_x[1], maxpositions[1]) stage.range_y = (stage.range_y[1], maxpositions[2]) end \ No newline at end of file From efe7c05ca8ac781abd381cf2109eb4b019673f93 Mon Sep 17 00:00:00 2001 From: kalidke Date: Tue, 29 Sep 2026 14:38:22 -0600 Subject: [PATCH 02/20] PIStage: initialize is ready only after full success; fake-GCS2 tests Id defaults to -1 and shutdown resets it; connectionstatus is set once, after every step past the connect; initialize waits for PI_IsControllerReady before PI_qFRF; the stage panels' initialize buttons log a failed initialize; stale TODOs and docstrings corrected. Adds test/pi_stage_fake_sdk.jl and test/pi_stage.jl. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 9 + .../pi_stage/config_methods.jl | 95 ++++++---- .../pi_stage/interface_methods.jl | 4 +- .../pi_stage/types.jl | 2 +- .../stage_interface/gui.jl | 24 ++- test/pi_stage.jl | 109 +++++++++++ test/pi_stage_fake_sdk.jl | 175 ++++++++++++++++++ test/runtests.jl | 4 + 8 files changed, 385 insertions(+), 37 deletions(-) create mode 100644 test/pi_stage.jl create mode 100644 test/pi_stage_fake_sdk.jl diff --git a/CHANGELOG.md b/CHANGELOG.md index b6a94c3..1389560 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,13 @@ the next version with `-DEV`). ## [Unreleased] +### Added +- Fake-GCS2 tests for `PIStage` (`test/pi_stage_fake_sdk.jl`, `test/pi_stage.jl`): `initialize`'s ordering and cleanup and `shutdown`'s id handling, with no hardware. + ### Fixed +- `PIStage`: `shutdown` could close another object's connection. `id` defaulted to `0`, a valid GCS id, and was never reset; it now defaults to `-1` and `shutdown` resets it. +- `PIStage.initialize` reported the stage connected before it was: `connectionstatus` was set before the connect, and a failed close after a failed reference left it `true`, so a retry answered "already initialized". It is now set only after the whole sequence succeeds, and every step after the connect is inside the cleanup. +- The stage panels' initialize buttons log a failed `initialize` instead of throwing out of the click callback. - **PI stage: `initialize` no longer finishes on an unreferenced stage.** It ignored the return of the reference move (`PI_FRF`), so when the controller rejected it (GCS error 5, e.g. one axis's servo off) the later move to the @@ -22,6 +28,9 @@ the next version with `-DEV`). unchanged. Reported by the MicroscopeAdapt rig; not yet run on hardware (#64). +### Changed +- `PIStage.initialize` waits for `PI_IsControllerReady` after the reference move, before polling `PI_qFRF`, as PI's samples do. Not yet run on hardware; needs a rig check on the C-867. + ### Changed (release process) - **`main` is the development branch**, carrying the next version with `-DEV`; every pull request goes into it. The `0.3rc1` release-candidate diff --git a/src/hardware_implementations/pi_stage/config_methods.jl b/src/hardware_implementations/pi_stage/config_methods.jl index 1318329..83a5711 100644 --- a/src/hardware_implementations/pi_stage/config_methods.jl +++ b/src/hardware_implementations/pi_stage/config_methods.jl @@ -1,7 +1,17 @@ """ -Function to initialize PI Stage, right now this requires calibration using PiMikroMove to work correctly, no documentation on how to calibrate using the PI_GCS2 library +Initialize the PI stage. Connects to the first PI C-867 the GCS2 library enumerates, turns both +servos on, starts the reference move, waits for the controller and for both axes to report +referenced, reads the travel range, waits for motion to stop, and sets `stage.velocity`. + +`connectionstatus` becomes true only when all of that succeeded. A failure after the connect +closes the connection and throws. Enumeration and connect failures log `@error` and return with +`connectionstatus == false`. + +`[limitation]` Not yet run on hardware; the wait for `PI_IsControllerReady` needs a rig check on +the C-867. The stage needs calibration using PiMikroMove to work correctly; there is no +documentation on how to calibrate using the PI_GCS2 library. """ -function initialize_original(stage::PIStage) #TODO: Error handling +function initialize_original(stage::PIStage) if stage.connectionstatus == true @error "Stage already initialized" return @@ -15,15 +25,11 @@ function initialize_original(stage::PIStage) #TODO: Error handling @info "Number of connected devices: " * string(numconnected) - #Set connection status to true - if numconnected > 0 - stage.connectionstatus = true - else + if numconnected <= 0 # The DLL enumerates only controllers nobody has open: a C-867 that Device Manager # still lists is held by another process (a second Julia with an initialized stage — # under any Windows user —, PIMikroMove, or an open COM port). @error "No PI C-867 found by the GCS2 library — controller absent, or held by another process" - stage.connectionstatus = false return end #Connect to usb device @@ -34,48 +40,49 @@ function initialize_original(stage::PIStage) #TODO: Error handling if stage.id < 0 # The connect itself failed (id -1): typically another process already holds the # controller (a second Julia with an initialized stage, PIMikroMove, an open COM port). - stage.connectionstatus = false @error "PI_ConnectUSB failed — the controller is probably held by another process" return end - #Set servo mode to on for both axes, noting axis X is labeled "1" and axis Y is labeled "2" - servo(stage, true, true) - - #Reference stage. A rejected FRF (e.g. GCS error 5, servo off on one axis) used to be - # ignored: every later PI_MOV was refused too, while the driver's cached position said - # the stage was centred. Refuse to come back from initialize unreferenced. - # On failure, close the connection so a retried initialize starts clean. + # Everything after the connect is inside the cleanup: a failure closes the connection so a + # retried initialize starts clean, and connectionstatus is set only once all of it succeeded. try + #Set servo mode to on for both axes, noting axis X is labeled "1" and axis Y is labeled "2" + servo(stage, true, true) + + #Reference stage. A rejected FRF (e.g. GCS error 5, servo off on one axis) used to be + # ignored: every later PI_MOV was refused too, while the driver's cached position said + # the stage was centred. Refuse to come back from initialize unreferenced. if referencemove(stage) != 1 error("PI_FRF refused (GCS error $(_pi_geterror(stage))); stage is not referenced") end - _waitforreference(stage) - catch - shutdown_original(stage) - rethrow() - end + _waitforready(stage; timeout = REFERENCE_TIMEOUT_S[]) + _waitforreference(stage; timeout = REFERENCE_TIMEOUT_S[]) - #Find the max and min position of the axes - getrange(stage) + #Find the max and min position of the axes + getrange(stage) - #Wait for any remaining motion to finish - ismoving(stage) - while stage.ismoving[1] == 1 || stage.ismoving[2] == 1 + #Wait for any remaining motion to finish ismoving(stage) - end + while stage.ismoving[1] == 1 || stage.ismoving[2] == 1 + ismoving(stage) + end - #Set velocity to 1 mm/s - success = setvel(stage, stage.velocity) + #Set the velocity to `stage.velocity` + success = setvel(stage, stage.velocity) + catch + shutdown_original(stage) + rethrow() + end + stage.connectionstatus = true @info "Stage initialized" return end """ -Function to calibrate PI Stage, not implemented yet as there is no documentation for this stage on calibration using the PI_GCS2 library -Possibly must use PiMikroMove to calibrate, but this is not ideal, however there is a CLI - +Start the reference move (`PI_FRF`) on both axes and return the GCS BOOL, 1 if accepted. +`initialize` waits for it to finish. """ function referencemove(stage::PIStage) ismoved = PI_FRF(stage.id, "1 2") @@ -85,6 +92,30 @@ end # PI_GetError returns and clears the controller's last GCS error code (0 = none). _pi_geterror(stage::PIStage) = PI_GetError(stage.id) +""" +How long `initialize` waits, in seconds, for the controller to become ready and then for both +axes to report referenced. A `Ref` so tests can shorten it. +""" +const REFERENCE_TIMEOUT_S = Ref(60.0) + +""" +Poll `PI_IsControllerReady` every 0.1 s until the controller reports ready; throw if the call +fails or it is not ready within `timeout` seconds. + +`[limitation]` Not yet run on hardware; the wait needs a rig check on the C-867. +""" +function _waitforready(stage::PIStage; timeout::Real = REFERENCE_TIMEOUT_S[]) + ready = Ref{Cint}(0) + deadline = time() + timeout + while true + ok = PI_IsControllerReady(stage.id, ready) + ok == 0 && error("PI_IsControllerReady failed (GCS error $(_pi_geterror(stage)))") + ready[] != 0 && return nothing + time() > deadline && error("PI controller not ready after $(timeout) s") + sleep(0.1) + end +end + """ Poll `PI_qFRF` until both axes report referenced; throw if that has not happened within `timeout` seconds or the query itself fails. @@ -119,10 +150,12 @@ function shutdown_original(stage::PIStage) else @info "Stage disconnected" stage.connectionstatus = false + stage.id = Cint(-1) end else @error "Stage already disconnected" stage.connectionstatus = false + stage.id = Cint(-1) end end diff --git a/src/hardware_implementations/pi_stage/interface_methods.jl b/src/hardware_implementations/pi_stage/interface_methods.jl index cdf1aa2..eb5291e 100644 --- a/src/hardware_implementations/pi_stage/interface_methods.jl +++ b/src/hardware_implementations/pi_stage/interface_methods.jl @@ -1,7 +1,7 @@ """ -Function to initialize PI Stage, right now this requires calibration using PiMikroMove to work correctly, no documentation on how to calibrate using the PI_GCS2 library +Initialize the PI stage; see `initialize_original` for the sequence and its failure behaviour. """ -function initialize(stage::PIStage) #TODO: Error handling +function initialize(stage::PIStage) initialize_original(stage) end diff --git a/src/hardware_implementations/pi_stage/types.jl b/src/hardware_implementations/pi_stage/types.jl index 8df1e0f..d92d397 100644 --- a/src/hardware_implementations/pi_stage/types.jl +++ b/src/hardware_implementations/pi_stage/types.jl @@ -28,7 +28,7 @@ function PIStage(; units::String = "Milimeters", dimensions::Int = 2, connectionstatus::Bool = false, - id::Cint = Cint(0), + id::Cint = Cint(-1), x::Float64 = 12.5, y::Float64 = 12.5, targ_x::Float64 = 12.5, diff --git a/src/hardware_interfaces/stage_interface/gui.jl b/src/hardware_interfaces/stage_interface/gui.jl index e186911..a634bfc 100644 --- a/src/hardware_interfaces/stage_interface/gui.jl +++ b/src/hardware_interfaces/stage_interface/gui.jl @@ -164,7 +164,13 @@ function gui1d(stage::Stage) stopmotion(stage) end on(control_buttons[3].clicks) do initialize_click - initialize(stage) + try + initialize(stage) + catch err + @error "Failed to initialize the stage" exception = err + return + end + stage.connectionstatus || return getposition(stage) xposition[] = stage.real_x xtarget[] = stage.targ_x @@ -453,7 +459,13 @@ function gui2d(stage::Stage) stopmotion(stage) end on(control_buttons[3].clicks) do initialize_click - initialize(stage) + try + initialize(stage) + catch err + @error "Failed to initialize the stage" exception = err + return + end + stage.connectionstatus || return getposition(stage) xposition[], yposition[] = stage.real_x, stage.real_y xtarget[], ytarget[] = stage.targ_x, stage.targ_y @@ -795,7 +807,13 @@ function gui3d(stage::Stage) stopmotion(stage) end on(control_buttons[3].clicks) do initialize_click - initialize(stage) + try + initialize(stage) + catch err + @error "Failed to initialize the stage" exception = err + return + end + stage.connectionstatus || return getposition(stage) xposition[], yposition[], zposition[] = stage.real_x, stage.real_y, stage.real_z xtarget[], ytarget[], ztarget[] = stage.targ_x, stage.targ_y, stage.targ_z diff --git a/test/pi_stage.jl b/test/pi_stage.jl new file mode 100644 index 0000000..4f0b809 --- /dev/null +++ b/test/pi_stage.jl @@ -0,0 +1,109 @@ +# PIStage against the fake GCS2 library in `pi_stage_fake_sdk.jl`: initialize's +# ordering and cleanup, and shutdown's id handling. No hardware. +@testset "PI stage (fake GCS2)" begin + PI = MicroscopeControl.HardwareImplementations.PI + F = Main.FakePIStage + ncalls(op) = count(==(op), F.calls) + saved_timeout = PI.REFERENCE_TIMEOUT_S[] + PI.REFERENCE_TIMEOUT_S[] = 0.3 + + try + @testset "never-initialized stage has no id" begin + F.reset!() + stage = PIStage() + @test stage.id == -1 + shutdown(stage) + @test ncalls("PI_CloseConnection") == 0 + end + + @testset "successful initialize" begin + F.reset!() + F.not_ready_polls[] = 2 + F.unreferenced_polls[] = 2 + push!(F.connect_ids, 5) + stage = PIStage() + seen = Ref{Any}(nothing) + F.frf_hook[] = () -> (seen[] = stage.connectionstatus) + initialize(stage) + @test stage.connectionstatus + @test stage.id == 5 + @test seen[] == false + iFRF = findfirst(==("PI_FRF"), F.calls) + iready = findfirst(==("PI_IsControllerReady"), F.calls) + iq = findfirst(==("PI_qFRF"), F.calls) + @test iFRF < iready < iq + @test ncalls("PI_IsControllerReady") == 3 + end + + @testset "refused PI_FRF" begin + F.reset!() + push!(F.connect_ids, 5) + F.fail!("PI_FRF") + stage = PIStage() + @test_throws ErrorException initialize(stage) + @test ncalls("PI_CloseConnection") == 1 + @test !stage.connectionstatus + @test stage.id == -1 + end + + @testset "controller never ready" begin + F.reset!() + push!(F.connect_ids, 5) + F.not_ready_polls[] = typemax(Int) + stage = PIStage() + @test_throws "not ready" initialize(stage) + @test ncalls("PI_CloseConnection") == 1 + @test !stage.connectionstatus + end + + @testset "axes never referenced" begin + F.reset!() + push!(F.connect_ids, 5) + F.unreferenced_polls[] = typemax(Int) + stage = PIStage() + @test_throws "not referenced" initialize(stage) + @test ncalls("PI_CloseConnection") == 1 + @test !stage.connectionstatus + end + + @testset "throw after the reference" begin + F.reset!() + push!(F.connect_ids, 5) + F.throw!("PI_qTMN") + stage = PIStage() + @test_throws "PI_qTMN threw" initialize(stage) + @test ncalls("PI_CloseConnection") == 1 + @test !stage.connectionstatus + end + + @testset "failed close keeps the id; retry cannot report ready" begin + F.reset!() + push!(F.connect_ids, 5) + F.fail!("PI_FRF") + F.close_leaves_open[] = true + stage = PIStage() + @test_throws ErrorException initialize(stage) + @test !stage.connectionstatus + @test stage.id == 5 + F.enum_count[] = 0 + initialize(stage) + @test !stage.connectionstatus + end + + @testset "shutdown after initialize" begin + F.reset!() + push!(F.connect_ids, 5) + stage = PIStage() + initialize(stage) + shutdown(stage) + @test ncalls("PI_CloseConnection") == 1 + @test !stage.connectionstatus + @test stage.id == -1 + shutdown(stage) + @test ncalls("PI_CloseConnection") == 1 + end + finally + PI.REFERENCE_TIMEOUT_S[] = saved_timeout + F.reset!() + end +end diff --git a/test/pi_stage_fake_sdk.jl b/test/pi_stage_fake_sdk.jl new file mode 100644 index 0000000..e0ab6a4 --- /dev/null +++ b/test/pi_stage_fake_sdk.jl @@ -0,0 +1,175 @@ +# A fake PI GCS2 library for the PIStage driver. +# +# Replaces the wrappers in `pi_stage/gcs2.jl` (each a single `@ccall` into a +# Windows DLL that is not on any build machine) with methods that record into +# `Main.FakePIStage`, so the driver's own `initialize`/`shutdown` run unmodified +# and no test can command a rig's controller. Same seam, and same caveats, as +# `tcube_fake_sdk.jl`: the replacements are global and permanent for the +# process, and this file must be included at top level, early, into `Main`. +# A wrapper the tests do not need throws, so no test can reach a DLL. + +""" + FakePIStage + +Recorder standing in for the PI GCS2 library: what the driver called, in what +order, and what each call reports back. Failures are per call: `fail!(op)` +makes it return FALSE, `throw!(op)` makes it throw. +""" +module FakePIStage + +"Operation names in the order the driver called them, since the last `reset!`." +const calls = String[] + +"Bytes `PI_EnumerateUSB` writes into the caller's buffer." +const enum_bytes = Ref(UInt8[]) + +"Count `PI_EnumerateUSB` returns." +const enum_count = Ref(1) + +"Ids `PI_ConnectUSB` returns, in order; when empty it returns 0." +const connect_ids = Int[] + +"Ids connected and not yet closed." +const open_ids = Set{Int}() + +"Operations that return FALSE." +const failing = Set{String}() + +"Operations that throw." +const throwing = Set{String}() + +"When true, `PI_CloseConnection` leaves the id open: a close that fails." +const close_leaves_open = Ref(false) + +"`PI_IsControllerReady` answers not ready for this many polls." +const not_ready_polls = Ref(0) + +"`PI_qFRF` answers unreferenced for this many polls." +const unreferenced_polls = Ref(0) + +"Called with no arguments inside `PI_FRF`, so a test can observe driver state there." +const frf_hook = Ref{Any}(nothing) + +"Returned by `PI_GetError`." +const error_code = Ref(0) + +const ready_polls = Ref(0) +const referenced_polls = Ref(0) + +function reset!() + empty!(calls) + enum_bytes[] = UInt8[codeunits("d1")..., 0x00] + enum_count[] = 1 + empty!(connect_ids) + empty!(open_ids) + empty!(failing) + empty!(throwing) + close_leaves_open[] = false + not_ready_polls[] = 0 + unreferenced_polls[] = 0 + frf_hook[] = nothing + error_code[] = 0 + ready_polls[] = 0 + referenced_polls[] = 0 + return nothing +end + +fail!(op) = push!(failing, op) +throw!(op) = push!(throwing, op) + +"Record `op`, throw if it is in `throwing`." +function record!(op) + push!(calls, op) + op in throwing && error("FakePIStage: $op threw") + return nothing +end + +"Record `op` and report FALSE if it is in `failing`, else TRUE." +function status!(op) + record!(op) + return op in failing ? Cint(0) : Cint(1) +end + +unmodelled(op) = error("FakePIStage: $op is not modelled") + +end # module FakePIStage + +FakePIStage.reset!() + +@eval MicroscopeControl.HardwareImplementations.PI begin + function PI_EnumerateUSB(buffer, bufsize, filter) + Main.FakePIStage.record!("PI_EnumerateUSB") + bytes = Main.FakePIStage.enum_bytes[] + for i in 1:min(length(bytes), Int(bufsize)) + buffer[i] = bytes[i] + end + return Cint(Main.FakePIStage.enum_count[]) + end + function PI_ConnectUSB(description) + Main.FakePIStage.record!("PI_ConnectUSB") + ids = Main.FakePIStage.connect_ids + id = isempty(ids) ? 0 : popfirst!(ids) + id >= 0 && push!(Main.FakePIStage.open_ids, id) + return Cint(id) + end + function PI_IsConnected(ID) + Main.FakePIStage.record!("PI_IsConnected") + return Int(ID) in Main.FakePIStage.open_ids ? Cint(1) : Cint(0) + end + function PI_CloseConnection(ID) + Main.FakePIStage.record!("PI_CloseConnection") + Main.FakePIStage.close_leaves_open[] || delete!(Main.FakePIStage.open_ids, Int(ID)) + return nothing + end + PI_GetError(ID) = (Main.FakePIStage.record!("PI_GetError"); Cint(Main.FakePIStage.error_code[])) + function PI_IsControllerReady(ID, piControllerReady) + st = Main.FakePIStage.status!("PI_IsControllerReady") + Main.FakePIStage.ready_polls[] += 1 + piControllerReady[] = Main.FakePIStage.ready_polls[] > Main.FakePIStage.not_ready_polls[] ? Cint(1) : Cint(0) + return st + end + function PI_FRF(ID, axes) + hook = Main.FakePIStage.frf_hook[] + hook === nothing || hook() + return Main.FakePIStage.status!("PI_FRF") + end + function PI_qFRF(ID, axes, referenced) + st = Main.FakePIStage.status!("PI_qFRF") + Main.FakePIStage.referenced_polls[] += 1 + done = Main.FakePIStage.referenced_polls[] > Main.FakePIStage.unreferenced_polls[] + referenced[1] = referenced[2] = done ? Cint(1) : Cint(0) + return st + end + PI_SVO(ID, axes, values) = Main.FakePIStage.status!("PI_SVO") + PI_VEL(ID, axes, values) = Main.FakePIStage.status!("PI_VEL") + function PI_qVEL(ID, axes, values) + st = Main.FakePIStage.status!("PI_qVEL") + values[1] = values[2] = 1.0 + return st + end + function PI_IsMoving(ID, axes, values) + st = Main.FakePIStage.status!("PI_IsMoving") + values[1] = values[2] = 0 + return st + end + function PI_qTMN(ID, axes, values) + st = Main.FakePIStage.status!("PI_qTMN") + values[1] = values[2] = 0.0 + return st + end + function PI_qTMX(ID, axes, values) + st = Main.FakePIStage.status!("PI_qTMX") + values[1] = values[2] = 25.0 + return st + end + function PI_qPOS(ID, axes, values) + st = Main.FakePIStage.status!("PI_qPOS") + for i in eachindex(values) + values[i] = 12.5 + end + return st + end + PI_MOV(ID, axes, values) = Main.FakePIStage.unmodelled("PI_MOV") + PI_HLT(ID, axes) = Main.FakePIStage.unmodelled("PI_HLT") + PI_STP(ID) = Main.FakePIStage.unmodelled("PI_STP") +end diff --git a/test/runtests.jl b/test/runtests.jl index 15b7ec5..fea70b4 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -8,6 +8,9 @@ const HDF5 = MicroscopeControl.HDF5 # be included at top level, before the testsets. See the file for the seam. include("tcube_fake_sdk.jl") +# Same seam for the PI stage's GCS2 wrappers; see the file. +include("pi_stage_fake_sdk.jl") + # Writes the lab test record summary when LAB_TEST_SUMMARY is set; see the file. include("lab_summary.jl") @@ -708,6 +711,7 @@ lab_summary("Core") do end end + include("pi_stage.jl") include("contract.jl") include("skills.jl") include("gui.jl") From 7acee6cdc99b110f4cf0babe1124f4def82899ed Mon Sep 17 00:00:00 2001 From: kalidke Date: Tue, 29 Sep 2026 14:53:09 -0600 Subject: [PATCH 03/20] 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 89dcd33b90870d0f01140a5671b7c6c150db5186 Mon Sep 17 00:00:00 2001 From: kalidke Date: Tue, 29 Sep 2026 15:15:25 -0600 Subject: [PATCH 04/20] Review fixes on #73 (P1-P5): check range and velocity reads, bound the motion-stop wait, reclaim own connection on retry, one GUI initialize guard Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 7 +- .../pi_stage/config_methods.jl | 61 +++++--- .../pi_stage/interface_methods.jl | 1 + .../pi_stage/query_methods.jl | 30 ++-- .../objective_positioner_interface/gui.jl | 7 +- .../stage_interface/gui.jl | 24 +--- .../triggerscope_interface/gui.jl | 3 +- src/instrument.jl | 17 +++ test/pi_stage.jl | 134 +++++++++++++++++- test/pi_stage_fake_sdk.jl | 16 ++- 10 files changed, 235 insertions(+), 65 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ccb50fa..58b9aca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,13 +13,16 @@ the next version with `-DEV`). ### Fixed - `PIStage`: `shutdown` could close another object's connection. `id` defaulted to `0`, a valid GCS id, and was never reset; it now defaults to `-1` and `shutdown` resets it. - `PIStage.initialize` reported the stage connected before it was: `connectionstatus` was set before the connect, and a failed close after a failed reference left it `true`, so a retry answered "already initialized". It is now set only after the whole sequence succeeds, and every step after the connect is inside the cleanup. -- The stage panels' initialize buttons log a failed `initialize` instead of throwing out of the click callback. +- `PIStage.initialize` ignored a FALSE from reading the travel range (`PI_qTMN`/`PI_qTMX`) or setting the velocity, and came up connected with an unset range. Each is now checked; a failure closes the connection and throws. A failed range query no longer writes an uninitialized buffer into `range_x`/`range_y`. +- `PIStage.initialize`'s wait for motion to stop after the reference move had no deadline and ignored `PI_IsMoving`'s return, so a failed query could spin forever. It now polls every 0.1 s, throws on a failed query, and gives up after `REFERENCE_TIMEOUT_S`. +- A `PIStage.initialize` retried after a failed close reported the controller held by another process. The stage now closes its own earlier connection first, and does not reconnect if that close fails too. +- The stage, Triggerscope and objective-positioner panels log a failed `initialize` instead of throwing out of the callback, through one helper, and a stage panel reads no position after an `initialize` that did not connect. ### Changed - `PIStage.initialize` waits for `PI_IsControllerReady` after the reference move, before polling `PI_qFRF`, as PI's samples do. Not yet run on hardware; needs a rig check on the C-867. ### Added -- Fake-GCS2 tests for `PIStage` (`test/pi_stage_fake_sdk.jl`, `test/pi_stage.jl`): `initialize`'s ordering and cleanup and `shutdown`'s id handling, with no hardware. +- Fake-GCS2 tests for `PIStage` (`test/pi_stage_fake_sdk.jl`, `test/pi_stage.jl`): `initialize`'s ordering and cleanup and `shutdown`'s id handling, the range, velocity and motion-stop checks, the reclaim after a failed close, and the GUI guard, with no hardware. ## [0.2.5] - 2026-09-29 diff --git a/src/hardware_implementations/pi_stage/config_methods.jl b/src/hardware_implementations/pi_stage/config_methods.jl index 83a5711..61b77c5 100644 --- a/src/hardware_implementations/pi_stage/config_methods.jl +++ b/src/hardware_implementations/pi_stage/config_methods.jl @@ -17,6 +17,17 @@ function initialize_original(stage::PIStage) return end + if stage.id >= 0 + # An earlier initialize connected and its close failed: this stage still holds the + # controller, and the DLL does not enumerate a controller that is open. Close it first. + @info "Closing this stage's earlier connection (id $(stage.id)) before reconnecting" + shutdown_original(stage) + if stage.id >= 0 + @error "This stage's earlier connection (id $(stage.id)) could not be closed; not reconnecting" + return + end + end + # Create a buffer string bufferstring = Vector{UInt8}(undef, 1024) @@ -60,16 +71,15 @@ function initialize_original(stage::PIStage) _waitforreference(stage; timeout = REFERENCE_TIMEOUT_S[]) #Find the max and min position of the axes - getrange(stage) + getrange(stage) == 1 || + error("PI_qTMN/PI_qTMX failed (GCS error $(_pi_geterror(stage))); travel range unknown") #Wait for any remaining motion to finish - ismoving(stage) - while stage.ismoving[1] == 1 || stage.ismoving[2] == 1 - ismoving(stage) - end + _waitforstop(stage; timeout = REFERENCE_TIMEOUT_S[]) #Set the velocity to `stage.velocity` - success = setvel(stage, stage.velocity) + setvel(stage, stage.velocity) == 1 || + error("PI_VEL/PI_qVEL failed (GCS error $(_pi_geterror(stage))); velocity not set") catch shutdown_original(stage) rethrow() @@ -93,8 +103,9 @@ end _pi_geterror(stage::PIStage) = PI_GetError(stage.id) """ -How long `initialize` waits, in seconds, for the controller to become ready and then for both -axes to report referenced. A `Ref` so tests can shorten it. +How long each of `initialize`'s three waits may take, in seconds: for the controller to report +ready, for both axes to report referenced, and for motion to stop. The budgets are separate, so +`initialize` can wait up to three times this in all. A `Ref` so tests can shorten it. """ const REFERENCE_TIMEOUT_S = Ref(60.0) @@ -116,11 +127,29 @@ function _waitforready(stage::PIStage; timeout::Real = REFERENCE_TIMEOUT_S[]) end end +""" +Poll `PI_IsMoving` every 0.1 s until neither axis is moving; throw if the query fails or motion +has not stopped within `timeout` seconds. Query only: it sends no motion command. +""" +function _waitforstop(stage::PIStage; timeout::Real = REFERENCE_TIMEOUT_S[]) + # PI_IsMoving fills `BOOL*`, bound as UInt32 in gcs2.jl. + moving = zeros(UInt32, 2) + deadline = time() + timeout + while true + ok = PI_IsMoving(stage.id, "1 2", moving) + ok == 1 || error("PI_IsMoving failed (GCS error $(_pi_geterror(stage)))") + stage.ismoving = (moving[1] != 0, moving[2] != 0) + any(!=(0), moving) || return nothing + time() > deadline && error("PI stage still moving after $(timeout) s") + sleep(0.1) + end +end + """ Poll `PI_qFRF` until both axes report referenced; throw if that has not happened within `timeout` seconds or the query itself fails. """ -function _waitforreference(stage::PIStage; timeout::Real = 60.0) +function _waitforreference(stage::PIStage; timeout::Real = REFERENCE_TIMEOUT_S[]) # PI_qFRF fills `BOOL*`: one 32-bit int per axis, like PI_SVO. referenced = zeros(Cint, 2) deadline = time() + timeout @@ -196,18 +225,18 @@ end function setvel(stage::PIStage,vel::Vector{Float64}) - success = PI_VEL(stage.id, "1 2", vel) + setok = PI_VEL(stage.id, "1 2", vel) - if success == 0 + if setok == 0 @error "Failed to set velocity" end - velocity = Vector{Cdouble}(undef, 2) - success = PI_qVEL(stage.id, "1 2", velocity) - - if success == 0 + velocity = zeros(Cdouble, 2) + queryok = PI_qVEL(stage.id, "1 2", velocity) + + if queryok == 0 @error "Failed to query velocity" else stage.velocity = velocity end - return success + return setok == 1 && queryok == 1 ? Cint(1) : Cint(0) end \ No newline at end of file diff --git a/src/hardware_implementations/pi_stage/interface_methods.jl b/src/hardware_implementations/pi_stage/interface_methods.jl index eb5291e..79b5f38 100644 --- a/src/hardware_implementations/pi_stage/interface_methods.jl +++ b/src/hardware_implementations/pi_stage/interface_methods.jl @@ -37,6 +37,7 @@ Function to update the position range of the PI Stage """ function StageInterface.getrange(stage::PIStage) getrange(stage) + return stage.range_y # preserves 0.2.5's return value end diff --git a/src/hardware_implementations/pi_stage/query_methods.jl b/src/hardware_implementations/pi_stage/query_methods.jl index 66e1b04..bb75691 100644 --- a/src/hardware_implementations/pi_stage/query_methods.jl +++ b/src/hardware_implementations/pi_stage/query_methods.jl @@ -36,32 +36,40 @@ BOOL PI_IsMoving (int ID, const char* szAxes, BOOL* pbValueArray) Function to check if the PI Stage is moving, both the x and y axis are checked """ function ismoving(stage::PIStage) - ismoving = Vector{UInt32}(undef, 2) + ismoving = zeros(UInt32, 2) PI_IsMoving(stage.id, "1 2", ismoving) stage.ismoving = (Bool(ismoving[1]), Bool(ismoving[2])) end function getrange(stage::PIStage) - findmin(stage) - findmax(stage) + # Both reads run, so a failed min does not skip the max. + okmin = findmin(stage) + okmax = findmax(stage) + return okmin == 1 && okmax == 1 ? Cint(1) : Cint(0) end """ """ function findmin(stage::PIStage) - minpositions = Vector{Cdouble}(undef, 2) - PI_qTMN(stage.id, "1 2", minpositions) - stage.range_x = (minpositions[1], stage.range_x[2]) - stage.range_y = (minpositions[2], stage.range_y[2]) + minpositions = zeros(Cdouble, 2) + ok = PI_qTMN(stage.id, "1 2", minpositions) + if ok == 1 + stage.range_x = (minpositions[1], stage.range_x[2]) + stage.range_y = (minpositions[2], stage.range_y[2]) + end + return ok end """ """ function findmax(stage::PIStage) - maxpositions = Vector{Cdouble}(undef, 2) - PI_qTMX(stage.id, "1 2", maxpositions) - stage.range_x = (stage.range_x[1], maxpositions[1]) - stage.range_y = (stage.range_y[1], maxpositions[2]) + maxpositions = zeros(Cdouble, 2) + ok = PI_qTMX(stage.id, "1 2", maxpositions) + if ok == 1 + stage.range_x = (stage.range_x[1], maxpositions[1]) + stage.range_y = (stage.range_y[1], maxpositions[2]) + end + return ok end \ No newline at end of file diff --git a/src/hardware_interfaces/objective_positioner_interface/gui.jl b/src/hardware_interfaces/objective_positioner_interface/gui.jl index 9bfe98f..c9b7316 100644 --- a/src/hardware_interfaces/objective_positioner_interface/gui.jl +++ b/src/hardware_interfaces/objective_positioner_interface/gui.jl @@ -1,12 +1,7 @@ using GLMakie function gui(positioner::Zpositioner) - try - initialize(positioner) - catch - @error "Failed to initialize the positioner. Please check the connection." - return - end + MicroscopeControl.gui_initialize(positioner, "positioner") || return fig = Figure(size=(600, 400)) diff --git a/src/hardware_interfaces/stage_interface/gui.jl b/src/hardware_interfaces/stage_interface/gui.jl index a634bfc..9785e19 100644 --- a/src/hardware_interfaces/stage_interface/gui.jl +++ b/src/hardware_interfaces/stage_interface/gui.jl @@ -164,13 +164,7 @@ function gui1d(stage::Stage) stopmotion(stage) end on(control_buttons[3].clicks) do initialize_click - try - initialize(stage) - catch err - @error "Failed to initialize the stage" exception = err - return - end - stage.connectionstatus || return + MicroscopeControl.gui_initialize(stage, "stage") || return getposition(stage) xposition[] = stage.real_x xtarget[] = stage.targ_x @@ -459,13 +453,7 @@ function gui2d(stage::Stage) stopmotion(stage) end on(control_buttons[3].clicks) do initialize_click - try - initialize(stage) - catch err - @error "Failed to initialize the stage" exception = err - return - end - stage.connectionstatus || return + MicroscopeControl.gui_initialize(stage, "stage") || return getposition(stage) xposition[], yposition[] = stage.real_x, stage.real_y xtarget[], ytarget[] = stage.targ_x, stage.targ_y @@ -807,13 +795,7 @@ function gui3d(stage::Stage) stopmotion(stage) end on(control_buttons[3].clicks) do initialize_click - try - initialize(stage) - catch err - @error "Failed to initialize the stage" exception = err - return - end - stage.connectionstatus || return + MicroscopeControl.gui_initialize(stage, "stage") || return getposition(stage) xposition[], yposition[], zposition[] = stage.real_x, stage.real_y, stage.real_z xtarget[], ytarget[], ztarget[] = stage.targ_x, stage.targ_y, stage.targ_z diff --git a/src/hardware_interfaces/triggerscope_interface/gui.jl b/src/hardware_interfaces/triggerscope_interface/gui.jl index 1673f61..94b6b31 100644 --- a/src/hardware_interfaces/triggerscope_interface/gui.jl +++ b/src/hardware_interfaces/triggerscope_interface/gui.jl @@ -30,7 +30,8 @@ function gui(trig::TRIG) trig_gui_fig[2,1] = stopbutton = Button(trig_gui_fig, label="Stop Device") on(startbutton.clicks) do event - initialize(trig) + MicroscopeControl.gui_initialize(trig, "Triggerscope") + return nothing end on(stopbutton.clicks) do event diff --git a/src/instrument.jl b/src/instrument.jl index 8de077c..503dc22 100644 --- a/src/instrument.jl +++ b/src/instrument.jl @@ -54,6 +54,23 @@ function gui(instrument::AbstractInstrument) error("gui not implemented for $(typeof(instrument))") end +""" + gui_initialize(device, what::AbstractString) -> Bool + +Call `initialize(device)` from a GUI panel or button. A throw is logged with `@error` instead of +escaping into Makie's callback. Returns `false` after a throw, or when the device has a +`connectionstatus` field that is still `false`; otherwise `true`. +""" +function gui_initialize(device, what::AbstractString) + try + initialize(device) + catch err + @error "Failed to initialize the $what" exception = err + return false + end + return !hasproperty(device, :connectionstatus) || device.connectionstatus +end + # ============================================================================= # AbstractSystem - Composite of instruments # ============================================================================= diff --git a/test/pi_stage.jl b/test/pi_stage.jl index 4f0b809..ee76a00 100644 --- a/test/pi_stage.jl +++ b/test/pi_stage.jl @@ -29,9 +29,10 @@ @test stage.id == 5 @test seen[] == false iFRF = findfirst(==("PI_FRF"), F.calls) - iready = findfirst(==("PI_IsControllerReady"), F.calls) - iq = findfirst(==("PI_qFRF"), F.calls) - @test iFRF < iready < iq + @test iFRF < findfirst(==("PI_IsControllerReady"), F.calls) + @test findlast(==("PI_IsControllerReady"), F.calls) < findfirst(==("PI_qFRF"), F.calls) + @test stage.range_x == (0.0, 25.0) + @test stage.range_y == (0.0, 25.0) @test ncalls("PI_IsControllerReady") == 3 end @@ -76,7 +77,7 @@ @test !stage.connectionstatus end - @testset "failed close keeps the id; retry cannot report ready" begin + @testset "failed close keeps the id; a retry reclaims it" begin F.reset!() push!(F.connect_ids, 5) F.fail!("PI_FRF") @@ -85,11 +86,136 @@ @test_throws ErrorException initialize(stage) @test !stage.connectionstatus @test stage.id == 5 + F.close_leaves_open[] = false + delete!(F.failing, "PI_FRF") + push!(F.connect_ids, 6) + empty!(F.calls) + initialize(stage) + @test stage.connectionstatus + @test stage.id == 6 + @test findfirst(==("PI_CloseConnection"), F.calls) < findfirst(==("PI_EnumerateUSB"), F.calls) + end + + @testset "reclaim fails: no reconnect" begin + F.reset!() + push!(F.connect_ids, 5) + F.fail!("PI_FRF") + F.close_leaves_open[] = true + stage = PIStage() + @test_throws ErrorException initialize(stage) + @test stage.id == 5 + empty!(F.calls) + initialize(stage) + @test !stage.connectionstatus + @test stage.id == 5 + @test ncalls("PI_EnumerateUSB") == 0 + end + + @testset "controller-ready query fails" begin + F.reset!() + push!(F.connect_ids, 5) + F.fail!("PI_IsControllerReady") + stage = PIStage() + @test_throws "PI_IsControllerReady failed" initialize(stage) + @test ncalls("PI_CloseConnection") == 1 + @test !stage.connectionstatus + @test stage.id == -1 + end + + @testset "nothing enumerated" begin + F.reset!() F.enum_count[] = 0 + stage = PIStage() + @test initialize(stage) === nothing + @test !stage.connectionstatus + @test stage.id == -1 + @test ncalls("PI_ConnectUSB") == 0 + end + + @testset "connect fails" begin + F.reset!() + push!(F.connect_ids, -1) + stage = PIStage() + @test initialize(stage) === nothing + @test !stage.connectionstatus + @test stage.id == -1 + @test ncalls("PI_SVO") == 0 + end + + @testset "range read fails" begin + for op in ("PI_qTMN", "PI_qTMX") + F.reset!() + push!(F.connect_ids, 5) + F.fail!(op) + stage = PIStage() + @test_throws "travel range unknown" initialize(stage) + @test ncalls("PI_CloseConnection") == 1 + @test !stage.connectionstatus + end + end + + @testset "velocity set fails" begin + for op in ("PI_VEL", "PI_qVEL") + F.reset!() + push!(F.connect_ids, 5) + F.fail!(op) + stage = PIStage() + @test_throws "velocity not set" initialize(stage) + @test ncalls("PI_CloseConnection") == 1 + @test !stage.connectionstatus + end + end + + @testset "motion stops after polls" begin + F.reset!() + push!(F.connect_ids, 5) + F.moving_polls[] = 2 + stage = PIStage() initialize(stage) + @test stage.connectionstatus + @test ncalls("PI_IsMoving") == 3 + end + + @testset "motion never stops" begin + F.reset!() + push!(F.connect_ids, 5) + F.moving_polls[] = typemax(Int) + stage = PIStage() + @test_throws "still moving" initialize(stage) + @test ncalls("PI_CloseConnection") == 1 @test !stage.connectionstatus end + @testset "IsMoving query fails" begin + F.reset!() + push!(F.connect_ids, 5) + F.fail!("PI_IsMoving") + stage = PIStage() + @test_throws "PI_IsMoving failed" initialize(stage) + @test ncalls("PI_CloseConnection") == 1 + @test !stage.connectionstatus + end + + @testset "GUI guard" begin + G = MicroscopeControl.gui_initialize + F.reset!() + push!(F.connect_ids, 5) + stage = PIStage() + @test G(stage, "stage") === true + + F.reset!() + F.enum_count[] = 0 + stage = PIStage() + @test G(stage, "stage") === false + + F.reset!() + push!(F.connect_ids, 5) + F.fail!("PI_FRF") + stage = PIStage() + r = @test_logs (:error, r"Failed to initialize the stage") match_mode=:any G(stage, "stage") + @test r === false + end + @testset "shutdown after initialize" begin F.reset!() push!(F.connect_ids, 5) diff --git a/test/pi_stage_fake_sdk.jl b/test/pi_stage_fake_sdk.jl index e0ab6a4..c5b1482 100644 --- a/test/pi_stage_fake_sdk.jl +++ b/test/pi_stage_fake_sdk.jl @@ -53,7 +53,11 @@ const frf_hook = Ref{Any}(nothing) "Returned by `PI_GetError`." const error_code = Ref(0) +"`PI_IsMoving` answers moving on both axes for this many polls." +const moving_polls = Ref(0) + const ready_polls = Ref(0) +const moving_count = Ref(0) const referenced_polls = Ref(0) function reset!() @@ -71,6 +75,8 @@ function reset!() error_code[] = 0 ready_polls[] = 0 referenced_polls[] = 0 + moving_polls[] = 0 + moving_count[] = 0 return nothing end @@ -144,22 +150,24 @@ FakePIStage.reset!() PI_VEL(ID, axes, values) = Main.FakePIStage.status!("PI_VEL") function PI_qVEL(ID, axes, values) st = Main.FakePIStage.status!("PI_qVEL") - values[1] = values[2] = 1.0 + st == 1 && (values[1] = values[2] = 1.0) return st end function PI_IsMoving(ID, axes, values) st = Main.FakePIStage.status!("PI_IsMoving") - values[1] = values[2] = 0 + Main.FakePIStage.moving_count[] += 1 + moving = Main.FakePIStage.moving_count[] <= Main.FakePIStage.moving_polls[] + st == 1 && (values[1] = values[2] = moving ? 1 : 0) return st end function PI_qTMN(ID, axes, values) st = Main.FakePIStage.status!("PI_qTMN") - values[1] = values[2] = 0.0 + st == 1 && (values[1] = values[2] = 0.0) return st end function PI_qTMX(ID, axes, values) st = Main.FakePIStage.status!("PI_qTMX") - values[1] = values[2] = 25.0 + st == 1 && (values[1] = values[2] = 25.0) return st end function PI_qPOS(ID, axes, values) From 4618582138e455f0856ae69b9b026daa16e71281 Mon Sep 17 00:00:00 2001 From: kalidke Date: Tue, 29 Sep 2026 15:29:57 -0600 Subject: [PATCH 05/20] Objective positioner panel: open disconnected after a failed initialize, as in 0.2.5 (#73 Q1) gui_initialize still logs the failure. 0.2.5 opened the panel when initialize returned without connecting (MCL_InitHandle == 0), with its callbacks refusing while connectionstatus is false; the P5 guard's '|| return' stopped that. Co-Authored-By: Claude Opus 5.5 --- src/hardware_interfaces/objective_positioner_interface/gui.jl | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/hardware_interfaces/objective_positioner_interface/gui.jl b/src/hardware_interfaces/objective_positioner_interface/gui.jl index c9b7316..abee4b8 100644 --- a/src/hardware_interfaces/objective_positioner_interface/gui.jl +++ b/src/hardware_interfaces/objective_positioner_interface/gui.jl @@ -1,7 +1,9 @@ using GLMakie function gui(positioner::Zpositioner) - MicroscopeControl.gui_initialize(positioner, "positioner") || return + # A failed initialize is logged, and the panel still opens disconnected, as in 0.2.5: + # its callbacks refuse until `connectionstatus` is true. + MicroscopeControl.gui_initialize(positioner, "positioner") fig = Figure(size=(600, 400)) From ddaa9b7c5c32b660414f56af3d6fe84c524f5dc3 Mon Sep 17 00:00:00 2001 From: kalidke Date: Tue, 29 Sep 2026 16:38:07 -0600 Subject: [PATCH 06/20] 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 07/20] 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 08/20] 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 09/20] 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) From a81bfb9e228264ef1858a8650d7c436deda5fb65 Mon Sep 17 00:00:00 2001 From: kalidke Date: Tue, 29 Sep 2026 19:13:11 -0600 Subject: [PATCH 10/20] DCAM4 capture: bound every wait, arm it before the start, clean up on every exit The quickbeam rig saw capture hang at full frame, 12.5 ms (v0.2.4). Fixes: - DCAMWAIT_START is built with its size (it was sent as 0), and a negative timeout (DCAM's INFINITE) is refused before any library call. - Every frame wait is bounded by capture_timeout_ms: 2 x (exposure + readout) + 1 s, with the readout read from the camera (TIMING_READOUTTIME); getdata scales it by N. - capture stops and releases any earlier capture first, arms the wait before dcamcap_start, and stops, releases and closes on every exit. A timeout or failed wait logs, sets last_error and throws a clear error. - getlastframe and getdata no longer destructure dcamwait_close's single DCAMERR (a MethodError on every failure path). getdata reads an ended cycle at once, never passes DCAM a null wait handle, and cleans up on every exit (in LIVE mode it now ends the live view). - test/dcam4_pure.jl covers what runs without the DCAM library. Co-Authored-By: Claude Sonnet 5.5 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 21 ++ .../dcam4_camera/dcam_helpers.jl | 61 ++++++ .../dcam4_camera/dcamwait.jl | 3 +- .../dcam4_camera/interface_methods.jl | 195 +++++++++++------- test/dcam4_pure.jl | 35 ++++ test/runtests.jl | 2 + 6 files changed, 241 insertions(+), 76 deletions(-) create mode 100644 test/dcam4_pure.jl diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bb5c07..b3a0ace 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,27 @@ the next version with `-DEV`). ## [Unreleased] +### Fixed + +- `DCAM4Camera` `capture` could hang or leave the camera unusable after a missed frame. Every frame wait is now + bounded by 2 x (exposure + readout) + 1 s, with the readout read from the camera + (`DCAM_IDPROP_TIMING_READOUTTIME`); the wait is armed before the capture starts; the wait's parameter struct + carries its size (it was sent as 0); and every exit stops the capture, releases the buffer and closes the wait. + A timeout or failed wait in `capture` now logs, sets `last_error` and throws a clear error (it threw a MethodError + before). `capture` also stops a running live view or sequence before it starts. Reported from the quickbeam rig: + full frame at 12.5 ms. +- `DCAM4Camera` `getlastframe` and `getdata`: a timeout or failed wait no longer throws a MethodError. It logs, + sets `last_error` and returns `nothing`, as the code intended. `getdata`'s wait for the end of a sequence now + includes the readout (2 x N x (exposure + readout) + 1 s; before, a full-frame sequence of short exposures timed + out). A sequence that has already ended is read without waiting. Every exit stops the capture, releases the + buffer and closes the wait. + +### Changed + +- `DCAM4Camera` `getdata` in LIVE mode now ends the live view, because every exit stops the capture and releases + the buffer. It already marked the camera not running, but DCAM refused the buffer release while capturing, so the + live view went on. No known rig code calls `getdata` during a live view. + ## [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/dcam4_camera/dcam_helpers.jl b/src/hardware_implementations/dcam4_camera/dcam_helpers.jl index 0a9bba2..a540c1f 100644 --- a/src/hardware_implementations/dcam4_camera/dcam_helpers.jl +++ b/src/hardware_implementations/dcam4_camera/dcam_helpers.jl @@ -203,5 +203,66 @@ function settriggermode!(camera::DCAM4Camera, trigger_mode::TriggerMode) settriggermode!(camera) end +""" + capture_timeout_ms(exposure_s, readout_s) -> Int32 + +The timeout for one frame wait, in milliseconds: `2 * (exposure_s + readout_s)` seconds plus 1 s. The readout term +matters: a full ORCA frame reads out in tens of milliseconds, far longer than a short exposure. Throws for a +negative or non-finite input, and for a result beyond `typemax(Int32)` ms, so a wait is never unbounded +(DCAM reads the negative value `0x80000000` as INFINITE). +""" +function capture_timeout_ms(exposure_s::Real, readout_s::Real) + (isfinite(exposure_s) && exposure_s >= 0) || + error("capture_timeout_ms: exposure $(exposure_s) s must be finite and non-negative") + (isfinite(readout_s) && readout_s >= 0) || + error("capture_timeout_ms: readout $(readout_s) s must be finite and non-negative") + t = 2000 * (Float64(exposure_s) + Float64(readout_s)) + 1000 + t <= typemax(Int32) || error("capture_timeout_ms: $(t) ms does not fit a DCAM timeout (Int32 ms)") + return Int32(round(t)) +end + +""" + READOUT_FALLBACK_S + +The readout time, in seconds, that `readout_time` assumes when the camera does not report +`DCAM_IDPROP_TIMING_READOUTTIME`. It is deliberately generous, since it only lengthens a timeout. +""" +const READOUT_FALLBACK_S = 1.0 + +""" + readout_time(camera::DCAM4Camera) -> Float64 +The sensor readout time in seconds, read from the camera's `DCAM_IDPROP_TIMING_READOUTTIME`, which depends on the +current ROI and readout speed, so read it after `setroi!`. If the read fails or the value is not a finite, +non-negative number, it warns and returns `READOUT_FALLBACK_S`. +""" +function readout_time(camera::DCAM4Camera) + err, t = dcamprop_getvalue(camera.camera_handle, DCAM_IDPROP_TIMING_READOUTTIME) + if is_failed(err) || !isfinite(t) || t < 0 + @warn "DCAM4Camera $(camera.unique_id): the camera did not report its readout time ($(err), $(t)); assuming $(READOUT_FALLBACK_S) s for the frame-wait timeout" + return READOUT_FALLBACK_S + end + return Float64(t) +end + +""" + stop_and_release!(camera::DCAM4Camera) +Leave the camera with no capture running and no buffer attached, whatever an earlier call left behind (a live view, +a sequence, or a capture that failed). It reads the status first, so a camera with nothing to stop logs no error: +BUSY is stopped, then BUSY or READY (a buffer attached) is released, and STABLE needs nothing. If the status read +fails, it tries both. Sets `camera.is_running = false`. +""" +function stop_and_release!(camera::DCAM4Camera) + hdcam = camera.camera_handle + err, status = dcamcap_status(hdcam) + if is_failed(err) + dcamcap_stop(hdcam) + dcambuf_release(hdcam) + else + status == DCAMCAP_STATUS_BUSY && dcamcap_stop(hdcam) + (status == DCAMCAP_STATUS_BUSY || status == DCAMCAP_STATUS_READY) && dcambuf_release(hdcam) + end + camera.is_running = false + return nothing +end diff --git a/src/hardware_implementations/dcam4_camera/dcamwait.jl b/src/hardware_implementations/dcam4_camera/dcamwait.jl index 2124162..474bb9e 100644 --- a/src/hardware_implementations/dcam4_camera/dcamwait.jl +++ b/src/hardware_implementations/dcam4_camera/dcamwait.jl @@ -82,7 +82,8 @@ function dcamwait_abort(hwait::Ptr{Cvoid}) end function dcamwait_event(hwait::Ptr{Cvoid}, eventmask::Int32, timeout_millisec::Int32) - dws = DCAMWAIT_START(0, 0, eventmask, timeout_millisec) + timeout_millisec >= 0 || error("dcamwait_event: timeout $(timeout_millisec) ms is negative (DCAM reads 0x80000000 as INFINITE); every DCAM wait must be bounded") + dws = DCAMWAIT_START(eventmask, timeout_millisec) err = dcamwait_start(hwait, Ref(dws)) if is_failed(err) @error "DCAM Failed to Wait for Event: $(err)))" diff --git a/src/hardware_implementations/dcam4_camera/interface_methods.jl b/src/hardware_implementations/dcam4_camera/interface_methods.jl index d5c16dc..3f8436a 100644 --- a/src/hardware_implementations/dcam4_camera/interface_methods.jl +++ b/src/hardware_implementations/dcam4_camera/interface_methods.jl @@ -2,67 +2,95 @@ """ CameraInterface.getlastframe(camera::DCAM4Camera) + +Wait for the next frame for at most `capture_timeout_ms(exposure, readout)`. On a timeout or a failed wait it logs, +sets `last_error` and returns `nothing`. """ function CameraInterface.getlastframe(camera::DCAM4Camera) - # Get the last image frame from the camera hdcam = camera.camera_handle - - # check if camera is busy, if so setup a wait - err, status = DCAM4.dcamcap_status(hdcam) - - if true #status == DCAM_STATUS_BUSY - err, hwait = dcamwait_open(hdcam) - timeout_milisec = Int32(max(1000, round(1000 * camera.exposure_time * 1.5))) - err, event = dcamwait_event(hwait, Int32(DCAMWAIT_CAPEVENT_FRAMEREADY), timeout_milisec) + timeout_ms = capture_timeout_ms(camera.exposure_time, readout_time(camera)) + err, hwait = dcamwait_open(hdcam) + if is_failed(err) + @error "Failed to open a frame wait: $err" + camera.last_error = err + return nothing + end + try + err, event = dcamwait_event(hwait, Int32(DCAMWAIT_CAPEVENT_FRAMEREADY), timeout_ms) if is_failed(err) - @error "Failed to get last frame on event $event: $err" - camera.last_error = err - err, hwait = dcamwait_close(hwait) - return - end - if is_timeout(err) - @error "Timeout to get last frame on event $event: $err" + if is_timeout(err) + @error "Timeout to get last frame on event $event after $timeout_ms ms: $err" + else + @error "Failed to get last frame on event $event: $err" + end camera.last_error = err - err, hwait = dcamwait_close(hwait) - return + return nothing end - - framedata = dcambuf_getlastframe(hdcam) - err = dcamwait_close(hwait) - - else - framedata = dcambuf_getlastframe(hdcam) + return dcambuf_getlastframe(hdcam) + finally + dcamwait_close(hwait) end - - return framedata end """ CameraInterface.capture(camera::DCAM4Camera) + +`capture` first stops any earlier capture and releases its buffer. It waits at most +`capture_timeout_ms(exposure, readout)` (2 x (exposure + readout) + 1 s, with the readout read from the camera). +A timeout or a failed wait logs, sets `last_error` and throws, after the capture is stopped and the buffer +released. A failed buffer allocation or start returns `nothing` with `last_error` set, as before. """ function CameraInterface.capture(camera::DCAM4Camera) - # Start a capture + hdcam = camera.camera_handle camera.capture_mode = SINGLE_FRAME + # Clear whatever an earlier call left (a live view, a sequence, a failed capture): + # properties cannot change while a buffer is attached. + stop_and_release!(camera) setexposuretime!(camera) settriggermode!(camera) setroi!(camera) + readout_s = readout_time(camera) + timeout_ms = capture_timeout_ms(camera.exposure_time, readout_s) - err = dcambuf_alloc(camera.camera_handle, Int32(1)) - if is_failed(err) - camera.last_error = err - return - end - - err = dcamcap_start(camera.camera_handle, Int32(DCAMCAP_START_SNAP)) - if is_failed(err) - camera.last_error = err - return + hwait = C_NULL + try + err = dcambuf_alloc(hdcam, Int32(1)) + if is_failed(err) + camera.last_error = err + return nothing + end + # Arm the wait BEFORE the start, so a frame that is ready early cannot be missed. + err, hwait = dcamwait_open(hdcam) + if is_failed(err) + camera.last_error = err + error("DCAM4Camera $(camera.unique_id): capture could not open a frame wait ($(err)).") + end + err = dcamcap_start(hdcam, Int32(DCAMCAP_START_SNAP)) + if is_failed(err) + camera.last_error = err + return nothing + end + err, _ = dcamwait_event(hwait, Int32(DCAMWAIT_CAPEVENT_FRAMEREADY), timeout_ms) + if is_failed(err) + camera.last_error = err + what = is_timeout(err) ? + "timed out after $(timeout_ms) ms (exposure $(camera.exposure_time) s, readout $(readout_s) s)" : + "frame wait failed ($(err))" + @error "DCAM4Camera $(camera.unique_id): capture $(what)" + error("DCAM4Camera $(camera.unique_id): capture $(what). The capture was stopped and its buffer " * + "released, so the camera can be used again.") + end + return dcambuf_getlastframe(hdcam) + finally + # On every exit, so the camera is usable afterwards. A failure here is logged, never + # thrown, so it cannot replace the error that got us here. + try + stop_and_release!(camera) + catch e + @error "DCAM4Camera $(camera.unique_id): cleanup after capture failed" exception = e + end + hwait == C_NULL || dcamwait_close(hwait) end - - data = getlastframe(camera) - err = dcambuf_release(camera.camera_handle) - - return data end """ @@ -165,43 +193,60 @@ end """ CameraInterface.getdata(camera::DCAM4Camera) + +Wait for the end of the cycle for at most `capture_timeout_ms(N * exposure, N * readout)`. A cycle that has +already ended is read at once. A timeout logs, sets `last_error` and returns `nothing`. Every exit stops the +capture, releases the buffer and closes the wait, so in LIVE mode `getdata` ends the live view. """ function CameraInterface.getdata(camera::DCAM4Camera) - # Get the data from the camera - - err, hwait = dcamwait_open(camera.camera_handle) - timeout_milisec = Int32(max(1000, round(1000 * camera.exposure_time * camera.sequence_length * 1.5))) - err, event = dcamwait_event(hwait, Int32(DCAMWAIT_CAPEVENT_CYCLEEND), timeout_milisec) + hdcam = camera.camera_handle + n = camera.sequence_length + # 2 x N x (exposure + readout) + 1 s: a full-frame readout can be longer than the exposure. + timeout_milisec = capture_timeout_ms(camera.exposure_time * n, readout_time(camera) * n) + hwait = C_NULL + try + # A cycle that has already ended (READY: stopped, with the buffer attached) is read at once. + # Waiting for its CYCLEEND could miss it, and the cleanup below would then drop the data. + serr, status = dcamcap_status(hdcam) + if is_failed(serr) || status != DCAMCAP_STATUS_READY + err, hwait = dcamwait_open(hdcam) + if is_failed(err) + # No wait to arm (dcamwait_open logged it): read what is there, as a failed wait + # always did, rather than hand DCAM a null wait handle. + camera.last_error = err + hwait = C_NULL + else + display(timeout_milisec) + err, event = dcamwait_event(hwait, Int32(DCAMWAIT_CAPEVENT_CYCLEEND), timeout_milisec) + if is_timeout(err) + display(event) + @error "DCAM4Camera $(camera.unique_id): getdata timed out after $(timeout_milisec) ms" + camera.last_error = err + return nothing + end + end + end - camera.is_running = false - display(timeout_milisec) - if is_timeout(err) - display(event) - camera.last_error = err - err, hwait = dcamwait_close(hwait) - return - end - err = dcamwait_close(hwait) - - if camera.capture_mode == SEQUENCE - display("Getting sequence data") - im_width, im_height = dcamprop_getsize(camera.camera_handle) - data = zeros(UInt16, im_height, im_width, camera.sequence_length) # (H, W, N) convention - for i in 1:camera.sequence_length - data[:, :, i] = dcambuf_getframe(camera.camera_handle, Int32(i - 1)) + if camera.capture_mode == SEQUENCE + display("Getting sequence data") + im_width, im_height = dcamprop_getsize(hdcam) + data = zeros(UInt16, im_height, im_width, n) # (H, W, N) convention + for i in 1:n + data[:, :, i] = dcambuf_getframe(hdcam, Int32(i - 1)) + end + return data + elseif camera.capture_mode == SINGLE_FRAME || camera.capture_mode == LIVE + return dcambuf_getlastframe(hdcam) + end + finally + # On every exit: stop, release the buffer, close the wait. A failure here is logged, never + # thrown, so it cannot replace the error or the data that got us here. + try + stop_and_release!(camera) + catch e + @error "DCAM4Camera $(camera.unique_id): cleanup after getdata failed" exception = e end - dcambuf_release(camera.camera_handle) - return data - - elseif camera.capture_mode == SINGLE_FRAME - data = dcambuf_getlastframe(camera.camera_handle) - dcambuf_release(camera.camera_handle) - return data - - elseif camera.capture_mode == LIVE - data = dcambuf_getlastframe(camera.camera_handle) - dcambuf_release(camera.camera_handle) - return data + hwait == C_NULL || dcamwait_close(hwait) end end diff --git a/test/dcam4_pure.jl b/test/dcam4_pure.jl new file mode 100644 index 0000000..1b17376 --- /dev/null +++ b/test/dcam4_pure.jl @@ -0,0 +1,35 @@ +# The DCAM library is absent on the machines that run this suite, so this file tests only what +# runs without it. The capture error paths call the DCAM library first and cannot be exercised +# without a swappable-library fake, which is a follow-up. +@testset "DCAM4 (no library)" begin + DC = MicroscopeControl.HardwareImplementations.DCAM4 + + @testset "capture_timeout_ms" begin + @test DC.capture_timeout_ms(0.0125, 0.043) == 1111 + @test DC.capture_timeout_ms(0.25, 0.25) == 2000 + @test DC.capture_timeout_ms(0, 0) == 1000 + @test DC.capture_timeout_ms(0.25, 0.25) isa Int32 + @test_throws ErrorException DC.capture_timeout_ms(NaN, 0) + @test_throws ErrorException DC.capture_timeout_ms(Inf, 0) + @test_throws ErrorException DC.capture_timeout_ms(-0.001, 0) + @test_throws ErrorException DC.capture_timeout_ms(0, -1) + @test_throws ErrorException DC.capture_timeout_ms(0, NaN) + @test_throws ErrorException DC.capture_timeout_ms(1e7, 0) + end + + @testset "wait parameter struct" begin + @test DC.DCAMWAIT_START(Int32(2), Int32(1000)).size == 16 + @test DC.DCAMWAIT_START(Int32(2), Int32(1000)).size == sizeof(DC.DCAMWAIT_START) + end + + @testset "an unbounded wait is refused before any library call" begin + @test_throws r"bounded" DC.dcamwait_event(C_NULL, Int32(2), Int32(-1)) + @test_throws r"bounded" DC.dcamwait_event(C_NULL, Int32(2), reinterpret(Int32, 0x80000000)) + end + + @testset "dcamwait_close is never destructured" begin + src = read(pkgdir(MicroscopeControl, "src", "hardware_implementations", "dcam4_camera", + "interface_methods.jl"), String) + @test !occursin(r",\s*\w+\s*=\s*dcamwait_close\(", src) + end +end diff --git a/test/runtests.jl b/test/runtests.jl index 893616a..442fd1a 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -1525,6 +1525,8 @@ lab_summary("Core") do include("pi_n472.jl") + include("dcam4_pure.jl") + include("contract.jl") include("skills.jl") include("gui.jl") From 485e68a9bc6ffedadf6bbf305514cd440ff6042b Mon Sep 17 00:00:00 2001 From: kalidke Date: Tue, 29 Sep 2026 19:37:03 -0600 Subject: [PATCH 11/20] Review fixes on #75 (D1-D8): capture refuses while running, getdata polls against a deadline, sequence's poller has one Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 35 +++--- .../dcam4_camera/dcam_helpers.jl | 69 ++++++++++-- .../dcam4_camera/dcambuf.jl | 13 ++- .../dcam4_camera/interface_methods.jl | 102 ++++++++---------- .../dcam4_camera/types.jl | 3 +- test/dcam4_pure.jl | 28 +++-- 6 files changed, 159 insertions(+), 91 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3a0ace..bf86422 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,24 +12,35 @@ the next version with `-DEV`). ### Fixed -- `DCAM4Camera` `capture` could hang or leave the camera unusable after a missed frame. Every frame wait is now +- `DCAM4Camera` `capture` could hang, or leave the camera unusable after a missed frame. Its frame wait is now bounded by 2 x (exposure + readout) + 1 s, with the readout read from the camera (`DCAM_IDPROP_TIMING_READOUTTIME`); the wait is armed before the capture starts; the wait's parameter struct carries its size (it was sent as 0); and every exit stops the capture, releases the buffer and closes the wait. - A timeout or failed wait in `capture` now logs, sets `last_error` and throws a clear error (it threw a MethodError - before). `capture` also stops a running live view or sequence before it starts. Reported from the quickbeam rig: - full frame at 12.5 ms. -- `DCAM4Camera` `getlastframe` and `getdata`: a timeout or failed wait no longer throws a MethodError. It logs, - sets `last_error` and returns `nothing`, as the code intended. `getdata`'s wait for the end of a sequence now - includes the readout (2 x N x (exposure + readout) + 1 s; before, a full-frame sequence of short exposures timed - out). A sequence that has already ended is read without waiting. Every exit stops the capture, releases the - buffer and closes the wait. + A timeout or failed wait logs, sets `last_error` and throws a clear error (it threw a MethodError before). + Reported from the quickbeam rig: full frame at 12.5 ms and at 100 ms, including the first capture in a fresh + session. +- `DCAM4Camera` `getlastframe`: a timeout or failed wait no longer throws a MethodError. It logs, sets `last_error` + and returns `nothing`, as the code intended. Its wait is bounded the same way, with the readout time read once per + `live`, `sequence` or `capture` and cached. +- `DCAM4Camera` `getdata` in SEQUENCE mode polls the capture status against a deadline of + 2 x N x (exposure + readout) + 1 s instead of waiting for the end-of-cycle event. A sequence that has already + ended cannot be missed, a full-frame sequence of short exposures no longer times out, and an interrupt can land + while it waits. A frame that cannot be read returns `nothing` with `last_error` set (it threw a MethodError). + Every exit stops the capture and releases the buffer. +- `DCAM4Camera` `sequence`: the task that marks the sequence finished gives up at the same deadline and stops a + capture stuck running, so `is_running` can no longer stay true forever. +- `DCAM4Camera`: clearing a leftover capture handles every state (a capture in the ERROR state was neither stopped + nor released), and `abort` now does exactly that. ### Changed -- `DCAM4Camera` `getdata` in LIVE mode now ends the live view, because every exit stops the capture and releases - the buffer. It already marked the camera not running, but DCAM refused the buffer release while capturing, so the - live view went on. No known rig code calls `getdata` during a live view. +- `DCAM4Camera` `capture` refuses (throws "Stop the live view or sequence first") while a live view or sequence is + running (`is_running`), instead of failing at the buffer allocation and returning `nothing`. It never releases a + buffer another task may be waiting on. A leftover from an earlier call (a failed capture, or a sequence that + ended and was never read) is stopped and released first. +- `DCAM4Camera` `getdata` in LIVE mode returns the newest frame at once and ends the live view, because every exit + stops the capture and releases the buffer. It already marked the camera not running, but DCAM refused the buffer + release while capturing. No known rig code calls `getdata` during a live view. ## [0.2.5] - 2026-09-29 diff --git a/src/hardware_implementations/dcam4_camera/dcam_helpers.jl b/src/hardware_implementations/dcam4_camera/dcam_helpers.jl index a540c1f..cf9fe8a 100644 --- a/src/hardware_implementations/dcam4_camera/dcam_helpers.jl +++ b/src/hardware_implementations/dcam4_camera/dcam_helpers.jl @@ -233,36 +233,83 @@ const READOUT_FALLBACK_S = 1.0 readout_time(camera::DCAM4Camera) -> Float64 The sensor readout time in seconds, read from the camera's `DCAM_IDPROP_TIMING_READOUTTIME`, which depends on the -current ROI and readout speed, so read it after `setroi!`. If the read fails or the value is not a finite, -non-negative number, it warns and returns `READOUT_FALLBACK_S`. +current ROI and readout speed, so read it after `setroi!`. It reads the camera and caches the value in +`camera.readout_s`. If the read fails or the value is not a finite, non-negative number, it warns and returns (and +caches) `READOUT_FALLBACK_S`. """ function readout_time(camera::DCAM4Camera) err, t = dcamprop_getvalue(camera.camera_handle, DCAM_IDPROP_TIMING_READOUTTIME) if is_failed(err) || !isfinite(t) || t < 0 - @warn "DCAM4Camera $(camera.unique_id): the camera did not report its readout time ($(err), $(t)); assuming $(READOUT_FALLBACK_S) s for the frame-wait timeout" + @warn "DCAM4Camera $(camera.unique_id): the camera did not report its readout time ($(err), $(t)); assuming $(READOUT_FALLBACK_S) s for the frame-wait timeout" maxlog = 1 + camera.readout_s = READOUT_FALLBACK_S return READOUT_FALLBACK_S end + camera.readout_s = Float64(t) return Float64(t) end +""" + cached_readout_time(camera::DCAM4Camera) -> Float64 + +The readout time cached by the last `readout_time` call (`live`, `sequence` and `capture` refresh it after +`setroi!`), or a fresh read if none is cached. Used on per-frame paths, so a live view does not read a +property per frame. +""" +cached_readout_time(camera::DCAM4Camera) = + isfinite(camera.readout_s) ? camera.readout_s : readout_time(camera) + """ stop_and_release!(camera::DCAM4Camera) Leave the camera with no capture running and no buffer attached, whatever an earlier call left behind (a live view, -a sequence, or a capture that failed). It reads the status first, so a camera with nothing to stop logs no error: -BUSY is stopped, then BUSY or READY (a buffer attached) is released, and STABLE needs nothing. If the status read -fails, it tries both. Sets `camera.is_running = false`. +a sequence, or a capture that failed). It reads the status first and does nothing only when the status is known to be +STABLE (no buffer) or UNSTABLE. A failed status read, BUSY or ERROR is stopped (unless READY, which is already +stopped); then anything with a buffer is released. The helpers log their own failures and never throw. Sets +`camera.is_running = false`. """ function stop_and_release!(camera::DCAM4Camera) hdcam = camera.camera_handle err, status = dcamcap_status(hdcam) - if is_failed(err) - dcamcap_stop(hdcam) + # Nothing to do only when the status is known to be STABLE (no buffer) or UNSTABLE. A failed + # status read, BUSY or ERROR is stopped; then anything with a buffer is released. The helpers + # log their own failures and never throw. + if is_failed(err) || !(status == DCAMCAP_STATUS_STABLE || status == DCAMCAP_STATUS_UNSTABLE) + status == DCAMCAP_STATUS_READY || dcamcap_stop(hdcam) dcambuf_release(hdcam) - else - status == DCAMCAP_STATUS_BUSY && dcamcap_stop(hdcam) - (status == DCAMCAP_STATUS_BUSY || status == DCAMCAP_STATUS_READY) && dcambuf_release(hdcam) end camera.is_running = false return nothing end + +""" + STATUS_POLL_S + +The interval, in seconds, at which `wait_not_busy` polls the capture status. +""" +const STATUS_POLL_S = 0.01 + +""" + wait_not_busy(camera::DCAM4Camera, timeout_ms, what) -> Bool + +Poll the capture status every `STATUS_POLL_S` until it is no longer BUSY, for at most `timeout_ms`. Returns `true` +when the capture has ended. On a timeout or a failed status read it logs (naming `what`), sets `last_error` and +returns `false`. It sleeps between polls, so an interrupt can land, unlike a blocking DCAM wait. +""" +function wait_not_busy(camera::DCAM4Camera, timeout_ms::Integer, what::AbstractString) + deadline = time() + timeout_ms / 1000 + while true + err, status = dcamcap_status(camera.camera_handle) + if is_failed(err) + camera.last_error = err + @error "DCAM4Camera $(camera.unique_id): $(what) could not read the capture status ($(err))" + return false + end + status == DCAMCAP_STATUS_BUSY || return true + if time() >= deadline + camera.last_error = DCAMERR_TIMEOUT + @error "DCAM4Camera $(camera.unique_id): $(what) timed out after $(timeout_ms) ms with the capture still running" + return false + end + sleep(STATUS_POLL_S) + end +end diff --git a/src/hardware_implementations/dcam4_camera/dcambuf.jl b/src/hardware_implementations/dcam4_camera/dcambuf.jl index 7d8df9e..f2f5f28 100644 --- a/src/hardware_implementations/dcam4_camera/dcambuf.jl +++ b/src/hardware_implementations/dcam4_camera/dcambuf.jl @@ -132,7 +132,7 @@ end # end -function dcambuf_getframe(hdcam::Ptr{Cvoid}, iFrame::Int32) +function dcambuf_getframe_err(hdcam::Ptr{Cvoid}, iFrame::Int32) err, width = dcamprop_getvalue(hdcam, Int32(DCAM_IDPROP_IMAGE_WIDTH)) err, height = dcamprop_getvalue(hdcam, Int32(DCAM_IDPROP_IMAGE_HEIGHT)) err, rowbytes = dcamprop_getvalue(hdcam, Int32(DCAM_IDPROP_IMAGE_ROWBYTES)) @@ -146,7 +146,7 @@ function dcambuf_getframe(hdcam::Ptr{Cvoid}, iFrame::Int32) bytes_per_pixel = 1 else @error "Unsupported pixel type" - return nothing + return DCAMERR_INVALIDPIXELTYPE, nothing end stride_elements = Int(rowbytes) ÷ bytes_per_pixel @@ -165,16 +165,19 @@ function dcambuf_getframe(hdcam::Ptr{Cvoid}, iFrame::Int32) err = @ccall "dcamapi.dll".dcambuf_copyframe(hdcam::Ptr{Cvoid}, pFrame::Ptr{DCAMBUF_FRAME})::DCAMERR if is_failed(err) - @error "DCAM Failed to Copy Frame" - return nothing + @error "DCAM Failed to Copy Frame $(iFrame): $(err)" + return err, nothing end # Reshape from row-major C buffer and permute to column-major Julia (H, W) data = permutedims(reshape(temp_buffer, (Int(width), Int(height))), (2, 1)) - return data + return DCAMERR_SUCCESS, data end +# The data only (`nothing` on a failure, which is logged); `dcambuf_getframe_err` also returns the DCAMERR. +dcambuf_getframe(hdcam::Ptr{Cvoid}, iFrame::Int32) = dcambuf_getframe_err(hdcam, iFrame)[2] + function dcambuf_getlastframe(hdcam::Ptr{Cvoid}) return dcambuf_getframe(hdcam::Ptr{Cvoid}, Int32(-1)) end diff --git a/src/hardware_implementations/dcam4_camera/interface_methods.jl b/src/hardware_implementations/dcam4_camera/interface_methods.jl index 3f8436a..78e0b20 100644 --- a/src/hardware_implementations/dcam4_camera/interface_methods.jl +++ b/src/hardware_implementations/dcam4_camera/interface_methods.jl @@ -3,12 +3,13 @@ """ CameraInterface.getlastframe(camera::DCAM4Camera) -Wait for the next frame for at most `capture_timeout_ms(exposure, readout)`. On a timeout or a failed wait it logs, +Wait for the next frame for at most `capture_timeout_ms(exposure, readout)`, with the readout cached by +`readout_time` (refreshed by `live`, `sequence` and `capture`). On a timeout or a failed wait it logs, sets `last_error` and returns `nothing`. """ function CameraInterface.getlastframe(camera::DCAM4Camera) hdcam = camera.camera_handle - timeout_ms = capture_timeout_ms(camera.exposure_time, readout_time(camera)) + timeout_ms = capture_timeout_ms(camera.exposure_time, cached_readout_time(camera)) err, hwait = dcamwait_open(hdcam) if is_failed(err) @error "Failed to open a frame wait: $err" @@ -35,15 +36,21 @@ end """ CameraInterface.capture(camera::DCAM4Camera) -`capture` first stops any earlier capture and releases its buffer. It waits at most +`capture` refuses (throws) while a live view or sequence is running (`is_running`); otherwise it first stops and +releases any leftover from an earlier call. It waits at most `capture_timeout_ms(exposure, readout)` (2 x (exposure + readout) + 1 s, with the readout read from the camera). A timeout or a failed wait logs, sets `last_error` and throws, after the capture is stopped and the buffer released. A failed buffer allocation or start returns `nothing` with `last_error` set, as before. """ function CameraInterface.capture(camera::DCAM4Camera) + # Never stop or release a capture another task may be waiting on: a live view's + # getlastframe loop crashes the process if its buffer is released under the wait. + camera.is_running && error("DCAM4Camera $(camera.unique_id): capture refused while a live view or " * + "sequence is running (is_running). Stop the live view or sequence first (abort(camera), or " * + "getdata after a sequence).") hdcam = camera.camera_handle camera.capture_mode = SINGLE_FRAME - # Clear whatever an earlier call left (a live view, a sequence, a failed capture): + # Only leftovers get here (a failed capture, or a sequence that ended and was never read): # properties cannot change while a buffer is attached. stop_and_release!(camera) setexposuretime!(camera) @@ -106,6 +113,7 @@ function CameraInterface.live(camera::DCAM4Camera; nframes=10) setexposuretime!(camera) settriggermode!(camera) setroi!(camera) + readout_time(camera) # refresh the cached readout for getlastframe # Allocate memory for the image buffer err = dcambuf_alloc(camera.camera_handle, Int32(nframes)) @@ -126,6 +134,10 @@ end """ CameraInterface.sequence(camera::DCAM4Camera, nframes::Real) + +Start a sequence of `nframes` and return. A task marks `is_running` false when the sequence ends, or after +`capture_timeout_ms(N * exposure, N * readout)`, when it stops a capture still running (logged, with `last_error` +set). """ function CameraInterface.sequence(camera::DCAM4Camera, nframes::Real) # Start collection of a sequence @@ -136,6 +148,7 @@ function CameraInterface.sequence(camera::DCAM4Camera, nframes::Real) settriggermode!(camera) setroi!(camera) + timeout_ms = capture_timeout_ms(camera.exposure_time * camera.sequence_length, readout_time(camera) * camera.sequence_length) # Allocate memory for the image buffer err = dcambuf_alloc(camera.camera_handle, Int32(camera.sequence_length)) @@ -152,24 +165,15 @@ function CameraInterface.sequence(camera::DCAM4Camera, nframes::Real) return end camera.is_running = 1 - err, frameinterval = DCAM4.dcamprop_getvalue(camera.camera_handle, DCAM4.DCAM_IDPROP_INTERNAL_FRAMEINTERVAL) - - err, status = DCAM4.dcamcap_status(camera.camera_handle) - #println("Starting sequence") @async begin println("Starting sequence") - while status == DCAM4.DCAMCAP_STATUS_BUSY - #println("Sequence running") - sleep(frameinterval) - err, status = DCAM4.dcamcap_status(camera.camera_handle) - #println(status) + # Bounded like getdata: a capture stuck BUSY is stopped, so is_running cannot stay true forever. + if !wait_not_busy(camera, timeout_ms, "sequence") + dcamcap_stop(camera.camera_handle) end - camera.is_running = 0 + camera.is_running = false println("Sequence done") end - - - return end @@ -183,70 +187,56 @@ end """ CameraInterface.abort(camera::DCAM4Camera) + +Stop any capture and release its buffer (`stop_and_release!`). Returns `nothing`. """ -function CameraInterface.abort(camera::DCAM4Camera) - # Abort a capture - dcamcap_stop(camera.camera_handle::Ptr{Cvoid}) - err = dcambuf_release(camera.camera_handle) - camera.is_running = 0 -end +CameraInterface.abort(camera::DCAM4Camera) = stop_and_release!(camera) """ CameraInterface.getdata(camera::DCAM4Camera) -Wait for the end of the cycle for at most `capture_timeout_ms(N * exposure, N * readout)`. A cycle that has -already ended is read at once. A timeout logs, sets `last_error` and returns `nothing`. Every exit stops the -capture, releases the buffer and closes the wait, so in LIVE mode `getdata` ends the live view. +In SEQUENCE mode it polls the capture status until the sequence ends, for at most +`capture_timeout_ms(N * exposure, N * readout)`, and reads the N frames. In SINGLE_FRAME or LIVE mode it reads the +newest frame at once. A timeout, a failed status read or a frame that cannot be read logs, sets `last_error` and +returns `nothing`. Every exit stops the capture and releases the buffer, so in LIVE mode `getdata` ends the live +view. """ function CameraInterface.getdata(camera::DCAM4Camera) hdcam = camera.camera_handle n = camera.sequence_length - # 2 x N x (exposure + readout) + 1 s: a full-frame readout can be longer than the exposure. - timeout_milisec = capture_timeout_ms(camera.exposure_time * n, readout_time(camera) * n) - hwait = C_NULL try - # A cycle that has already ended (READY: stopped, with the buffer attached) is read at once. - # Waiting for its CYCLEEND could miss it, and the cleanup below would then drop the data. - serr, status = dcamcap_status(hdcam) - if is_failed(serr) || status != DCAMCAP_STATUS_READY - err, hwait = dcamwait_open(hdcam) - if is_failed(err) - # No wait to arm (dcamwait_open logged it): read what is there, as a failed wait - # always did, rather than hand DCAM a null wait handle. - camera.last_error = err - hwait = C_NULL - else - display(timeout_milisec) - err, event = dcamwait_event(hwait, Int32(DCAMWAIT_CAPEVENT_CYCLEEND), timeout_milisec) - if is_timeout(err) - display(event) - @error "DCAM4Camera $(camera.unique_id): getdata timed out after $(timeout_milisec) ms" - camera.last_error = err - return nothing - end - end - end - if camera.capture_mode == SEQUENCE - display("Getting sequence data") + # 2 x N x (exposure + readout) + 1 s, inside the try so a throw here still cleans up. + timeout_ms = capture_timeout_ms(camera.exposure_time * n, cached_readout_time(camera) * n) + # Poll the status against that deadline rather than wait for the end-of-cycle event: + # a sequence that has already ended cannot be missed, and an interrupt can land. + wait_not_busy(camera, timeout_ms, "getdata") || return nothing im_width, im_height = dcamprop_getsize(hdcam) data = zeros(UInt16, im_height, im_width, n) # (H, W, N) convention for i in 1:n - data[:, :, i] = dcambuf_getframe(hdcam, Int32(i - 1)) + err, frame = dcambuf_getframe_err(hdcam, Int32(i - 1)) + if frame === nothing + camera.last_error = err + @error "DCAM4Camera $(camera.unique_id): getdata could not read frame $(i) of $(n) ($(err))" + return nothing + end + data[:, :, i] = frame end return data elseif camera.capture_mode == SINGLE_FRAME || camera.capture_mode == LIVE - return dcambuf_getlastframe(hdcam) + # No cycle to wait for: read the newest frame now. + err, frame = dcambuf_getframe_err(hdcam, Int32(-1)) + frame === nothing && (camera.last_error = err) + return frame end finally - # On every exit: stop, release the buffer, close the wait. A failure here is logged, never + # On every exit: stop the capture and release the buffer. A failure here is logged, never # thrown, so it cannot replace the error or the data that got us here. try stop_and_release!(camera) catch e @error "DCAM4Camera $(camera.unique_id): cleanup after getdata failed" exception = e end - hwait == C_NULL || dcamwait_close(hwait) end end diff --git a/src/hardware_implementations/dcam4_camera/types.jl b/src/hardware_implementations/dcam4_camera/types.jl index cb64a4d..47e99c8 100644 --- a/src/hardware_implementations/dcam4_camera/types.jl +++ b/src/hardware_implementations/dcam4_camera/types.jl @@ -27,6 +27,7 @@ mutable struct DCAM4Camera <: Camera is_running::Bool camerastate data::Array{UInt16} + readout_s::Float64 end function DCAM4Camera(dev_id::Int = 0; @@ -64,5 +65,5 @@ function DCAM4Camera(dev_id::Int = 0; err, exposure_time = dcamprop_getvalue(dco.hdcam, DCAM_IDPROP_EXPOSURETIME) data = zeros(UInt16, im_width, im_height) - DCAM4Camera(unique_id, camera_format, dco.hdcam, exposure_time, frame_rate, roi, capture_mode, trigger_mode, sequence_length, last_error, is_running, camerastate,data) + DCAM4Camera(unique_id, camera_format, dco.hdcam, exposure_time, frame_rate, roi, capture_mode, trigger_mode, sequence_length, last_error, is_running, camerastate, data, NaN) end \ No newline at end of file diff --git a/test/dcam4_pure.jl b/test/dcam4_pure.jl index 1b17376..46427a7 100644 --- a/test/dcam4_pure.jl +++ b/test/dcam4_pure.jl @@ -1,8 +1,15 @@ # The DCAM library is absent on the machines that run this suite, so this file tests only what -# runs without it. The capture error paths call the DCAM library first and cannot be exercised -# without a swappable-library fake, which is a follow-up. +# runs without it. The capture error paths, getdata's poll and stop_and_release! call the DCAM +# library first and cannot run without a swappable-library fake (a follow-up). The refusal and the +# cache are tested because they come before any library call. @testset "DCAM4 (no library)" begin DC = MicroscopeControl.HardwareImplementations.DCAM4 + CameraInterface = MicroscopeControl.HardwareInterfaces.CameraInterface + + # A camera built with no library call, through the positional constructor. + fake_camera() = DC.DCAM4Camera("fake", DC.CameraFormat(1, 1, 1, 1, "SCMOS"), C_NULL, 0.01, 10.0, + DC.CameraROI(0, 0, 1, 1), DC.LIVE, DC.DCAMPROP_TRIGGER_MODE__NORMAL, 10, DC.DCAMERR_SUCCESS, + false, 0, zeros(UInt16, 1, 1), NaN) @testset "capture_timeout_ms" begin @test DC.capture_timeout_ms(0.0125, 0.043) == 1111 @@ -27,9 +34,18 @@ @test_throws r"bounded" DC.dcamwait_event(C_NULL, Int32(2), reinterpret(Int32, 0x80000000)) end - @testset "dcamwait_close is never destructured" begin - src = read(pkgdir(MicroscopeControl, "src", "hardware_implementations", "dcam4_camera", - "interface_methods.jl"), String) - @test !occursin(r",\s*\w+\s*=\s*dcamwait_close\(", src) + @testset "capture refuses while running, before any library call" begin + cam = fake_camera() + cam.is_running = true + cam.capture_mode = DC.LIVE + @test_throws r"Stop the live view or sequence first" CameraInterface.capture(cam) + @test cam.capture_mode == DC.LIVE + @test cam.is_running + end + + @testset "cached readout time" begin + cam = fake_camera() + cam.readout_s = 0.043 + @test DC.cached_readout_time(cam) == 0.043 end end From bcfa0be282ed2ae6cf8bad8ac2b516025ae427f3 Mon Sep 17 00:00:00 2001 From: kalidke Date: Tue, 29 Sep 2026 19:54:15 -0600 Subject: [PATCH 12/20] Fix-check items on #75 (E1-E3): a stale sequence poller never touches a newer capture, getdata leaves a live view running Also: getdata reads a READY buffer that never ran as LOSTFRAME, and capture and getlastframe set last_error on a failed frame copy. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 13 +++-- .../dcam4_camera/dcam_helpers.jl | 33 +++++++---- .../dcam4_camera/interface_methods.jl | 57 ++++++++++++++----- .../dcam4_camera/types.jl | 3 +- test/dcam4_pure.jl | 9 ++- 5 files changed, 80 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf86422..b03ed9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,14 +21,18 @@ the next version with `-DEV`). session. - `DCAM4Camera` `getlastframe`: a timeout or failed wait no longer throws a MethodError. It logs, sets `last_error` and returns `nothing`, as the code intended. Its wait is bounded the same way, with the readout time read once per - `live`, `sequence` or `capture` and cached. + `live`, `sequence` or `capture` and cached. A frame that cannot be copied, here or in `capture`, sets `last_error`. - `DCAM4Camera` `getdata` in SEQUENCE mode polls the capture status against a deadline of 2 x N x (exposure + readout) + 1 s instead of waiting for the end-of-cycle event. A sequence that has already ended cannot be missed, a full-frame sequence of short exposures no longer times out, and an interrupt can land while it waits. A frame that cannot be read returns `nothing` with `last_error` set (it threw a MethodError). - Every exit stops the capture and releases the buffer. + Every exit stops the capture and releases the buffer. A sequence that transferred fewer frames than requested (or + never ran) returns `nothing` with `last_error` set to `DCAMERR_LOSTFRAME`. In LIVE mode `getdata` returns the + newest frame at once and leaves the live view running; it used to clear `is_running` while the view ran on. - `DCAM4Camera` `sequence`: the task that marks the sequence finished gives up at the same deadline and stops a - capture stuck running, so `is_running` can no longer stay true forever. + capture stuck running, so `is_running` can no longer stay true forever. It acts only while its sequence is + current, so it never stops or marks finished a newer live view or sequence, and a failed status read is retried + until the deadline. - `DCAM4Camera`: clearing a leftover capture handles every state (a capture in the ERROR state was neither stopped nor released), and `abort` now does exactly that. @@ -38,9 +42,6 @@ the next version with `-DEV`). running (`is_running`), instead of failing at the buffer allocation and returning `nothing`. It never releases a buffer another task may be waiting on. A leftover from an earlier call (a failed capture, or a sequence that ended and was never read) is stopped and released first. -- `DCAM4Camera` `getdata` in LIVE mode returns the newest frame at once and ends the live view, because every exit - stops the capture and releases the buffer. It already marked the camera not running, but DCAM refused the buffer - release while capturing. No known rig code calls `getdata` during a live view. ## [0.2.5] - 2026-09-29 diff --git a/src/hardware_implementations/dcam4_camera/dcam_helpers.jl b/src/hardware_implementations/dcam4_camera/dcam_helpers.jl index cf9fe8a..1a786a1 100644 --- a/src/hardware_implementations/dcam4_camera/dcam_helpers.jl +++ b/src/hardware_implementations/dcam4_camera/dcam_helpers.jl @@ -265,9 +265,11 @@ Leave the camera with no capture running and no buffer attached, whatever an ear a sequence, or a capture that failed). It reads the status first and does nothing only when the status is known to be STABLE (no buffer) or UNSTABLE. A failed status read, BUSY or ERROR is stopped (unless READY, which is already stopped); then anything with a buffer is released. The helpers log their own failures and never throw. Sets -`camera.is_running = false`. +`camera.is_running = false`. It also increments `capture_generation`, so a `sequence` poller started before it no +longer acts. Every start goes through it first: `live` and `sequence` through `abort`, and `capture` directly. """ function stop_and_release!(camera::DCAM4Camera) + camera.capture_generation += 1 # any stop makes every older sequence poller stale hdcam = camera.camera_handle err, status = dcamcap_status(hdcam) # Nothing to do only when the status is known to be STABLE (no buffer) or UNSTABLE. A failed @@ -289,27 +291,34 @@ The interval, in seconds, at which `wait_not_busy` polls the capture status. const STATUS_POLL_S = 0.01 """ - wait_not_busy(camera::DCAM4Camera, timeout_ms, what) -> Bool + wait_not_busy(camera::DCAM4Camera, timeout_ms, what; current = () -> true) -> Bool Poll the capture status every `STATUS_POLL_S` until it is no longer BUSY, for at most `timeout_ms`. Returns `true` -when the capture has ended. On a timeout or a failed status read it logs (naming `what`), sets `last_error` and -returns `false`. It sleeps between polls, so an interrupt can land, unlike a blocking DCAM wait. +when the capture has ended. A failed status read is logged once and retried until the deadline. At the deadline +it logs (naming `what`), sets `last_error` (the status read's error if the last read failed, else +`DCAMERR_TIMEOUT`) and returns `false`. It sleeps between polls, so an interrupt can land, unlike a blocking DCAM +wait. It also returns `false` as soon as `current()` is false (a newer capture replaced the one it watches), +touching nothing. """ -function wait_not_busy(camera::DCAM4Camera, timeout_ms::Integer, what::AbstractString) +function wait_not_busy(camera::DCAM4Camera, timeout_ms::Integer, what::AbstractString; + current::Function = () -> true) deadline = time() + timeout_ms / 1000 - while true + logged = false + while current() err, status = dcamcap_status(camera.camera_handle) if is_failed(err) - camera.last_error = err - @error "DCAM4Camera $(camera.unique_id): $(what) could not read the capture status ($(err))" - return false + logged || @error "DCAM4Camera $(camera.unique_id): $(what) could not read the capture status ($(err)); retrying until the deadline" + logged = true + elseif status != DCAMCAP_STATUS_BUSY + return true end - status == DCAMCAP_STATUS_BUSY || return true if time() >= deadline - camera.last_error = DCAMERR_TIMEOUT - @error "DCAM4Camera $(camera.unique_id): $(what) timed out after $(timeout_ms) ms with the capture still running" + camera.last_error = is_failed(err) ? err : DCAMERR_TIMEOUT + @error "DCAM4Camera $(camera.unique_id): $(what) timed out after $(timeout_ms) ms " * + (is_failed(err) ? "(the status read fails: $(err))" : "with the capture still running") return false end sleep(STATUS_POLL_S) end + return false end diff --git a/src/hardware_implementations/dcam4_camera/interface_methods.jl b/src/hardware_implementations/dcam4_camera/interface_methods.jl index 78e0b20..0a36e12 100644 --- a/src/hardware_implementations/dcam4_camera/interface_methods.jl +++ b/src/hardware_implementations/dcam4_camera/interface_methods.jl @@ -5,7 +5,7 @@ Wait for the next frame for at most `capture_timeout_ms(exposure, readout)`, with the readout cached by `readout_time` (refreshed by `live`, `sequence` and `capture`). On a timeout or a failed wait it logs, -sets `last_error` and returns `nothing`. +sets `last_error` and returns `nothing`. A frame that cannot be copied returns `nothing` with `last_error` set. """ function CameraInterface.getlastframe(camera::DCAM4Camera) hdcam = camera.camera_handle @@ -27,7 +27,9 @@ function CameraInterface.getlastframe(camera::DCAM4Camera) camera.last_error = err return nothing end - return dcambuf_getlastframe(hdcam) + err, frame = dcambuf_getframe_err(hdcam, Int32(-1)) + frame === nothing && (camera.last_error = err) + return frame finally dcamwait_close(hwait) end @@ -40,7 +42,7 @@ end releases any leftover from an earlier call. It waits at most `capture_timeout_ms(exposure, readout)` (2 x (exposure + readout) + 1 s, with the readout read from the camera). A timeout or a failed wait logs, sets `last_error` and throws, after the capture is stopped and the buffer -released. A failed buffer allocation or start returns `nothing` with `last_error` set, as before. +released. A failed buffer allocation or start returns `nothing` with `last_error` set, as before. A frame that cannot be copied returns `nothing` with `last_error` set. """ function CameraInterface.capture(camera::DCAM4Camera) # Never stop or release a capture another task may be waiting on: a live view's @@ -87,7 +89,9 @@ function CameraInterface.capture(camera::DCAM4Camera) error("DCAM4Camera $(camera.unique_id): capture $(what). The capture was stopped and its buffer " * "released, so the camera can be used again.") end - return dcambuf_getlastframe(hdcam) + err, frame = dcambuf_getframe_err(hdcam, Int32(-1)) + frame === nothing && (camera.last_error = err) + return frame finally # On every exit, so the camera is usable afterwards. A failure here is logged, never # thrown, so it cannot replace the error that got us here. @@ -137,7 +141,8 @@ end Start a sequence of `nframes` and return. A task marks `is_running` false when the sequence ends, or after `capture_timeout_ms(N * exposure, N * readout)`, when it stops a capture still running (logged, with `last_error` -set). +set). The task acts only while the sequence is current (no `abort`, `live`, `sequence`, `capture` or `getdata` +since); it always ends, and a throw inside it still clears `is_running` for its own sequence. """ function CameraInterface.sequence(camera::DCAM4Camera, nframes::Real) # Start collection of a sequence @@ -165,14 +170,21 @@ function CameraInterface.sequence(camera::DCAM4Camera, nframes::Real) return end camera.is_running = 1 + gen = camera.capture_generation # this sequence's generation (abort, above, already moved it on) @async begin println("Starting sequence") - # Bounded like getdata: a capture stuck BUSY is stopped, so is_running cannot stay true forever. - if !wait_not_busy(camera, timeout_ms, "sequence") - dcamcap_stop(camera.camera_handle) + current() = camera.capture_generation == gen + try + # Bounded like getdata. A capture stuck BUSY is stopped, but only while it is still this + # sequence: a newer live view or sequence must never be stopped by a stale poller. + if !wait_not_busy(camera, timeout_ms, "sequence"; current = current) && current() + dcamcap_stop(camera.camera_handle) + end + finally + # Only this sequence's poller may clear is_running; a newer capture owns it now. + current() && (camera.is_running = false) + println("Sequence done") end - camera.is_running = false - println("Sequence done") end return end @@ -196,14 +208,22 @@ CameraInterface.abort(camera::DCAM4Camera) = stop_and_release!(camera) CameraInterface.getdata(camera::DCAM4Camera) In SEQUENCE mode it polls the capture status until the sequence ends, for at most -`capture_timeout_ms(N * exposure, N * readout)`, and reads the N frames. In SINGLE_FRAME or LIVE mode it reads the -newest frame at once. A timeout, a failed status read or a frame that cannot be read logs, sets `last_error` and -returns `nothing`. Every exit stops the capture and releases the buffer, so in LIVE mode `getdata` ends the live -view. +`capture_timeout_ms(N * exposure, N * readout)`, and reads the N frames. In SINGLE_FRAME mode it reads the +newest frame at once. In LIVE mode it returns the newest frame at once and leaves the live view running (no stop, +no release, `is_running` unchanged). A timeout, a failed status read or a frame that cannot be read logs, sets +`last_error` and returns `nothing`; so does a sequence that transferred fewer frames than requested, with +`last_error` set to `DCAMERR_LOSTFRAME`. Every other exit stops the capture and releases the buffer. """ function CameraInterface.getdata(camera::DCAM4Camera) hdcam = camera.camera_handle n = camera.sequence_length + if camera.capture_mode == LIVE + # Leave the live view running, with no stop and no release: a threaded live reader + # crashes the process if its buffer is released under it. + err, frame = dcambuf_getframe_err(hdcam, Int32(-1)) + frame === nothing && (camera.last_error = err) + return frame + end try if camera.capture_mode == SEQUENCE # 2 x N x (exposure + readout) + 1 s, inside the try so a throw here still cleans up. @@ -211,6 +231,13 @@ function CameraInterface.getdata(camera::DCAM4Camera) # Poll the status against that deadline rather than wait for the end-of-cycle event: # a sequence that has already ended cannot be missed, and an interrupt can land. wait_not_busy(camera, timeout_ms, "getdata") || return nothing + # READY also describes a buffer that was allocated but never ran: check the frame count. + terr, info = dcamcap_transferinfo(hdcam) + if !is_failed(terr) && info.nFrameCount < n + camera.last_error = DCAMERR_LOSTFRAME + @error "DCAM4Camera $(camera.unique_id): getdata found $(info.nFrameCount) of $(n) frames transferred" + return nothing + end im_width, im_height = dcamprop_getsize(hdcam) data = zeros(UInt16, im_height, im_width, n) # (H, W, N) convention for i in 1:n @@ -223,7 +250,7 @@ function CameraInterface.getdata(camera::DCAM4Camera) data[:, :, i] = frame end return data - elseif camera.capture_mode == SINGLE_FRAME || camera.capture_mode == LIVE + elseif camera.capture_mode == SINGLE_FRAME # No cycle to wait for: read the newest frame now. err, frame = dcambuf_getframe_err(hdcam, Int32(-1)) frame === nothing && (camera.last_error = err) diff --git a/src/hardware_implementations/dcam4_camera/types.jl b/src/hardware_implementations/dcam4_camera/types.jl index 47e99c8..6a270b6 100644 --- a/src/hardware_implementations/dcam4_camera/types.jl +++ b/src/hardware_implementations/dcam4_camera/types.jl @@ -28,6 +28,7 @@ mutable struct DCAM4Camera <: Camera camerastate data::Array{UInt16} readout_s::Float64 + capture_generation::Int end function DCAM4Camera(dev_id::Int = 0; @@ -65,5 +66,5 @@ function DCAM4Camera(dev_id::Int = 0; err, exposure_time = dcamprop_getvalue(dco.hdcam, DCAM_IDPROP_EXPOSURETIME) data = zeros(UInt16, im_width, im_height) - DCAM4Camera(unique_id, camera_format, dco.hdcam, exposure_time, frame_rate, roi, capture_mode, trigger_mode, sequence_length, last_error, is_running, camerastate, data, NaN) + DCAM4Camera(unique_id, camera_format, dco.hdcam, exposure_time, frame_rate, roi, capture_mode, trigger_mode, sequence_length, last_error, is_running, camerastate, data, NaN, 0) end \ No newline at end of file diff --git a/test/dcam4_pure.jl b/test/dcam4_pure.jl index 46427a7..bf61e38 100644 --- a/test/dcam4_pure.jl +++ b/test/dcam4_pure.jl @@ -9,7 +9,14 @@ # A camera built with no library call, through the positional constructor. fake_camera() = DC.DCAM4Camera("fake", DC.CameraFormat(1, 1, 1, 1, "SCMOS"), C_NULL, 0.01, 10.0, DC.CameraROI(0, 0, 1, 1), DC.LIVE, DC.DCAMPROP_TRIGGER_MODE__NORMAL, 10, DC.DCAMERR_SUCCESS, - false, 0, zeros(UInt16, 1, 1), NaN) + false, 0, zeros(UInt16, 1, 1), NaN, 0) + + @testset "a superseded wait touches nothing, before any library call" begin + cam = fake_camera() + cam.last_error = DC.DCAMERR_SUCCESS + @test DC.wait_not_busy(cam, 1000, "test"; current = () -> false) == false + @test cam.last_error == DC.DCAMERR_SUCCESS + end @testset "capture_timeout_ms" begin @test DC.capture_timeout_ms(0.0125, 0.043) == 1111 From 0ad190e46230bfa8450b4a4fae13f06caed2e71c Mon Sep 17 00:00:00 2001 From: kalidke Date: Tue, 29 Sep 2026 20:02:56 -0600 Subject: [PATCH 13/20] Release 0.2.6: drop -DEV; one CHANGELOG section with a line per part (PI stage, TCube laser safety, DCAM4 capture) Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 18 +++++++++++++++++- Project.toml | 2 +- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e741ff9..24787b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,23 @@ 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.6] - 2026-09-29 + +A non-breaking release in three parts. None of it has run on hardware yet; the rig +checks are listed in #74 and #75. + +- **PI stage (#73).** `PIStage.initialize` checks every status return, bounds its + wait for motion to stop, and reclaims its own earlier connection. The stage, + Triggerscope and objective-positioner panels log a failed `initialize` instead of + throwing. +- **TCube laser safety (#74).** Fixes from the 642 nm rig's controller facts. Fresh + reads send their request twice, because the TLD001 answers one request behind. + `check_lock` refuses in both directions. A refusal found while the diode is lit + zeroes and disables it first. Power mode gains an optional calibration reference, + which every power-mode rig should record. Several calls take longer (see Changed). +- **DCAM4 capture (#75).** Every frame wait is bounded and armed before the capture + starts, and every exit cleans up. This fixes the quickbeam rig's full-frame + `capture` hang. `capture` now refuses while a live view or sequence runs. ### Fixed diff --git a/Project.toml b/Project.toml index f1f971d..790e5d4 100644 --- a/Project.toml +++ b/Project.toml @@ -1,6 +1,6 @@ name = "MicroscopeControl" uuid = "aa70d9ae-4a1e-49fd-870a-8ccfd99f4c3e" -version = "0.2.6-DEV" +version = "0.2.6" authors = ["klidke@unm.edu"] [deps] From 6f30d365e01b3ea2b7c7a108848ea575a0112ee0 Mon Sep 17 00:00:00 2001 From: kalidke Date: Tue, 29 Sep 2026 20:16:12 -0600 Subject: [PATCH 14/20] 0.2.6 CHANGELOG review nits (N2-N5): name #73's rig check, narrow the PI and DCAM4 claims, drop a blank line Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 24787b0..5c152f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,10 +11,11 @@ the next version with `-DEV`). ## [0.2.6] - 2026-09-29 A non-breaking release in three parts. None of it has run on hardware yet; the rig -checks are listed in #74 and #75. +checks are listed in #73 (the C-867 startup check), #74 and #75. -- **PI stage (#73).** `PIStage.initialize` checks every status return, bounds its - wait for motion to stop, and reclaims its own earlier connection. The stage, +- **PI stage (#73).** `PIStage.initialize` checks the travel-range, velocity and + motion-stop returns, bounds its wait for motion to stop, and reclaims its own + earlier connection. The stage, Triggerscope and objective-positioner panels log a failed `initialize` instead of throwing. - **TCube laser safety (#74).** Fixes from the 642 nm rig's controller facts. Fresh @@ -23,12 +24,12 @@ checks are listed in #74 and #75. zeroes and disables it first. Power mode gains an optional calibration reference, which every power-mode rig should record. Several calls take longer (see Changed). - **DCAM4 capture (#75).** Every frame wait is bounded and armed before the capture - starts, and every exit cleans up. This fixes the quickbeam rig's full-frame - `capture` hang. `capture` now refuses while a live view or sequence runs. + starts, and every exit cleans up. That addresses the likely cause of the quickbeam + rig's full-frame `capture` hang, pending the rig check. `capture` now refuses while + a live view or sequence runs. ### Fixed - - `PIStage`: `shutdown` could close another object's connection. `id` defaulted to `0`, a valid GCS id, and was never reset; it now defaults to `-1` and `shutdown` resets it. - `PIStage.initialize` reported the stage connected before it was: `connectionstatus` was set before the connect, and a failed close after a failed reference left it `true`, so a retry answered "already initialized". It is now set only after the whole sequence succeeds, and every step after the connect is inside the cleanup. - `PIStage.initialize` ignored a FALSE from reading the travel range (`PI_qTMN`/`PI_qTMX`) or setting the velocity, and came up connected with an unset range. Each is now checked; a failure closes the connection and throws. A failed range query no longer writes an uninitialized buffer into `range_x`/`range_y`. From d4f5797bb28f9738a5468c9c61597c860f686195 Mon Sep 17 00:00:00 2001 From: kalidke Date: Tue, 29 Sep 2026 20:24:24 -0600 Subject: [PATCH 15/20] 0.2.6 CHANGELOG: the quickbeam rig confirms the DCAM4 capture fix (26 full-frame captures on trial 97d26f6) Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c152f9..4d2dab7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,23 +10,25 @@ the next version with `-DEV`). ## [0.2.6] - 2026-09-29 -A non-breaking release in three parts. None of it has run on hardware yet; the rig -checks are listed in #73 (the C-867 startup check), #74 and #75. +A non-breaking release in three parts. Only the DCAM4 part has run on hardware so +far (below); the rig checks still owed are listed in #73 (the C-867 startup check), +#74 and #75. - **PI stage (#73).** `PIStage.initialize` checks the travel-range, velocity and motion-stop returns, bounds its wait for motion to stop, and reclaims its own - earlier connection. The stage, - Triggerscope and objective-positioner panels log a failed `initialize` instead of - throwing. + earlier connection. The stage, Triggerscope and objective-positioner panels log a + failed `initialize` instead of throwing. - **TCube laser safety (#74).** Fixes from the 642 nm rig's controller facts. Fresh reads send their request twice, because the TLD001 answers one request behind. `check_lock` refuses in both directions. A refusal found while the diode is lit zeroes and disables it first. Power mode gains an optional calibration reference, which every power-mode rig should record. Several calls take longer (see Changed). - **DCAM4 capture (#75).** Every frame wait is bounded and armed before the capture - starts, and every exit cleans up. That addresses the likely cause of the quickbeam - rig's full-frame `capture` hang, pending the rig check. `capture` now refuses while - a live view or sequence runs. + starts, and every exit cleans up. This fixes the full-frame `capture` hang, + confirmed on the quickbeam rig: 26 full-frame captures at 12.5, 100 and 250 ms on a + v0.2.4-based trial with this camera code, with no timeout or hang. The forced-timeout + and sequence checks are still owed. `capture` now refuses while a live view or + sequence runs. ### Fixed From cabde643051e249518da76f4d01f54dc34edf2c4 Mon Sep 17 00:00:00 2001 From: kalidke Date: Thu, 1 Oct 2026 09:28:39 -0600 Subject: [PATCH 16/20] 0.2.6 CHANGELOG: claim only what is proven; one camera hang remains; dated today The DCAM4 summary no longer says the full-frame capture hang is fixed: the rig's 26 of 26 captures stand, and the hang seen after the rig's GPU code loaded is named as open and tracked for 0.2.7, as are the NOTREADY, TIMEOUT and NOTSTABLE errors seen in GUI use. The Fixed entry points to it. The release heading carries the date it ships. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d2dab7..a54bc79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ 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`). -## [0.2.6] - 2026-09-29 +## [0.2.6] - 2026-10-01 A non-breaking release in three parts. Only the DCAM4 part has run on hardware so far (below); the rig checks still owed are listed in #73 (the C-867 startup check), @@ -24,11 +24,14 @@ far (below); the rig checks still owed are listed in #73 (the C-867 startup chec zeroes and disables it first. Power mode gains an optional calibration reference, which every power-mode rig should record. Several calls take longer (see Changed). - **DCAM4 capture (#75).** Every frame wait is bounded and armed before the capture - starts, and every exit cleans up. This fixes the full-frame `capture` hang, - confirmed on the quickbeam rig: 26 full-frame captures at 12.5, 100 and 250 ms on a - v0.2.4-based trial with this camera code, with no timeout or hang. The forced-timeout - and sequence checks are still owed. `capture` now refuses while a live view or - sequence runs. + starts, and every exit cleans up. On the quickbeam rig, full-frame captures that had + always hung passed 26 of 26 (12.5, 100 and 250 ms) on a v0.2.4-based trial with this + camera code. One hang remains: after the rig's GPU code had loaded, a full-frame + capture hung inside a camera library call, on several cores, until the process was + ended. It is not fixed here and is tracked for 0.2.7. The NOTREADY, TIMEOUT and + NOTSTABLE errors the rig logs during GUI use are not fixed here either. The + forced-timeout and sequence checks are still owed. `capture` now refuses while a + live view or sequence runs. ### Fixed @@ -69,13 +72,13 @@ far (below); the rig checks still owed are listed in #73 (the C-867 startup chec `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). -- `DCAM4Camera` `capture` could hang, or leave the camera unusable after a missed frame. Its frame wait is now - bounded by 2 x (exposure + readout) + 1 s, with the readout read from the camera - (`DCAM_IDPROP_TIMING_READOUTTIME`); the wait is armed before the capture starts; the wait's parameter struct - carries its size (it was sent as 0); and every exit stops the capture, releases the buffer and closes the wait. - A timeout or failed wait logs, sets `last_error` and throws a clear error (it threw a MethodError before). - Reported from the quickbeam rig: full frame at 12.5 ms and at 100 ms, including the first capture in a fresh - session. +- `DCAM4Camera` `capture` could hang, or leave the camera unusable after a missed frame. One hang inside a camera + library call remains (see the summary). Its frame wait is now bounded by 2 x (exposure + readout) + 1 s, with the + readout read from the camera (`DCAM_IDPROP_TIMING_READOUTTIME`); the wait is armed before the capture starts; the + wait's parameter struct carries its size (it was sent as 0); and every exit stops the capture, releases the + buffer and closes the wait. A timeout or failed wait logs, sets `last_error` and throws a clear error (it threw a + MethodError before). Reported from the quickbeam rig: full frame at 12.5 ms and at 100 ms, including the first + capture in a fresh session. - `DCAM4Camera` `getlastframe`: a timeout or failed wait no longer throws a MethodError. It logs, sets `last_error` and returns `nothing`, as the code intended. Its wait is bounded the same way, with the readout time read once per `live`, `sequence` or `capture` and cached. A frame that cannot be copied, here or in `capture`, sets `last_error`. From 371d6ab7d540e5bd2be46ddaddb5fdf4db16a19d Mon Sep 17 00:00:00 2001 From: kalidke Date: Thu, 1 Oct 2026 11:53:03 -0600 Subject: [PATCH 17/20] Warn on three power-mode laser limits: failed zero, unchecked scale, latched refusal Power mode stays and nothing new is refused. A failed setpoint zero is flagged (PhotodiodeLoop.zero_failed) and the next light_on warns that the stored setpoint may be stale. setoutputpower! with the output on and the photodiode scale unchecked or refused warns. The latched calibration re-check refusal tries a switch-off first when the output may be lit, and still throws. CALIBRATION.md records the reference (3b) inside step 3. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 11 ++++++ .../tcube_laser/CALIBRATION.md | 10 +++--- .../tcube_laser/interface_methods.jl | 15 ++++++-- .../lightsource_interface/interface_types.jl | 8 +++-- test/runtests.jl | 35 +++++++++++++++++++ 5 files changed, 70 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a54bc79..473ea93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -95,6 +95,17 @@ far (below); the rig checks still owed are listed in #73 (the C-867 startup chec until the deadline. - `DCAM4Camera`: clearing a leftover capture handles every state (a capture in the ERROR state was neither stopped nor released), and `abort` now does exactly that. +- `TCubeLaser` power mode: a failed setpoint zero (in `light_off`, or in a failure cleanup) is no longer only + logged. It is flagged in the laser's state (`PhotodiodeLoop.zero_failed`, cleared by a later successful zero + and by `initialize`), and the next `light_on` warns that the controller's stored setpoint may be stale and + that the first moments after the enable may run toward it, bounded by the programmed clamp. +- `TCubeLaser` power mode: `setoutputpower!` with the output on warns when the photodiode scale is unchecked (the + calibration re-check was refused or has not run, as after a front-panel switch-on): the delivered power may + differ from the request, by twice at half the photodiode gain. Recalibrate or call `light_on`. +- `TCubeLaser` power mode: the latched calibration-reference refusal tries a switch-off first when the output may + be lit, and its message says that the laser may still be lit if an earlier switch-off failed. It still throws. +- `CALIBRATION.md`: the calibration reference (step 3b) is recorded inside step 3, with the laser still on and + before `light_off` and `shutdown`. ### Changed diff --git a/src/hardware_implementations/tcube_laser/CALIBRATION.md b/src/hardware_implementations/tcube_laser/CALIBRATION.md index 724b11c..baae44e 100644 --- a/src/hardware_implementations/tcube_laser/CALIBRATION.md +++ b/src/hardware_implementations/tcube_laser/CALIBRATION.md @@ -239,7 +239,7 @@ for I in 70.0:10.0:130.0 P = parse(Float64, readline()) push!(rows, (I_mA = I, I_pd_A = s.photocurrent_A, P_mW = P)) end -light_off(laser) +# the laser stays on: record the calibration reference (3b) before light_off and shutdown x = [r.I_pd_A for r in rows] y = [r.P_mW / 1000 for r in rows] # W @@ -249,16 +249,18 @@ wa_calibration = sum(x .* y) / sum(x .^ 2) # least squares through the ori Is = [r.I_mA for r in rows]; Ps = [r.P_mW for r in rows] slope = sum((Is .- mean(Is)) .* (Ps .- mean(Ps))) / sum((Is .- mean(Is)) .^ 2) threshold_current = mean(Is) - mean(Ps) / slope - -shutdown(laser) ``` Check the residuals: if power against photocurrent is not a straight line, the range is wrong (step 1) or the photodiode is saturating. +Step 3b belongs to this step and runs now, with the laser still on. Only then switch it +off, with `light_off(laser)` and `shutdown(laser)`. + ### 3b. Record the calibration reference -Do this in the same session, on the same range and gain as the W/A factor. Pick one +Do this inside step 3, before its `light_off(laser)` and `shutdown(laser)`, 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). diff --git a/src/hardware_implementations/tcube_laser/interface_methods.jl b/src/hardware_implementations/tcube_laser/interface_methods.jl index 00e63b4..f47df00 100644 --- a/src/hardware_implementations/tcube_laser/interface_methods.jl +++ b/src/hardware_implementations/tcube_laser/interface_methods.jl @@ -725,9 +725,13 @@ correct range change, where the words do follow, passes. check_scale!(light::TCubeLaser{ConstantCurrent}) = nothing function check_scale!(light::TCubeLaser{ConstantPhotocurrent}) pd, serialNo = light.pd, light.serialNo - pd.scale_refused && error( - "TCubeLaser $serialNo: light_on refused: the calibration-reference re-check failed since the last initialize (a mismatch, or a command or mode failure during it), " * - "and the diode is not lit again to re-check it. Fix the setup or recalibrate (CALIBRATION.md), then call initialize.") + if pd.scale_refused + output_may_be_on(light) && disable_after_failure(light, "light_on (latched calibration-reference refusal)") # the output may be lit if an earlier switch-off failed + error( + "TCubeLaser $serialNo: light_on refused: the calibration-reference re-check failed since the last initialize (a mismatch, or a command or mode failure during it), " * + "and the diode is not lit again to re-check it. The laser may still be lit if an earlier switch-off failed; a switch-off was just attempted (see the log). " * + "Fix the setup or recalibrate (CALIBRATION.md), then call initialize.") + end 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." @@ -921,6 +925,7 @@ function reset_loop_state!(light::TCubeLaser{ConstantPhotocurrent}) pd.max_current_clamp = NaN pd.scale_checked = false pd.scale_refused = false + pd.zero_failed = false return nothing end @@ -1045,6 +1050,7 @@ function LightSourceInterface.light_on(light::TCubeLaser) require_clamp(regulation_mode(light), light, "light_on") check_scale!(light) serialNo = light.serialNo + light.pd isa PhotodiodeLoop && light.pd.zero_failed && @warn "TCubeLaser $serialNo: the last setpoint zero failed, so the controller's stored setpoint may be stale (after a failed re-check, the calibration-reference word read as a power target); the first moments after this enable may run toward it, bounded by the programmed clamp" has_request(light) || @warn "TCubeLaser $(serialNo): light_on before any setpoint was requested; sending setpoint 0, since the controller's stored setpoint cannot be trusted" code = intended_code(light) # On (or unknown) from the moment the enable is sent (Codex C3); a failed @@ -1222,6 +1228,7 @@ 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 + on && (pd.scale_refused || !pd.scale_checked) && @warn "TCubeLaser $serialNo: the photodiode scale is unchecked (the calibration re-check was refused or has not run), so the delivered power may differ from the request (twice it at half the photodiode gain); recalibrate or call light_on" overrange = try bits & STATUS_BITS.tia_over != 0 || (on && read_photocurrent_word(light) == PHOTOCURRENT_OVER_RANGE) catch @@ -1274,6 +1281,7 @@ function zero_then_disable(light::TCubeLaser) serialNo = light.serialNo zeroed = zero_setpoint(light) isnothing(zeroed) || @error "TCubeLaser $serialNo: zeroing the setpoint before disable failed ($zeroed); the controller's stored setpoint was not cleared" + light.pd isa PhotodiodeLoop && (light.pd.zero_failed = !isnothing(zeroed)) check_err(LD_DisableOutput(serialNo), "LD_DisableOutput", serialNo) light.properties.is_on = false return nothing @@ -1292,6 +1300,7 @@ the next enable must start dark, not on a stale setpoint. function disable_after_failure(light::TCubeLaser, what::AbstractString) serialNo = light.serialNo zeroed = zero_setpoint(light) + light.pd isa PhotodiodeLoop && (light.pd.zero_failed = !isnothing(zeroed)) disabled, offerr = false, nothing try status = LD_DisableOutput(serialNo) diff --git a/src/hardware_interfaces/lightsource_interface/interface_types.jl b/src/hardware_interfaces/lightsource_interface/interface_types.jl index ea891a0..f1f4169 100644 --- a/src/hardware_interfaces/lightsource_interface/interface_types.jl +++ b/src/hardware_interfaces/lightsource_interface/interface_types.jl @@ -167,8 +167,11 @@ delivered power drifts while photocurrent is held steady. That is why 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`. +- `zero_failed::Bool`: state, `true` while the last setpoint zero failed (cleared by a later + successful zero and by `initialize`). The next power-mode `light_on` warns that the controller's + stored setpoint may be stale. -Construct it with the keyword form, which fills the last twelve fields. +Construct it with the keyword form, which fills the last thirteen fields. """ mutable struct PhotodiodeLoop wa_calibration::Float64 @@ -186,6 +189,7 @@ mutable struct PhotodiodeLoop ref_ratio::Float64 scale_checked::Bool scale_refused::Bool + zero_failed::Bool end """ @@ -224,6 +228,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, false) + Float64(ref_ratio), false, false, false) end diff --git a/test/runtests.jl b/test/runtests.jl index 3027b55..4c66464 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -940,6 +940,41 @@ lab_summary("Core") do empty!(FK.calls) @test_throws r"not lit again" light_on(l) @test "LD_SetOpenLoopMode" ∉ FK.calls && "LD_EnableOutput" ∉ FK.calls + # B1: a failed zero sets a flag; the next power-mode light_on warns once; a good zero or initialize clears it. + l = readyr(); light_on(l) + @test !l.pd.zero_failed + FK.fail!("LD_SetLaserSetPoint") + @test_logs (:error, r"zeroing the setpoint") match_mode = :any light_off(l) + @test l.pd.zero_failed + FK.status["LD_SetLaserSetPoint"] = 0 + @test_logs (:warn, r"stored setpoint may be stale") match_mode = :any light_on(l) + light_off(l) + @test !l.pd.zero_failed + @test_logs light_on(l) + FK.fail!("LD_SetLaserSetPoint"); light_off(l); FK.status["LD_SetLaserSetPoint"] = 0 + @test l.pd.zero_failed + initialize(l) + @test !l.pd.zero_failed + # B2: setoutputpower! with the output on and the scale unchecked or refused warns; checked, or output off, stays quiet. + l = readyr(); light_on(l) + @test_logs setoutputpower!(l, 10.0) + l.pd.scale_checked = false # a front-panel switch-on never ran the re-check + @test_logs (:warn, r"photodiode scale is unchecked") match_mode = :any setoutputpower!(l, 10.0) + l.pd.scale_checked = true; l.pd.scale_refused = true + @test_logs (:warn, r"photodiode scale is unchecked") match_mode = :any setoutputpower!(l, 10.0) + l = readyr() + @test_logs setoutputpower!(l, 12.0) + # B3: the latched refusal first tries a switch-off when the output may be lit, and still throws. + l = readyr(); FK.pd_scale[] = 0.5 + @test_throws r"calibration reference" light_on(l) + empty!(FK.calls) + @test_throws r"not lit again" light_on(l) + @test "LD_DisableOutput" ∉ FK.calls # output off: no switch-off attempt + l.properties.is_on = true + empty!(FK.calls) + @test_logs (:error, r"output disabled") match_mode = :any @test_throws r"switch-off was just attempted" light_on(l) + @test "LD_DisableOutput" ∈ FK.calls && !l.properties.is_on + FK.pd_scale[] = 1.0 # M2: open-loop initialize confirms open loop. FK.reset!(); FK.bits[] |= FK.CLOSED; FK.open_loop_ignored[] = true l = cc() From 0fec84ea42f73dede183462058511ca79817c21f Mon Sep 17 00:00:00 2001 From: kalidke Date: Thu, 1 Oct 2026 16:00:35 -0600 Subject: [PATCH 18/20] 0.2.6 (option B): the final laser fixes, and the camera items as known issues Brings in the laser and guide parts of the final review pass (ac39877): the calibration-reference re-check in light_on switches the output off before rethrowing whatever it throws; zero_failed is cleared only by a zero that lands; the refused-scale warning says to re-initialize; the guide's 3b block ends with light_off and shutdown. Camera code and tests stay as at cabde64. The camera fixes (b2327f8, ac39877) are kept on the local branch dcam4-0.2.7 for 0.2.7, and the CHANGELOG lists them, with two laser limits, under Known issues. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 32 ++++++++++--- .../tcube_laser/CALIBRATION.md | 5 ++- .../tcube_laser/interface_methods.jl | 45 ++++++++++++++----- .../lightsource_interface/interface_types.jl | 6 +-- test/runtests.jl | 24 ++++++++-- 5 files changed, 84 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 473ea93..3ac14c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -96,16 +96,21 @@ far (below); the rig checks still owed are listed in #73 (the C-867 startup chec - `DCAM4Camera`: clearing a leftover capture handles every state (a capture in the ERROR state was neither stopped nor released), and `abort` now does exactly that. - `TCubeLaser` power mode: a failed setpoint zero (in `light_off`, or in a failure cleanup) is no longer only - logged. It is flagged in the laser's state (`PhotodiodeLoop.zero_failed`, cleared by a later successful zero - and by `initialize`), and the next `light_on` warns that the controller's stored setpoint may be stale and + logged. It is flagged in the laser's state (`PhotodiodeLoop.zero_failed`, cleared only by a zero that lands, + one sent while the output is recorded on, since the controller ignores a zero sent with the output off, and by + `initialize`), and the next `light_on` warns that the controller's stored setpoint may be stale and that the first moments after the enable may run toward it, bounded by the programmed clamp. - `TCubeLaser` power mode: `setoutputpower!` with the output on warns when the photodiode scale is unchecked (the calibration re-check was refused or has not run, as after a front-panel switch-on): the delivered power may - differ from the request, by twice at half the photodiode gain. Recalibrate or call `light_on`. -- `TCubeLaser` power mode: the latched calibration-reference refusal tries a switch-off first when the output may - be lit, and its message says that the laser may still be lit if an earlier switch-off failed. It still throws. -- `CALIBRATION.md`: the calibration reference (step 3b) is recorded inside step 3, with the laser still on and - before `light_off` and `shutdown`. + differ from the request, by twice at half the photodiode gain. Recalibrate or call `light_on`; after a refused + re-check, which `light_on` keeps refusing until `initialize`, it says to re-initialize after fixing the setup or + recalibrating. +- `TCubeLaser` power mode: whatever the calibration-reference re-check throws in `light_on` (the latched refusal, + a reference above the current ceiling, a command failure or a mismatch), the output is switched off first when + it may be lit, as for a `require_clamp` refusal, and the error is rethrown. The log says whether the switch-off + worked; the refusal message no longer claims a switch-off that was not made. +- `CALIBRATION.md`: the calibration reference (step 3b) is recorded inside step 3, with the laser still on, and + its code block ends with `light_off(laser); shutdown(laser)`, so running the blocks in order leaves the diode off. ### Changed @@ -142,6 +147,19 @@ far (below); the rig checks still owed are listed in #73 (the C-867 startup chec **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. +### Known issues + +- Camera (`DCAM4Camera`), fixed in 0.2.7: + - A frame read waiting in `getdata` can stop and release a newer live view's buffer if another task starts one. + - A failed stop or buffer release is not recorded, and the camera is marked stopped; it recovers at the next start. + - A sequence or live view that fails to start leaves its buffer allocated until the next start. + - If the frame-count query fails, a sequence can return frames without checking that all arrived. +- Laser (`TCubeLaser`): + - In power mode, a zero sent right after a failed enable counts as landed, so the next `light_on` may not warn + about a stale stored setpoint. The first moments after that enable are still bounded by the programmed clamp. + - In `CALIBRATION.md`, if reading the reference throws, the example's `light_off` and `shutdown` do not run: + switch the laser off by hand after any error there. + ## [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 baae44e..4918191 100644 --- a/src/hardware_implementations/tcube_laser/CALIBRATION.md +++ b/src/hardware_implementations/tcube_laser/CALIBRATION.md @@ -259,8 +259,8 @@ off, with `light_off(laser)` and `shutdown(laser)`. ### 3b. Record the calibration reference -Do this inside step 3, before its `light_off(laser)` and `shutdown(laser)`, on the same -range and gain as the W/A factor. Pick one +Do this inside step 3, with the laser still on, on the same range and gain as the W/A +factor; the block below ends step 3 with `light_off(laser)` and `shutdown(laser)`. 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). @@ -269,6 +269,7 @@ otherwise). On the 642 nm rig that is 90 mA (about 99 uA, about 22 mW at 224.2 W 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 +light_off(laser); shutdown(laser) # the end of step 3: the laser is off ``` Then pass `ref_current_mA = 90.0, ref_photocurrent_A = ref_photocurrent_A` when you build diff --git a/src/hardware_implementations/tcube_laser/interface_methods.jl b/src/hardware_implementations/tcube_laser/interface_methods.jl index f47df00..e5ae099 100644 --- a/src/hardware_implementations/tcube_laser/interface_methods.jl +++ b/src/hardware_implementations/tcube_laser/interface_methods.jl @@ -710,7 +710,10 @@ latches the refusal until the next `initialize`: `scale_refused` is set before t 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. +closed loop restored. Whatever it throws (that latched refusal, a reference above the +ceiling, a failure or a mismatch), `light_on` first switches the output off +([`disable_after_failure`](@ref)) when it may be on, as for a `require_clamp` refusal, then +rethrows; the log says whether that switch-off worked. 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 @@ -725,13 +728,10 @@ correct range change, where the words do follow, passes. check_scale!(light::TCubeLaser{ConstantCurrent}) = nothing function check_scale!(light::TCubeLaser{ConstantPhotocurrent}) pd, serialNo = light.pd, light.serialNo - if pd.scale_refused - output_may_be_on(light) && disable_after_failure(light, "light_on (latched calibration-reference refusal)") # the output may be lit if an earlier switch-off failed - error( - "TCubeLaser $serialNo: light_on refused: the calibration-reference re-check failed since the last initialize (a mismatch, or a command or mode failure during it), " * - "and the diode is not lit again to re-check it. The laser may still be lit if an earlier switch-off failed; a switch-off was just attempted (see the log). " * - "Fix the setup or recalibrate (CALIBRATION.md), then call initialize.") - end + pd.scale_refused && error( + "TCubeLaser $serialNo: light_on refused: the calibration-reference re-check failed since the last initialize (a mismatch, or a command or mode failure during it), " * + "and the diode is not lit again to re-check it. " * + "Fix the setup or recalibrate (CALIBRATION.md), then call initialize.") pd.scale_checked && return nothing if isnan(pd.ref_current_mA) @warn "TCubeLaser $serialNo: no calibration reference (ref_current_mA, ref_photocurrent_A), so the photodiode scale re-check is skipped for this initialize; check_lock's two-sided test is the only guard against a scale change since calibration. See CALIBRATION.md." @@ -1048,7 +1048,14 @@ controller power cycle can restore the pot). """ function LightSourceInterface.light_on(light::TCubeLaser) require_clamp(regulation_mode(light), light, "light_on") - check_scale!(light) + try + check_scale!(light) + catch + # require_clamp's safety catch: a refusal while the output may be on (an earlier switch-off + # failed, or the reference is above the clamp) zeroes and disables it first. + output_may_be_on(light) && disable_after_failure(light, "light_on (the calibration-reference check refused while the output may be on)") + rethrow() + end serialNo = light.serialNo light.pd isa PhotodiodeLoop && light.pd.zero_failed && @warn "TCubeLaser $serialNo: the last setpoint zero failed, so the controller's stored setpoint may be stale (after a failed re-check, the calibration-reference word read as a power target); the first moments after this enable may run toward it, bounded by the programmed clamp" 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" @@ -1228,7 +1235,8 @@ 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 - on && (pd.scale_refused || !pd.scale_checked) && @warn "TCubeLaser $serialNo: the photodiode scale is unchecked (the calibration re-check was refused or has not run), so the delivered power may differ from the request (twice it at half the photodiode gain); recalibrate or call light_on" + on && (pd.scale_refused || !pd.scale_checked) && @warn "TCubeLaser $serialNo: the photodiode scale is unchecked (the calibration re-check was refused or has not run), so the delivered power may differ from the request (twice it at half the photodiode gain); " * + (pd.scale_refused ? "re-initialize after fixing the setup or recalibrating (light_on refuses until then)" : "recalibrate or call light_on") overrange = try bits & STATUS_BITS.tia_over != 0 || (on && read_photocurrent_word(light) == PHOTOCURRENT_OVER_RANGE) catch @@ -1281,7 +1289,7 @@ function zero_then_disable(light::TCubeLaser) serialNo = light.serialNo zeroed = zero_setpoint(light) isnothing(zeroed) || @error "TCubeLaser $serialNo: zeroing the setpoint before disable failed ($zeroed); the controller's stored setpoint was not cleared" - light.pd isa PhotodiodeLoop && (light.pd.zero_failed = !isnothing(zeroed)) + note_zero!(light, zeroed) check_err(LD_DisableOutput(serialNo), "LD_DisableOutput", serialNo) light.properties.is_on = false return nothing @@ -1300,7 +1308,7 @@ the next enable must start dark, not on a stale setpoint. function disable_after_failure(light::TCubeLaser, what::AbstractString) serialNo = light.serialNo zeroed = zero_setpoint(light) - light.pd isa PhotodiodeLoop && (light.pd.zero_failed = !isnothing(zeroed)) + note_zero!(light, zeroed) disabled, offerr = false, nothing try status = LD_DisableOutput(serialNo) @@ -1319,6 +1327,19 @@ function disable_after_failure(light::TCubeLaser, what::AbstractString) return disabled end +# Record a setpoint zero's outcome in `pd.zero_failed` (power mode), before the disable. A failed zero sets it. A +# zero sent with the output off is ignored by the controller, so only one sent while the output is recorded on +# (`properties.is_on`) clears it; `initialize` clears it too. +function note_zero!(light::TCubeLaser, zeroed) + light.pd isa PhotodiodeLoop || return nothing + if !isnothing(zeroed) + light.pd.zero_failed = true + elseif light.properties.is_on + light.pd.zero_failed = false + end + return nothing +end + """ zero_setpoint(light::TCubeLaser) diff --git a/src/hardware_interfaces/lightsource_interface/interface_types.jl b/src/hardware_interfaces/lightsource_interface/interface_types.jl index f1f4169..d9d1940 100644 --- a/src/hardware_interfaces/lightsource_interface/interface_types.jl +++ b/src/hardware_interfaces/lightsource_interface/interface_types.jl @@ -167,9 +167,9 @@ delivered power drifts while photocurrent is held steady. That is why 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`. -- `zero_failed::Bool`: state, `true` while the last setpoint zero failed (cleared by a later - successful zero and by `initialize`). The next power-mode `light_on` warns that the controller's - stored setpoint may be stale. +- `zero_failed::Bool`: state, `true` once a setpoint zero failed, until a zero lands (one that + succeeds while the output is recorded on; the controller ignores a zero sent with the output off) + or `initialize`. The next power-mode `light_on` warns that the controller's stored setpoint may be stale. Construct it with the keyword form, which fills the last thirteen fields. """ diff --git a/test/runtests.jl b/test/runtests.jl index 4c66464..b80fd45 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -890,6 +890,10 @@ lab_summary("Core") do l = readyr(; ref_current_mA=159.95) @test_throws ArgumentError light_on(l) @test "LD_SetOpenLoopMode" ∉ FK.calls && "LD_EnableOutput" ∉ FK.calls + # N1: refused there with the output possibly lit, it is switched off first. + l = readyr(; ref_current_mA=159.95); l.properties.is_on = true; empty!(FK.calls) + @test_logs (:error, r"output disabled") match_mode = :any @test_throws ArgumentError light_on(l) + @test "LD_DisableOutput" ∈ FK.calls && !l.properties.is_on # 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) @@ -953,27 +957,39 @@ lab_summary("Core") do @test_logs light_on(l) FK.fail!("LD_SetLaserSetPoint"); light_off(l); FK.status["LD_SetLaserSetPoint"] = 0 @test l.pd.zero_failed + # X2: a zero sent with the output off is ignored by the controller, so it leaves the flag set. + light_off(l) + @test l.pd.zero_failed + @test_logs (:error, r"test failed; output disabled") TCube.disable_after_failure(l, "test") + @test l.pd.zero_failed initialize(l) @test !l.pd.zero_failed # B2: setoutputpower! with the output on and the scale unchecked or refused warns; checked, or output off, stays quiet. l = readyr(); light_on(l) @test_logs setoutputpower!(l, 10.0) l.pd.scale_checked = false # a front-panel switch-on never ran the re-check - @test_logs (:warn, r"photodiode scale is unchecked") match_mode = :any setoutputpower!(l, 10.0) + @test_logs (:warn, r"photodiode scale is unchecked.*call light_on") match_mode = :any setoutputpower!(l, 10.0) l.pd.scale_checked = true; l.pd.scale_refused = true - @test_logs (:warn, r"photodiode scale is unchecked") match_mode = :any setoutputpower!(l, 10.0) + # N5: light_on refuses until initialize, so the advice is to re-initialize, not to call light_on. + @test_logs (:warn, r"photodiode scale is unchecked.*re-initialize after fixing the setup or recalibrating") match_mode = :any setoutputpower!(l, 10.0) l = readyr() @test_logs setoutputpower!(l, 12.0) # B3: the latched refusal first tries a switch-off when the output may be lit, and still throws. + # N1: the refusal claims no switch-off; the log says whether one was made and whether it worked. l = readyr(); FK.pd_scale[] = 0.5 @test_throws r"calibration reference" light_on(l) empty!(FK.calls) - @test_throws r"not lit again" light_on(l) + refusal = try light_on(l); nothing catch e; e end + @test occursin("not lit again", refusal.msg) && !occursin("switch-off", refusal.msg) && !occursin("still be lit", refusal.msg) @test "LD_DisableOutput" ∉ FK.calls # output off: no switch-off attempt l.properties.is_on = true empty!(FK.calls) - @test_logs (:error, r"output disabled") match_mode = :any @test_throws r"switch-off was just attempted" light_on(l) + @test_logs (:error, r"output disabled") match_mode = :any @test_throws r"not lit again" light_on(l) @test "LD_DisableOutput" ∈ FK.calls && !l.properties.is_on + l.properties.is_on = true; FK.fail!("LD_DisableOutput") + @test_logs (:error, r"may still be ON") match_mode = :any @test_throws r"not lit again" light_on(l) + @test l.properties.is_on + FK.status["LD_DisableOutput"] = 0 FK.pd_scale[] = 1.0 # M2: open-loop initialize confirms open loop. FK.reset!(); FK.bits[] |= FK.CLOSED; FK.open_loop_ignored[] = true From 3c4a2d201c4e4b2cc6948ba9d354d3d196624ed1 Mon Sep 17 00:00:00 2001 From: kalidke Date: Thu, 1 Oct 2026 16:07:19 -0600 Subject: [PATCH 19/20] 0.2.6 CHANGELOG: known issue, stop a threaded getlastframe loop before another task's abort, live, sequence or capture Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ac14c9..af4d1c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -154,6 +154,8 @@ far (below); the rig checks still owed are listed in #73 (the C-867 startup chec - A failed stop or buffer release is not recorded, and the camera is marked stopped; it recovers at the next start. - A sequence or live view that fails to start leaves its buffer allocated until the next start. - If the frame-count query fails, a sequence can return frames without checking that all arrived. + - Stop any threaded `getlastframe` loop before calling `abort`, `live`, `sequence` or `capture` from another task: a + buffer released during its frame wait can crash the process. (Fixed in 0.2.7.) - Laser (`TCubeLaser`): - In power mode, a zero sent right after a failed enable counts as landed, so the next `light_on` may not warn about a stale stored setpoint. The first moments after that enable are still bounded by the programmed clamp. From 3a7c6d078bcb104fc7b5018581cee510fcfa78c4 Mon Sep 17 00:00:00 2001 From: kalidke Date: Thu, 1 Oct 2026 16:07:53 -0600 Subject: [PATCH 20/20] 0.2.6 CHANGELOG: the camera known issues are planned for 0.2.7, not fixed there Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index af4d1c9..5a391b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -149,13 +149,13 @@ far (below); the rig checks still owed are listed in #73 (the C-867 startup chec ### Known issues -- Camera (`DCAM4Camera`), fixed in 0.2.7: +- Camera (`DCAM4Camera`), planned for 0.2.7: - A frame read waiting in `getdata` can stop and release a newer live view's buffer if another task starts one. - A failed stop or buffer release is not recorded, and the camera is marked stopped; it recovers at the next start. - A sequence or live view that fails to start leaves its buffer allocated until the next start. - If the frame-count query fails, a sequence can return frames without checking that all arrived. - Stop any threaded `getlastframe` loop before calling `abort`, `live`, `sequence` or `capture` from another task: a - buffer released during its frame wait can crash the process. (Fixed in 0.2.7.) + buffer released during its frame wait can crash the process. - Laser (`TCubeLaser`): - In power mode, a zero sent right after a failed enable counts as landed, so the next `light_on` may not warn about a stale stored setpoint. The first moments after that enable are still bounded by the programmed clamp.