diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f621ec..5a391b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,160 @@ 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-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), +#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. +- **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. 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 + +- `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`. +- `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. +- `TCubeLaser`: every fresh read of the controller (the status word, the current limit, the + potentiometer position, the W/A read-back, `initialize`'s limit read and `tcube_get_current`) + now sends its request twice before reading. The 642 nm rig's TLD001 answers one request behind + (2026-09-29), so a single request could let `light_on`'s current-limit gate pass a + potentiometer raised since the previous read, and the enable then ran above `max_current` + until the setpoint landed. +- `TCubeLaser` power mode: `check_lock` makes its own readings and status requests and refuses + in both directions. Besides a photocurrent above `lock_ratio` times the request, it refuses when + the controller reports its current limit reached (status bit `0x400`) or the photocurrent is + below the request divided by `lock_ratio`; the output is then zeroed and disabled, as for a + suspected lock. It used to read the polled cache and test only the high side, so a loop driven + to the clamp -- by a request the clamp cannot reach, or by a photodiode giving fewer counts per + mW than at calibration -- went unnoticed. +- `PhotodiodeLoop` refuses a `lock_check_s` below 0.1 s (`LOCK_CHECK_MIN_S`): 0 was accepted and + disabled the lock check. A construction passing a smaller value now throws. +- `TCubeLaser`: `measured_current` (and so `loop_status`) accepts the raw reading -32768, which + the Kinesis header defines as -220 mA, instead of throwing. +- `TCubeLaser`: any failure during the calibration-reference re-check (a mismatch, a mode refusal or a + command error) latches until the next `initialize`, and the diode is not re-lit on every retried `light_on`. +- `TCubeLaser`: a safety check that refuses while the output may be on (the stored-limit + check, and in power mode also a mode, TIA-range or clamp change, or an over-range photodiode) now zeroes and disables + the output before it throws, instead of leaving the diode lit in the fault. +- `TCubeLaser` open-loop `initialize` confirms open loop with a fresh status read after + `LD_SetOpenLoopMode`, and refuses if the controller stays in closed loop. +- `TCubeLaser` power mode: `check_lock` also runs the current-limit test (`0x400`) at a zero request. +- `TCubeLaser` power mode: `setoutputpower!` decides on fresh status and photocurrent reads, and + `light_on` and `setoutputpower!` refuse a photodiode range that no longer matches `tia_range`. +- `TCubeLaser`: the header's potentiometer floor, 17.25 mA, no longer gates anything. Open-loop + `initialize` lowers the potentiometer for any `max_current` below the controller's limit, and + power-mode construction no longer refuses a `max_current` under 17.25 mA; the limit the + controller reports decides (the 642 nm rig's unit reads 16.74 mA at the lowest position). +- `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`. +- `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. 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. 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. +- `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 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`; 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 + +- `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. +- `TCubeLaser` timings, against 0.2.5, at the default `lock_check_s` (0.2 s) and `REQUEST_WAIT_S` + (0.1 s; a fresh read now sends its request twice, 0.2 s, where it was 0.1 s): + - a power-mode `light_on` takes about 0.6 s longer (`require_clamp` two fresh reads, +0.2 s; `check_lock` + adds its photocurrent and status reads, +0.4 s); + - a power-mode `setoutputpower!` with the output on takes about 0.8 s longer for a request above zero + and about 0.6 s for a zero request (`require_clamp` +0.2 s, the fresh photocurrent read +0.2 s, + `check_lock` +0.4 s, or +0.2 s at zero); with the output off, about 0.2 s longer; + - an open-loop `light_on` takes about 0.1 s longer (one fresh limit read), and a `setcurrent!` with + the output off about 0.1 s longer (one fresh status read); + - `initialize` takes about 0.8 s longer in power mode (eight fresh reads: status 2, potentiometer 2, limit 3, + W/A 1, for one potentiometer setting; each further setting adds 0.2 s) and about 0.3 s in open loop + (one limit read, more if the potentiometer is lowered, plus 0.2 s for the open-loop confirm); + - the first power-mode `light_on` after `initialize` also runs the calibration-reference re-check: + `REFERENCE_DWELL_S` (0.1 s), three fresh reads (0.6 s) and the setpoint confirm, about 0.7 s plus the confirm. +- `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. + +### 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, the range, velocity and motion-stop checks, the reclaim after a failed close, and the GUI guard, with no hardware. +- A calibration reference for power mode: `ref_current_mA`, `ref_photocurrent_A` and `ref_ratio` + (default 1.5) on `PhotodiodeLoop`, `TCubeLaser` and `SimDiodeLaser` (which stores them and does + not check). With a reference, the first power-mode `light_on` after each `initialize` runs the + diode in open loop at `ref_current_mA` for `REFERENCE_DWELL_S` (0.1 s) before reading the photodiode, + and refuses unless the photocurrent is within a factor `ref_ratio` of `ref_photocurrent_A`; the + output is off after the check either way, and a mismatch refuses every later `light_on` until the + next `initialize`. Without one, that `light_on` warns once that the check is skipped. See `CALIBRATION.md`. + **Every rig that uses power mode should record one** (`ref_current_mA`, `ref_photocurrent_A`), + with its next W/A measurement; `CALIBRATION.md` step 3b says how. + +### Known issues + +- 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. +- 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/Project.toml b/Project.toml index bd83563..790e5d4 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" authors = ["klidke@unm.edu"] [deps] diff --git a/src/hardware_implementations/dcam4_camera/dcam_helpers.jl b/src/hardware_implementations/dcam4_camera/dcam_helpers.jl index 0a9bba2..1a786a1 100644 --- a/src/hardware_implementations/dcam4_camera/dcam_helpers.jl +++ b/src/hardware_implementations/dcam4_camera/dcam_helpers.jl @@ -203,5 +203,122 @@ 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!`. 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" 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 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`. 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 + # 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) + 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; 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. 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; + current::Function = () -> true) + deadline = time() + timeout_ms / 1000 + logged = false + while current() + err, status = dcamcap_status(camera.camera_handle) + if is_failed(err) + 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 + if time() >= deadline + 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/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/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..0a36e12 100644 --- a/src/hardware_implementations/dcam4_camera/interface_methods.jl +++ b/src/hardware_implementations/dcam4_camera/interface_methods.jl @@ -2,67 +2,106 @@ """ CameraInterface.getlastframe(camera::DCAM4Camera) + +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`. A frame that cannot be copied returns `nothing` with `last_error` set. """ 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, cached_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) + err, frame = dcambuf_getframe_err(hdcam, Int32(-1)) + frame === nothing && (camera.last_error = err) + return frame + finally + dcamwait_close(hwait) end - - return framedata end """ CameraInterface.capture(camera::DCAM4Camera) + +`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. A frame that cannot be copied returns `nothing` with `last_error` set. """ function CameraInterface.capture(camera::DCAM4Camera) - # Start a capture + # 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 + # 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) 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 + 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. + 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 """ @@ -78,6 +117,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)) @@ -98,6 +138,11 @@ 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). 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 @@ -108,6 +153,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)) @@ -124,24 +170,22 @@ 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") + gen = camera.capture_generation # this sequence's generation (abort, above, already moved it on) @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) + 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 = 0 - println("Sequence done") end - - - return end @@ -155,53 +199,71 @@ 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) + +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 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) - # 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) - - camera.is_running = false - display(timeout_milisec) - if is_timeout(err) - display(event) - camera.last_error = err - err, hwait = dcamwait_close(hwait) - return + 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 - 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)) + try + if camera.capture_mode == SEQUENCE + # 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 + # 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 + 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 + # 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 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 - 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 end end diff --git a/src/hardware_implementations/dcam4_camera/types.jl b/src/hardware_implementations/dcam4_camera/types.jl index cb64a4d..6a270b6 100644 --- a/src/hardware_implementations/dcam4_camera/types.jl +++ b/src/hardware_implementations/dcam4_camera/types.jl @@ -27,6 +27,8 @@ mutable struct DCAM4Camera <: Camera is_running::Bool camerastate data::Array{UInt16} + readout_s::Float64 + capture_generation::Int end function DCAM4Camera(dev_id::Int = 0; @@ -64,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) + 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/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..61b77c5 100644 --- a/src/hardware_implementations/pi_stage/config_methods.jl +++ b/src/hardware_implementations/pi_stage/config_methods.jl @@ -1,100 +1,160 @@ """ -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 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) #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) - #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 - stage.id = @ccall gcs2path.PI_ConnectUSB(bufferstring::Ptr{UInt8})::Cint + stage.id = PI_ConnectUSB(bufferstring) @info "Device ID: " * string(stage.id) 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) + _waitforready(stage; timeout = REFERENCE_TIMEOUT_S[]) + _waitforreference(stage; timeout = REFERENCE_TIMEOUT_S[]) + + #Find the max and min position of the axes + getrange(stage) == 1 || + error("PI_qTMN/PI_qTMX failed (GCS error $(_pi_geterror(stage))); travel range unknown") + + #Wait for any remaining motion to finish + _waitforstop(stage; timeout = REFERENCE_TIMEOUT_S[]) + + #Set the velocity to `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() end - #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 - ismoving(stage) - end - - #Set velocity to 1 mm/s - success = setvel(stage, stage.velocity) - + 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 = @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) + +""" +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) + +""" +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_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 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,21 +168,23 @@ 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" 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 @@ -133,7 +195,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 +209,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,25 +218,25 @@ 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 + 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 = @ccall gcs2path.PI_qVEL(stage.id::Cint, "1 2"::Ptr{UInt8}, velocity::Ptr{Cdouble})::Cint - - 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/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/interface_methods.jl b/src/hardware_implementations/pi_stage/interface_methods.jl index cdf1aa2..79b5f38 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 @@ -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/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..bb75691 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 @@ -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) - @ccall gcs2path.PI_IsMoving(stage.id::Cint, "1 2"::Ptr{UInt8}, ismoving::Ptr{UInt32})::Cint + 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) - @ccall gcs2path.PI_qTMN(stage.id::Cint, "1 2"::Ptr{UInt8}, minpositions::Ptr{Cdouble})::Cint - 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) - @ccall gcs2path.PI_qTMX(stage.id::Cint, "1 2"::Ptr{UInt8}, maxpositions::Ptr{Cdouble})::Cint - 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_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_implementations/simulated_diode_laser/SimulatedDiodeLaser.jl b/src/hardware_implementations/simulated_diode_laser/SimulatedDiodeLaser.jl index 1636e44..167c78e 100644 --- a/src/hardware_implementations/simulated_diode_laser/SimulatedDiodeLaser.jl +++ b/src/hardware_implementations/simulated_diode_laser/SimulatedDiodeLaser.jl @@ -98,9 +98,10 @@ construction line serves a system and its simulated twin. `mode` defaults to `ConstantCurrent()`. In `ConstantPhotocurrent` mode `wa_calibration`, `tia_range`, `tec_stabilised`, `properties` and `max_current` are required, and the loop keywords `ramp_step_mW`, -`ramp_step_s`, `lock_check_s` and `lock_ratio` are accepted into the -[`PhotodiodeLoop`](@ref). `[limitation]` the simulation neither ramps nor -checks for a loop lock; it only stores them. `threshold_current` defaults to +`ramp_step_s`, `lock_check_s`, `lock_ratio`, `ref_current_mA`, `ref_photocurrent_A` +and `ref_ratio` are accepted into the [`PhotodiodeLoop`](@ref). `[limitation]` the +simulation neither ramps, checks for a loop lock, nor re-checks a calibration +reference; it only stores them. `threshold_current` defaults to 65 mA and, in `ConstantCurrent` mode, `max_current` to 160 mA (the 642 nm diode's numbers); the model fields are documented on the type. @@ -122,6 +123,9 @@ function SimDiodeLaser(; ramp_step_s::Union{Nothing,Real}=nothing, lock_check_s::Union{Nothing,Real}=nothing, lock_ratio::Union{Nothing,Real}=nothing, + ref_current_mA::Union{Nothing,Real}=nothing, + ref_photocurrent_A::Union{Nothing,Real}=nothing, + ref_ratio::Union{Nothing,Real}=nothing, efficiency::Float64=1.2, responsivity::Union{Nothing,Float64}=nothing, responsivity_drift::Float64=0.0, @@ -133,7 +137,8 @@ function SimDiodeLaser(; ) name = "SimDiodeLaser" pd = LightSourceInterface.diode_loop_from_keywords(mode, name; wa_calibration, tia_range, - tec_stabilised, properties, max_current, ramp_step_mW, ramp_step_s, lock_check_s, lock_ratio) + tec_stabilised, properties, max_current, ramp_step_mW, ramp_step_s, lock_check_s, lock_ratio, + ref_current_mA, ref_photocurrent_A, ref_ratio) max_current = something(max_current, 160.0) props = something(properties, LightSourceProperties("mA", 0.0, false, min_current, max_current)) resp = something(responsivity, pd === nothing ? 1 / 224.2 : 1 / pd.wa_calibration) diff --git a/src/hardware_implementations/tcube_laser/CALIBRATION.md b/src/hardware_implementations/tcube_laser/CALIBRATION.md index b187063..4918191 100644 --- a/src/hardware_implementations/tcube_laser/CALIBRATION.md +++ b/src/hardware_implementations/tcube_laser/CALIBRATION.md @@ -8,6 +8,7 @@ your mW into a photocurrent setpoint through two numbers you must supply: | Number | Keyword | What it is | Where it comes from | |---|---|---|---| | W/A factor | `wa_calibration` | optical power at the laser output per amp of monitor photocurrent | measured with a power meter (this document) | +| Calibration reference | `ref_current_mA`, `ref_photocurrent_A` | an open-loop current and the photocurrent in A read at it, re-checked at the first `light_on` after every `initialize` | measured with the W/A factor (step 3b) | | TIA range | `tia_range` | full scale of the photodiode amplifier, in A: `10e-6`, `100e-6`, `1e-3` or `10e-3` | the rear-panel DIP switch on the TLD001; `initialize` refuses to start if the controller reports a different one | The conversion both ways is @@ -31,6 +32,7 @@ and the endpoints of `setlevel!` and the panel slider). | Measurement plane | power meter **before the fibre** (coupling efficiency drifts, so the meter is never read after it) | | Measured by | Ali Kazemi Nasaban Shotorban, recorded in `helpers.jl`, Oct 2024 | | Threshold | ~65 mA | +| Calibration reference | none recorded yet (see step 3b) | Verification, in closed loop with those two factors: power commanded through the formula above versus power measured before the fibre, in mW. Single @@ -63,6 +65,7 @@ laser = TCubeLaser("00000000"; tia_range = 1e-3, # A, the rear-panel DIP switch tec_stabilised = missing, # true / false once known; `missing` is honest until then threshold_current = 65.0, # mA + # ref_current_mA = 90.0, ref_photocurrent_A = , # step 3b; without it light_on warns max_current = 150.0, # mA, your diode's rating: programmed into the controller as the loop's clamp properties = LightSourceProperties("mW", 0.0, false, 1.0, 70.0)) # [1 mW, 70 mW]: the 70 is max_power, your diode's rating @@ -79,11 +82,13 @@ rig's TLD001, serial `64849775`). Facts the driver's docstrings already state are marked with where. - A plain `LD_SetMaxCurrentDigPot` is ignored: adjust mode (`LD_EnableMaxCurrentAdjust`) and a pause before leaving it are needed, 2026-09-28 (driver: `set_digpot!`, `CLAMP_WAIT_S`). -- The header's potentiometer scale (`position * 220 / 255` mA) is wrong for this unit: position 204 gave 160.74 mA and 194 gave 152.43 mA, 2026-09-28 (driver: `DIGPOT_STEP_ESTIMATE_mA`). +- The header's potentiometer scale (`position * 220 / 255` mA) is wrong for this unit: position 204 gave 160.74 mA and 194 gave 152.43 mA, 2026-09-28; the manual gives about 0.7 mA per step (p.28, p.38); position 203 read 159.9 mA, and the limit readback is stable across fresh reads, 2026-09-29. The limit readback is what the driver trusts; `DIGPOT_STEP_ESTIMATE_mA` keeps the header's 220/255 mA, the larger step, only to choose the next position, so moves approach `max_current` from below. - The potentiometer position does not survive a controller power cycle, 2026-09-29; `initialize` re-programs it every time and power-mode `light_on` re-checks it. - The controller ignores a setpoint sent while its output is off and then runs on a stale stored setpoint (on this rig the diode went to its ~160 mA limit); the setpoint read-back is stale with the output off (65530), 2026-09-28 (driver: `send_setpoint`). - The photodiode UNDER-range flag was set at 1 mW (4.5 uA on the 1 mA range) while the loop regulated correctly, 2026-09-29; the driver warns and does not refuse. - The photocurrent reading is signed and `0x8000` means over range (seen at 110 mA open loop on the 1 mA range), 2026-09-28 (driver: `measured_photocurrent`). +- The controller answers one request behind: the first `LD_RequestStatusBits` after an enable returned the pre-enable bits even 0.5 s later; readings behave the same, 2026-09-29 (driver: `request_twice`). +- With no light the photocurrent reads raw 65532, i.e. -4 signed, 2026-09-29. - A setpoint jumped up from 0 can lock the loop at ~90 mA / ~21 mW / ~98 uA whatever is requested: 3 of 3 at 10 mW, then 4 of 4 without the lock after a Kinesis CONST P session; stepped setpoints never failed (10 of 10), 2026-09-28/29 (driver: `PhotodiodeLoop`'s `ramp_step_mW` field, `check_lock`). - One USB write takes about 15 ms, so ramp pauses below ~10 ms do not go faster, 2026-09-29. - The controller must be power-cycled when switching between the Kinesis application and this driver, in either direction (`LD_Open` error 2, or "load device failed" in Kinesis, until then), 2026-09-29. Recorded only here. @@ -161,7 +166,8 @@ plane**. Recalibrate when any of these changes: measure the W/A factor again on the new range rather than assuming it carries over); - the diode, its mount or the photodiode is replaced or realigned; - the plane you want to quote power at changes; -- the verification (step 5) drifts by more than you can accept. +- the verification (step 5) drifts by more than you can accept; +- record a new calibration reference whenever the W/A factor is re-measured. It is also worth re-running step 5 every few months: it takes minutes and tells you whether the old factor still holds. @@ -233,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 @@ -243,13 +249,41 @@ 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 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). + +```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 +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 +the power-mode laser (step 4). The first `light_on` after every `initialize` then drives the +diode in open loop at `ref_current_mA`, reads the photodiode, and refuses unless the reading +is within a factor `ref_ratio` (default 1.5) of `ref_photocurrent_A`, in either direction; the +output is off after the check. A range the reading does not follow, or a `tia_range` +relabelled with W/A kept, fails that check. Re-measure W/A and the reference whenever the +switch moves. + +`[limitation]` the 642 nm rig's words of 2026-09-29 (375 / 1802 / 6172 at 70 / 80 / 110 mA) +are **not** a reference for its W/A factor. That factor dates from Oct 2024, and the gain was +re-optimised on 2026-09-28 (see above). Record the reference at the next W/A verification. + ### 4. Build the laser in closed loop Construct `TCubeLaser(...; mode = ConstantPhotocurrent(), ...)` with the three diff --git a/src/hardware_implementations/tcube_laser/interface_methods.jl b/src/hardware_implementations/tcube_laser/interface_methods.jl index c534938..e5ae099 100644 --- a/src/hardware_implementations/tcube_laser/interface_methods.jl +++ b/src/hardware_implementations/tcube_laser/interface_methods.jl @@ -32,13 +32,34 @@ end """ REQUEST_WAIT_S -Seconds to wait between an `LD_Request*` call and the `LD_Get*` that reads its -answer. The Kinesis getters return a value cached by the DLL, and the request +Seconds to wait between each `LD_Request*` call and the next request or the +`LD_Get*` that reads its answer. The Kinesis getters return a value cached by the DLL, and the request refreshes it asynchronously; 0.1 s is what this driver has always waited. A `Ref` so the test suite can set it to zero. Not hardware-verified. """ const REQUEST_WAIT_S = Ref(0.1) +""" + request_twice(request, name, serialNo) + +Send `request` (an `LD_Request*` wrapper) twice, waiting `REQUEST_WAIT_S` after each, so the +`LD_Get*` that follows returns the controller's state at the first request. The 642 nm rig's +TLD001 answers one request behind (2026-09-29): the first request after a change returned the +state before it, even 0.5 s later, and the next one was current. Every fresh read in this +driver goes through here. + +`[limitation]` whether this driver's polling already hides the lag for the limit and the status +word after a change is not measured (rig check R1); the second request is kept either way, at +`REQUEST_WAIT_S` per read. +""" +function request_twice(request, name::AbstractString, serialNo::AbstractString) + for _ in 1:2 + check_err(request(serialNo), name, serialNo) + sleep(REQUEST_WAIT_S[]) + end + return nothing +end + """ POLL_INTERVAL_MS @@ -50,7 +71,8 @@ constant rather than a keyword: 20 Hz covers the 1-10 Hz a rig logs at. `[limitation]` that `LD_StartPolling` refreshes the reading caches at this period is read from the Kinesis header, not observed on hardware. If it does not, those getters return stale values; `tcube_get_current` issues its own -request and is the fallback. +request and is the fallback. `check_lock` does not depend on it: it makes its own +requests (0.2.6). Whether polling refreshes the readings is rig check R2. """ const POLL_INTERVAL_MS = 50 @@ -255,11 +277,11 @@ Open loop only, run by `initialize` after the controller's limit is recorded: if `max_current` the potentiometer is not touched: a rig that lowered it by hand keeps it. Closed loop programs its clamp in `enter_mode!` and this does nothing. -If `max_current` is below the potentiometer's floor ([`DIGPOT_MIN_mA`](@ref), -about 17.25 mA) no position can clamp to it: it warns and leaves the -potentiometer alone, and `light_on` then refuses while the current limit stored in -the controller is above `max_current` (see [`light_on`](@ref)). The search -runs with `raise = false`, so no position above the starting one is ever set. +If no potentiometer position gives a limit at or below `max_current` (the 642 nm +rig's reads 16.74 mA at the lowest), the search fails and it warns as below; `light_on` +then refuses while the current limit stored in the controller is above `max_current` +(see [`light_on`](@ref)). The search runs with `raise = false`, so no position above +the starting one is ever set. If the search fails -- adjust mode refused, a position that does not read back, or even the lowest position reading above `max_current` -- it warns the same @@ -271,10 +293,6 @@ kept, which is an upper bound for the same reason. """ function lower_open_loop_clamp!(light::TCubeLaser{ConstantCurrent}) light.controller_max_current > light.max_current || return nothing - if light.max_current < DIGPOT_MIN_mA - @warn "TCubeLaser $(light.serialNo): max_current = $(light.max_current) mA is below the lowest limit the controller's potentiometer can be set to (about $(round(DIGPOT_MIN_mA; digits=2)) mA). The potentiometer is left alone: the controller's own limit stays $(light.controller_max_current) mA and max_current is enforced in software only (setcurrent! refuses above it), light_on will refuse until the current limit stored in the controller is at or below max_current." - return nothing - end try light.controller_max_current = program_clamp!(light; raise = false) catch err @@ -308,31 +326,23 @@ const TLD001_TIA_RANGES = (10e-6, 100e-6, 1e-3, 10e-3) """ DIGPOT_MIN_POS, DIGPOT_MAX_POS, DIGPOT_STEP_ESTIMATE_mA -The TLD001's max-current potentiometer: positions 20..255. The Kinesis header -gives its scale as `position * 220 / 255` mA, and **the controller does not -follow it**: on the 642 nm rig's TLD001 (64849775, 2026-09-28) position 204 gave -a limit of 160.74 mA and 194 gave 152.43 mA -- about 0.83 mA per step, where the -header's scale says 176 and 167. So no clamp value is ever computed from a -position. [`program_clamp!`](@ref) reads the controller's own limit after each -setting. The header's step, 220/255 ≈ 0.863 mA, is kept only as an estimate to -choose the next position from: it is larger than the observed step, so -estimated moves fall short and the search approaches the ceiling from one side. +The TLD001's max-current potentiometer: positions 20..255. The manual gives a step of +about 0.863 mA per position (the header's step, 220/255), used only to choose the next +position. The header's scale, `position * 220 / 255` mA, is **not** the limit: the +controller does not follow it. On the 642 nm rig's TLD001 (64849775, 2026-09-28) +position 204 gave a limit of 160.74 mA and 194 gave 152.43 mA -- about 0.83 mA per +step. So no clamp value is ever computed from a position. The estimate is at least the +measured step, so estimated moves fall short and the search approaches `max_current` +from below: moves approach `max_current` from below. Since 0.863 mA is slightly more than +the measured ~0.83 mA/step, an upward search can stop one position short of the highest +position under `max_current`, which errs safe (a lower ceiling). The manual's ~0.7 mA per position (p.28, +p.38) would overshoot upward, so it is not used. [`program_clamp!`](@ref) reads the +controller's own limit after each setting, and that readback decides every position. """ const DIGPOT_MIN_POS = 20 const DIGPOT_MAX_POS = 255 const DIGPOT_STEP_ESTIMATE_mA = 220.0 / 255 -""" - DIGPOT_MIN_mA - -The lowest clamp the header's scale allows, `20 * 220 / 255` = 17.25 mA. A -diode whose ceiling is below it cannot be clamped, so it cannot be built in -power mode; in open loop `initialize` only warns -([`lower_open_loop_clamp!`](@ref)). (In adjust mode the rig's controller reported 16.74 mA at position -20, so this is a conservative floor.) -""" -const DIGPOT_MIN_mA = DIGPOT_MIN_POS * 220.0 / 255 - """ CLAMP_WAIT_S @@ -346,15 +356,13 @@ const CLAMP_WAIT_S = Ref(0.5) "Fresh read of the controller's diode current limit, in mA." function read_limit_mA(light::TCubeLaser) serialNo = light.serialNo - check_err(LD_RequestLaserDiodeMaxCurrentLimit(serialNo), "LD_RequestLaserDiodeMaxCurrentLimit", serialNo) - sleep(REQUEST_WAIT_S[]) + request_twice(LD_RequestLaserDiodeMaxCurrentLimit, "LD_RequestLaserDiodeMaxCurrentLimit", serialNo) return setpoint_current(light, LD_GetLaserDiodeMaxCurrentLimit(serialNo)) end "Fresh read of the potentiometer position." function read_digpot(serialNo::AbstractString) - check_err(LD_RequestMaxCurrentDigPot(serialNo), "LD_RequestMaxCurrentDigPot", serialNo) - sleep(REQUEST_WAIT_S[]) + request_twice(LD_RequestMaxCurrentDigPot, "LD_RequestMaxCurrentDigPot", serialNo) return Int(LD_GetMaxCurrentDigPot(serialNo)) end @@ -389,14 +397,16 @@ end program_clamp!(light::TCubeLaser; raise::Bool=true) Leave the controller's diode current limit at the highest potentiometer position +(or one position below it: see [`DIGPOT_STEP_ESTIMATE_mA`](@ref)) whose limit, **as the controller reports it**, does not exceed `light.max_current`, and return that limit in mA. The search starts from the current position. It moves by the header's step estimate ([`DIGPOT_STEP_ESTIMATE_mA`](@ref)), which is larger than the real step on the rig's controller, so moves fall short and approach the ceiling from one -side. It never needs more than a few settings, and none if the present position -already qualifies. Throws if even the lowest position is above the ceiling, or if +side. It reads the controller's limit after each setting and keeps the best +position under the ceiling and the lowest over it, so it settles in a few +settings, and none if the present position already qualifies. Throws if even the lowest position is above the ceiling, or if it cannot settle. Output must be off (it is, in `initialize`). In `ConstantPhotocurrent` mode `initialize` calls it to program the clamp. In @@ -409,6 +419,10 @@ return. It never raises the potentiometer in that mode. The default, `[limitation]` lowering the open-loop potentiometer is not validated on hardware beyond the 642 nm rig's closed-loop sequence; not yet run on hardware in open loop. + +`[limitation]` that the controller clamps to the limit it reports (manual p.41, p.50; +header :711), and not to the header's position scale, is inferred, not measured (rig +check R3). """ function program_clamp!(light::TCubeLaser; raise::Bool=true) ceiling = light.max_current @@ -500,13 +514,19 @@ end # Verified reads, for the one-off checks in `initialize` and the setters # --------------------------------------------------------------------------- -"Request, wait, then read the status word: a fresh read, not the polled cache." +"Request twice, wait, then read the status word: a fresh read, not the polled cache." function read_status_fresh(serialNo::AbstractString) - check_err(LD_RequestStatusBits(serialNo), "LD_RequestStatusBits", serialNo) - sleep(REQUEST_WAIT_S[]) + request_twice(LD_RequestStatusBits, "LD_RequestStatusBits", serialNo) return UInt32(LD_GetStatusBits(serialNo)) end +"Fresh read of the raw photocurrent word, signed: two `LD_RequestReadings`, then the reading." +function read_photocurrent_word(light::TCubeLaser) + serialNo = light.serialNo + request_twice(LD_RequestReadings, "LD_RequestReadings", serialNo) + return Int(LD_GetPhotoCurrentReading(serialNo)) +end + """ SETPOINT_CONFIRM_TIMEOUT_S, SETPOINT_READBACK_TOLERANCE @@ -517,9 +537,6 @@ codes the read-back may differ by: the rig's TLD001 reported 10424 for 10425. const SETPOINT_CONFIRM_TIMEOUT_S = Ref(1.0) const SETPOINT_READBACK_TOLERANCE = 2 -"Whether the controller reports its output enabled (polled status word)." -output_enabled(serialNo::AbstractString) = UInt32(LD_GetStatusBits(serialNo)) & STATUS_BITS.output_enabled != 0 - """ send_setpoint(light::TCubeLaser, code::UInt16) @@ -600,27 +617,155 @@ end """ check_lock(light::TCubeLaser{ConstantPhotocurrent}, code) -After a setpoint, wait `pd.lock_check_s` and compare the measured photocurrent -([`measured_photocurrent`](@ref)) with the one `code` requests. If `code > 0` -and the measurement exceeds `pd.lock_ratio` times the request, throw: the loop -has probably locked at a high current whatever is requested (the failure the -ramp works around; see [`PhotodiodeLoop`](@ref)). It never disables the output -by itself; its callers do. A no-op in open loop. - -`[limitation]` the threshold and the wait are unvalidated on hardware (one -night's lock measured about 98 uA for 44.6 uA requested, 2.2x) and need the -642 nm rig check. A false trip refuses, which is the safe direction. +After a setpoint, wait `pd.lock_check_s`, then make its own fresh reads: the photocurrent +word ([`read_photocurrent_word`](@ref)), then the status word ([`read_status_fresh`](@ref)), +and compare them with what `code` requests. It throws on the first of these that holds, in +this order: + +1. the measured photocurrent exceeds `pd.lock_ratio` times the request: the loop has + probably locked at a high current whatever is requested (the failure the ramp works + around; see [`PhotodiodeLoop`](@ref)). This is tested before the status read, so a lock + trips about 2 x `REQUEST_WAIT_S` sooner; +2. the status reports the current limit reached (`0x400`): the loop is at the clamp and the + power is not being held; +3. the measured photocurrent is below the request divided by `pd.lock_ratio`: the loop is + not holding its setpoint. + +The test is two-sided because a loop driven to the clamp, by a request the clamp cannot +reach or by a photodiode giving fewer counts per mW than at calibration, reads low. `code` +is the final code its caller confirmed (after any ramp); intermediate ramp steps are not +checked. At `code == 0` it waits `lock_check_s` and runs only the `0x400` test (no +photocurrent read): a zero request has no lock and no deficit, but a controller at its +current limit is still a fault. Its own reads cost about `lock_check_s + 4 x +REQUEST_WAIT_S` (`+ 2 x` at `code == 0`). It never disables the output by itself; its callers do. A no-op in open +loop. + +`[limitation]` the thresholds and the wait are unvalidated on hardware (one night's lock +measured about 98 uA for 44.6 uA requested, 2.2x) and need the 642 nm rig check. A false trip +refuses, which is the safe direction. + +`[limitation]` that `0x400` sets when the *closed* loop saturates is read from the Kinesis +header, not observed. + +`[limitation]` a loop slower than `lock_check_s` to reach 2/3 of an upward step false-trips, +which refuses (the safe direction). """ check_lock(light::TCubeLaser{ConstantCurrent}, code) = nothing function check_lock(light::TCubeLaser{ConstantPhotocurrent}, code) - pd = light.pd + pd, serialNo = light.pd, light.serialNo sleep(pd.lock_check_s) - measured = measured_photocurrent(light) requested = photocurrent_from_code(light, code, pd.tia_range) - (code > 0 && measured > pd.lock_ratio * requested) && error( - "TCubeLaser $(light.serialNo): loop lock suspected: the photodiode reads $(measured) A for a request of $(requested) A " * - "(ratio $(measured / requested), limit $(pd.lock_ratio)). The output should be treated as running away from its setpoint. " * - "Construct the laser with the ramp (ramp_step_mW = 3.0) and try again.") + measured = NaN + if code > 0 + measured = photocurrent_from_raw(light, read_photocurrent_word(light), pd.tia_range) + measured > pd.lock_ratio * requested && error( + "TCubeLaser $(light.serialNo): loop lock suspected: the photodiode reads $(measured) A for a request of $(requested) A " * + "(ratio $(measured / requested), limit $(pd.lock_ratio)). The output should be treated as running away from its setpoint. " * + "Construct the laser with the ramp (ramp_step_mW = 3.0) and try again.") + end + bits = read_status_fresh(serialNo) + bits & STATUS_BITS.saturated != 0 && error( + "TCubeLaser $serialNo: the controller reports its current limit reached (status 0x400) holding a request of $(requested) A" * + "$(code > 0 ? "; the photodiode reads $(measured) A" : " (a zero request)"). The loop is at the clamp and the power is not being held: the request needs more " * + "current than the clamp allows, or the photodiode gives fewer counts per mW than at calibration (a moved DIP switch or " * + "a changed TIA gain; see CALIBRATION.md).") + code > 0 && measured < requested / pd.lock_ratio && error( + "TCubeLaser $serialNo: the photodiode reads $(measured) A for a request of $(requested) A, below 1/$(pd.lock_ratio) " * + "of it after $(pd.lock_check_s) s: the loop is not holding its setpoint. Check the photodiode and the calibration (CALIBRATION.md).") + return nothing +end + +""" + REFERENCE_DWELL_S + +How long the calibration-reference re-check holds the diode at `ref_current_mA` before it reads the photodiode. +Fixed and short, so the reference emission does not grow with `lock_check_s`. The diode current settles far faster. + +`[limitation]` unvalidated on hardware (rig check R2). +""" +const REFERENCE_DWELL_S = 0.1 + +""" + check_scale!(light::TCubeLaser{ConstantPhotocurrent}) + +The calibration-reference re-check: runs at most once per `initialize` +(`pd.scale_checked` and `pd.scale_refused`, both cleared by every `initialize`, successful +or failed), from +[`light_on`](@ref) right after `require_clamp`. A no-op in open loop. + +With no reference (`pd.ref_current_mA` is `NaN`) it logs one `@warn`, sets +`scale_checked` and makes no SDK calls. With one, it checks `ref_current_mA` against +the current ceiling (nothing is sent if that throws), zeroes and disables the output, +enters open loop ([`set_open_loop!`](@ref), which confirms it from a fresh status read +before the enable), enables, sends the setpoint for `ref_current_mA` (after the enable: a +setpoint sent with the output off is ignored), waits [`REFERENCE_DWELL_S`](@ref), reads the +photocurrent, zeroes and disables again, and restores closed loop +([`set_closed_loop!`](@ref)). It then compares the reading, decoded with `pd.tia_range`, +with `pd.ref_photocurrent_A`: it passes iff the reading is within a factor +`pd.ref_ratio` of it, either way. The output is off afterwards, and `light_on` continues +with its own enable. + +Any failure inside the re-check (a `set_open_loop!` refusal, a command error, or a mismatch) +latches the refusal until the next `initialize`: `scale_refused` is set before the check +starts and cleared only on a pass, so every later `light_on` refuses at once, without +lighting the diode again. A command failure is also cleaned up +([`disable_after_failure`](@ref)) and rethrown; a mismatch leaves the output off and +closed loop restored. 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 +correct range change, where the words do follow, passes. + +`[limitation]` the reference emission (open loop at `ref_current_mA` for about +`REFERENCE_DWELL_S + 2 x REQUEST_WAIT_S` plus the setpoint confirm, no longer tied to +`lock_check_s`) happens before the user's request at the first `light_on`. + +`[limitation]` unvalidated on hardware. +""" +check_scale!(light::TCubeLaser{ConstantCurrent}) = nothing +function check_scale!(light::TCubeLaser{ConstantPhotocurrent}) + pd, serialNo = light.pd, light.serialNo + pd.scale_refused && error( + "TCubeLaser $serialNo: light_on refused: the calibration-reference re-check failed since the last initialize (a mismatch, or a command or mode failure during it), " * + "and the diode is not lit again to re-check it. " * + "Fix the setup or recalibrate (CALIBRATION.md), then call initialize.") + pd.scale_checked && return nothing + if isnan(pd.ref_current_mA) + @warn "TCubeLaser $serialNo: no calibration reference (ref_current_mA, ref_photocurrent_A), so the photodiode scale re-check is skipped for this initialize; check_lock's two-sided test is the only guard against a scale change since calibration. See CALIBRATION.md." + pd.scale_checked = true + return nothing + end + pd.scale_refused = true # any failure from here to the pass latches until initialize + check_current(light, pd.ref_current_mA) # within the programmed clamp; nothing is sent if not + word = try + zero_then_disable(light) # the mode command is never sent while the diode is lit + set_open_loop!(light) # LD_SetOpenLoopMode, output off, open loop confirmed before the enable + light.properties.is_on = true + check_err(LD_EnableOutput(serialNo), "LD_EnableOutput", serialNo) + send_setpoint(light, setpoint_code(light, pd.ref_current_mA)) # after the enable: a setpoint sent with the output off is ignored + sleep(REFERENCE_DWELL_S) + w = read_photocurrent_word(light) + zero_then_disable(light) # off, and stored setpoint 0, before the mode command + set_closed_loop!(light) + w + catch + disable_after_failure(light, "the calibration-reference re-check") + rethrow() + end + measured = photocurrent_from_raw(light, word, pd.tia_range) + ref = pd.ref_photocurrent_A + if !(ref / pd.ref_ratio <= measured <= ref * pd.ref_ratio) + error( + "TCubeLaser $serialNo: light_on refused: at the calibration reference, $(pd.ref_current_mA) mA in open loop, the photodiode reads $(measured) A " * + "where $(ref) A was recorded (allowed: within a factor of $(pd.ref_ratio)). The photodiode's counts per mW are not those of the calibration: " * + "a moved DIP switch, a changed TIA gain, a tia_range that no longer matches the amplifier, or a changed diode or photodiode. " * + "Recalibrate and record a new reference (CALIBRATION.md). The output is off.") + end + pd.scale_refused = false + pd.scale_checked = true return nothing end @@ -662,13 +807,12 @@ is lit and `properties.is_on` is false afterwards. Polling starts first because write depends on only refreshes through it (see [`SETPOINT_CONFIRM_TIMEOUT_S`](@ref)). -`ConstantCurrent`: `LD_SetOpenLoopMode`, then the limit read. If the controller's +`ConstantCurrent`: `LD_SetOpenLoopMode`, then a fresh status read that confirms open loop +(it throws if the controller still reports closed loop), then the limit read. If the controller's limit is above `max_current`, the potentiometer is then lowered until it is not ([`lower_open_loop_clamp!`](@ref)) and `controller_max_current` is the limit that results; if it is at or below `max_current` the potentiometer is never -touched, so a limit a rig set lower by hand stays. If `max_current` is below the -potentiometer's floor ([`DIGPOT_MIN_mA`](@ref)) it warns and leaves the -potentiometer alone. If lowering fails, it warns the same way and goes on, so +touched, so a limit a rig set lower by hand stays. If lowering fails, it warns the same way and goes on, so `initialize` never fails here where 0.2.4 did not; `light_on` then refuses while the current limit stored in the controller is above `max_current`. `[limitation]` the open-loop potentiometer lowering is unvalidated on hardware beyond the 642 nm rig's @@ -683,7 +827,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 +845,9 @@ simplified away: factor scales the controller's display only; the driver does its own conversion. +The calibration-reference re-check is **not** run here: `initialize` never emits. +It runs at the first power-mode [`light_on`](@ref) after each `initialize`. + If any step after `LD_Open` fails, the handle is closed before the error propagates -- a half-open controller refuses the next `LD_Open` and so blocks the retry -- and the original error is the one raised. If the @@ -719,6 +869,7 @@ sets status bit `0x4`; the W/A factor reads back. `[limitation]` the second flag of `LD_EnableMaxCurrentAdjust` (always passed `false`) is not verified. """ function initialize(light::TCubeLaser) + reset_loop_state!(light) serialNo = light.serialNo check_err(TLI_BuildDeviceList(), "TLI_BuildDeviceList", serialNo) numdev = TLI_GetDeviceListSize() @@ -738,17 +889,14 @@ function initialize(light::TCubeLaser) rethrow() end enter_mode!(regulation_mode(light), light) - check_err(LD_RequestReadings(serialNo), "LD_RequestReadings", serialNo) # The diode current limit has its OWN request in the Kinesis API, and - # `LD_RequestReadings` does not stand in for it. Reading the limit - # after only the generic request can hand back a stale or never- - # populated cache -- and this value feeds `effective_max_current`, so a - # stale one widens or narrows the ceiling `setcurrent!` enforces. Not + # a generic readings request does not stand in for it. Reading the limit + # without it can hand back a stale or never-populated cache -- and this + # value feeds `effective_max_current`, so a stale one widens or narrows + # the ceiling `setcurrent!` enforces. Not # hardware-verified: reported by the 642 nm rig from the Kinesis header # while building its probe, which will measure whether the two differ. - check_err(LD_RequestLaserDiodeMaxCurrentLimit(serialNo), - "LD_RequestLaserDiodeMaxCurrentLimit", serialNo) - sleep(REQUEST_WAIT_S[]) + request_twice(LD_RequestLaserDiodeMaxCurrentLimit, "LD_RequestLaserDiodeMaxCurrentLimit", serialNo) out = LD_GetLaserDiodeMaxCurrentLimit(serialNo) record_controller_limit!(light, out) lower_open_loop_clamp!(light) @@ -770,13 +918,46 @@ function initialize(light::TCubeLaser) return nothing end -enter_mode!(::ConstantCurrent, light::TCubeLaser) = - check_err(LD_SetOpenLoopMode(light.serialNo), "LD_SetOpenLoopMode", light.serialNo) +# Every initialize, successful or not, starts from no programmed clamp and an un-run reference re-check. +reset_loop_state!(::TCubeLaser{ConstantCurrent}) = nothing +function reset_loop_state!(light::TCubeLaser{ConstantPhotocurrent}) + pd = light.pd + pd.max_current_clamp = NaN + pd.scale_checked = false + pd.scale_refused = false + pd.zero_failed = false + return nothing +end + +enter_mode!(::ConstantCurrent, light::TCubeLaser) = set_open_loop!(light) + +"`LD_SetClosedLoopMode`, then a fresh status read must report closed loop (`0x4`)." +function set_closed_loop!(light::TCubeLaser) + serialNo = light.serialNo + check_err(LD_SetClosedLoopMode(serialNo), "LD_SetClosedLoopMode", serialNo) + read_status_fresh(serialNo) & STATUS_BITS.closed_loop != 0 || error( + "TCubeLaser $serialNo: LD_SetClosedLoopMode returned success but the status word does not report closed loop (0x4)") + return nothing +end + +""" + set_open_loop!(light::TCubeLaser) + +`LD_SetOpenLoopMode`, then a fresh status read must report open loop (`0x4` clear). +Used by open-loop `initialize` and by the reference re-check. +""" +function set_open_loop!(light::TCubeLaser) + serialNo = light.serialNo + check_err(LD_SetOpenLoopMode(serialNo), "LD_SetOpenLoopMode", serialNo) + bits = read_status_fresh(serialNo) + bits & STATUS_BITS.closed_loop == 0 || error( + "TCubeLaser $serialNo: LD_SetOpenLoopMode returned success but the status word still reports closed loop (status 0x$(string(bits; base=16))); the diode is not enabled") + return nothing +end function enter_mode!(::ConstantPhotocurrent, light::TCubeLaser) serialNo, pd = light.serialNo, light.pd name = "TCubeLaser $serialNo" - pd.max_current_clamp = NaN # a failed re-initialize must not leave a stale clamp # 1. key switch and interlock bits = read_status_fresh(serialNo) @@ -797,14 +978,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 +1011,19 @@ the cleanup left: `false` if the disable succeeded, `true` if it failed too off. In `ConstantPhotocurrent` mode the setpoint is ramped from 0 when the laser was built with a finite `ramp_step_mW`, and [`check_lock`](@ref) then runs; a suspected loop lock is a failure after the enable like any other, so the output -is disabled and the error rethrown. +is disabled and the error rethrown. `check_lock` is two-sided: it also refuses when the +controller reports its current limit reached (`0x400`) or the photocurrent is below +1/`lock_ratio` of the request. + +The first power-mode `light_on` after each `initialize` also runs the calibration-reference +re-check ([`check_scale!`](@ref)): with a reference it drives the diode in open loop at +`ref_current_mA` for about `REFERENCE_DWELL_S + 2 x REQUEST_WAIT_S` plus the setpoint +confirm (not tied to `lock_check_s`), reads the photodiode and refuses on a mismatch, +leaving the output off; a mismatch latches until the next `initialize`, and later +`light_on` calls refuse without lighting the diode again. Without a reference it warns +once and carries on. + +A safety check that refuses while the output may be on zeroes and disables it first. `[limitation]` Between the enable and the setpoint the controller runs on its stored setpoint, bounded in hardware only by its current-limit potentiometer; @@ -847,18 +1037,27 @@ other starting point a jump. A `ConstantPhotocurrent` laser refuses until `initialize` has programmed and verified its clamp (`pd.max_current_clamp` is not `NaN`), and re-checks the -controller before it emits: a fresh status read must report closed loop, and a +controller before it emits: a fresh status read must report closed loop and the photodiode range `tia_range` states, and a fresh read of the controller's limit must not exceed `max_current`, or the programmed clamp by more than 0.5 mA, about half a potentiometer step (a controller power cycle can restore the pot). -`[limitation]` those two checks add two request/read round trips (about 2 x -`REQUEST_WAIT_S`) to every closed-loop `light_on` and `setoutputpower!`; -unvalidated on hardware. +`[limitation]` those checks add two fresh reads (about 4 x `REQUEST_WAIT_S`), and +`check_lock` adds `lock_check_s` plus about 4 x `REQUEST_WAIT_S`, to every closed-loop +`light_on` and `setoutputpower!`; unvalidated on hardware. """ function LightSourceInterface.light_on(light::TCubeLaser) require_clamp(regulation_mode(light), light, "light_on") + 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" code = intended_code(light) # On (or unknown) from the moment the enable is sent (Codex C3); a failed @@ -882,7 +1081,7 @@ end # clear with the output off. The only bound on that interval is the current # limit stored in the controller, so it is read fresh before every enable and # the enable is refused if it is above max_current. -function require_clamp(::ConstantCurrent, light::TCubeLaser, op) +function _require_clamp(::ConstantCurrent, light::TCubeLaser, op) limit = read_limit_mA(light) limit > light.max_current && error( "TCubeLaser $(light.serialNo): $op refused: the current limit stored in the controller reads $(limit) mA, above max_current = $(light.max_current) mA. " * @@ -890,7 +1089,7 @@ function require_clamp(::ConstantCurrent, light::TCubeLaser, op) "Lower the controller's current limit (front-panel encoder or software) to max_current or below, or call initialize to lower it.") return nothing end -function require_clamp(::ConstantPhotocurrent, light::TCubeLaser, op) +function _require_clamp(::ConstantPhotocurrent, light::TCubeLaser, op) isnan(light.pd.max_current_clamp) && error( "TCubeLaser $(light.serialNo): $op refused: the max-current clamp has not been programmed and verified. " * "Call initialize first; it is the only real protection in closed loop.") @@ -900,6 +1099,12 @@ function require_clamp(::ConstantPhotocurrent, light::TCubeLaser, op) bits & STATUS_BITS.closed_loop != 0 || error( "TCubeLaser $(light.serialNo): $op refused: the controller is not in closed loop (status 0x$(string(bits; base=16))); " * "was the mode changed on the front panel? Call initialize again.") + reported = LightSourceInterface.tia_range_from_word(bits) + isnan(reported) && error( + "TCubeLaser $(light.serialNo): $op refused: the status word reports no single photodiode range (status 0x$(string(bits; base=16))). Call initialize again.") + isapprox(reported, light.pd.tia_range; rtol=1e-9) || error( + "TCubeLaser $(light.serialNo): $op refused: the controller's photodiode range is $(reported) A but tia_range states $(light.pd.tia_range) A. " * + "Check the rear-panel DIP switch; the calibration is only valid on the range it was measured on.") # The clamp is the highest pot position whose limit is <= max_current, so # one step up already exceeds max_current; half a step (~0.4 mA) of slack # above the recorded clamp catches that drift too. @@ -907,7 +1112,32 @@ function require_clamp(::ConstantPhotocurrent, light::TCubeLaser, op) (limit > light.max_current || limit > light.pd.max_current_clamp + 0.5) && error( "TCubeLaser $(light.serialNo): $op refused: the controller's max-current clamp reads $(limit) mA, above the $(light.pd.max_current_clamp) mA " * "that initialize programmed. The clamp may have been reset by a controller power cycle: call initialize again.") - return nothing + return bits +end + +# Whether the output may be on: the driver's record, else a fresh status read. A failed read counts as on. +function output_may_be_on(light::TCubeLaser) + light.properties.is_on && return true + try + return read_status_fresh(light.serialNo) & STATUS_BITS.output_enabled != 0 + catch + return true + end +end + +""" + require_clamp(mode::RegulationMode, light::TCubeLaser, op) + +The pre-enable safety check of `mode` (`_require_clamp`). A safety check that refuses +while the output may be on zeroes and disables it first, then rethrows (#74 review M1). +""" +function require_clamp(mode::RegulationMode, light::TCubeLaser, op) + try + return _require_clamp(mode, light, op) + catch + output_may_be_on(light) && disable_after_failure(light, "$op (a safety check refused while the output may be on)") + rethrow() + end end """ @@ -964,31 +1194,35 @@ Command the optical power at the laser output, in mW -- the plane where `pd.wa_calibration` was measured. Not the power at the sample. 1. Refuse unless `initialize` programmed the clamp, and re-check the controller: - a fresh status read must report closed loop and a fresh limit read must not + a fresh status read must report closed loop and the photodiode range `tia_range` states, and a fresh limit read must not exceed `max_current`, or the programmed clamp by more than 0.5 mA, about half - a potentiometer step. `[limitation]` this adds two - request/read round trips (about 2 x `REQUEST_WAIT_S`) to every call; - unvalidated on hardware. + a potentiometer step. `[limitation]` this adds two fresh + reads (about 4 x `REQUEST_WAIT_S`), and `check_lock` (step 6) adds `lock_check_s` + plus about 4 x `REQUEST_WAIT_S`, to every call; unvalidated on hardware. 2. [`check_power`](@ref) against `properties.min_power..max_power`. -3. Refuse from the (polled) status word unless it reports closed loop, or if the - photodiode amplifier is over range. An under-range flag with the output on +3. Decide on the fresh status word step 1 returned: refuse unless it reports closed + loop, or if the photodiode amplifier is over range (the status flag, or, with the + output on, a fresh photocurrent word of -32768). An under-range flag with the output on only warns (the resolution is reduced; the loop still regulates, as seen at 1 mW on the 642 nm rig); with the output off it is expected and ignored. 4. Convert: photocurrent `= power_mW / 1000 / wa_calibration` A, encoded by [`photocurrent_code`](@ref), rounding DOWN; above full scale it throws naming the DIP switch. -5. With the output on (the driver recorded it on, or the polled status word +5. With the output on (the driver recorded it on, or the fresh status word reports it; a stale status bit cannot drop the send silently, it is attempted and confirmed or it throws), send and confirm the setpoint ([`send_setpoint_ramped`](@ref), ramping from the code of the previous request, `0` if none); with it off, leave it for `light_on` ([`send_setpoint`](@ref)). 6. With the output on, [`check_lock`](@ref) after sending. A send or confirm - failure, or a suspected lock, zeroes and disables the output, logs, and + failure, a suspected lock, a current limit reached (`0x400`) or a photocurrent + below 1/`lock_ratio` of the request zeroes and disables the output, logs, and rethrows ([`disable_after_failure`](@ref)); `properties.is_on` is `true` afterwards only if the disable failed. The request is not recorded. 7. Record `pd.output_power_requested` and the DECODED `pd.photocurrent_requested`. +A safety check that refuses while the output may be on zeroes and disables it first. + `[limitation]` the lock check's threshold and wait are unvalidated on hardware (see [`check_lock`](@ref)). @@ -997,16 +1231,23 @@ at the output matches is a question for a power meter; see [`indicated_output_power`](@ref) and [`loop_status`](@ref). """ function LightSourceInterface.setoutputpower!(light::TCubeLaser{ConstantPhotocurrent}, power_mW::Float64) - require_clamp(ConstantPhotocurrent(), light, "setoutputpower!") + bits = require_clamp(ConstantPhotocurrent(), light, "setoutputpower!") check_power(light, power_mW) pd, serialNo = light.pd, light.serialNo - bits = UInt32(LD_GetStatusBits(serialNo)) on = light.properties.is_on || bits & STATUS_BITS.output_enabled != 0 - bits & STATUS_BITS.closed_loop != 0 || error( - "TCubeLaser $serialNo: setoutputpower! refused: the controller is not in closed loop (status 0x$(string(bits; base=16))); " * - "was the mode changed on the front panel? Call initialize again.") - (bits & STATUS_BITS.tia_over != 0 || (on && Int(LD_GetPhotoCurrentReading(serialNo)) == PHOTOCURRENT_OVER_RANGE)) && error( - "TCubeLaser $serialNo: setoutputpower! refused: the photodiode amplifier reports OVER range, so the loop's feedback is invalid") + 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 + 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. @@ -1048,6 +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" + note_zero!(light, zeroed) check_err(LD_DisableOutput(serialNo), "LD_DisableOutput", serialNo) light.properties.is_on = false return nothing @@ -1066,6 +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) + note_zero!(light, zeroed) disabled, offerr = false, nothing try status = LD_DisableOutput(serialNo) @@ -1084,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) @@ -1149,12 +1405,10 @@ end The diode drive current the controller reports, in mA: a polled cache read (see [`POLL_INTERVAL_MS`](@ref)), decoded by [`setpoint_current`](@ref). The reading -is signed; a raw value outside ±32767 is a protocol error and throws. +is signed; -32768..32767 represents -220..+220 mA (Kinesis header). """ function LightSourceInterface.measured_current(light::TCubeLaser) raw = Int(LD_GetLaserDiodeCurrentReading(light.serialNo)) - abs(raw) <= SETPOINT_PROTOCOL_MAX || error( - "TCubeLaser $(light.serialNo): diode current reading $(raw) is outside the protocol's ±$(SETPOINT_PROTOCOL_MAX)") return setpoint_current(light, raw) end @@ -1236,8 +1490,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..d9d1940 100644 --- a/src/hardware_interfaces/lightsource_interface/interface_types.jl +++ b/src/hardware_interfaces/lightsource_interface/interface_types.jl @@ -82,6 +82,16 @@ anywhere, and the name would say it did. """ struct ConstantPhotocurrent <: RegulationMode end +""" + LOCK_CHECK_MIN_S + +The shortest `lock_check_s` a [`PhotodiodeLoop`](@ref) accepts, in s: 0.1. The floor gives the +loop time to settle before `check_lock` reads; `check_lock` makes its own reads and does not +depend on polling. Below it the photodiode is read before the loop has answered a new setpoint. At 0, before 0.2.6, the lock check read the polled cache before the loop moved +and could never trip; with the two-sided check it would trip on every upward step. +""" +const LOCK_CHECK_MIN_S = 0.1 + """ PhotodiodeLoop @@ -130,14 +140,38 @@ delivered power drifts while photocurrent is held steady. That is why (68.59 mW). - `ramp_step_s::Float64`: seconds between ramp steps. Default `0.01`. - `lock_check_s::Float64`: seconds the driver waits after sending a setpoint - before it compares the measured photocurrent with the request. Default `0.2`. + before it compares the measured photocurrent with the request. Default `0.2`; + at least [`LOCK_CHECK_MIN_S`](@ref). - `lock_ratio::Float64`: the measured photocurrent may exceed the request by this factor before the driver reports a suspected loop lock. Default `1.5`. `[limitation]` this threshold and wait are unvalidated on hardware: the one lock observed measured about 98 uA for 44.6 uA requested (2.2x). A false trip refuses, which is the safe direction. - -Construct it with the keyword form, which fills the last seven fields. +- `ref_current_mA::Float64`, `ref_photocurrent_A::Float64`: the calibration + reference. The open-loop drive current, and the photocurrent in A the controller + read at it, decoded with the same `tia_range` as the config states, recorded in + the same session and on the same range and gain as `wa_calibration` + (CALIBRATION.md). With one, the first power-mode `light_on` after each + `initialize` re-measures it and refuses on a mismatch. `NaN` (both) means none: + that `light_on` warns once and `check_lock` is the only guard. The reference is + in amps, decoded with `tia_range`, so a range the reading does not follow, or a + `tia_range` relabelled with W/A kept (the 642 nm rig's observation of + 2026-09-29), fails the re-check. Re-measure W/A and the reference whenever the + DIP switch moves. +- `ref_ratio::Float64`: the re-measured photocurrent must be within this factor + of `ref_photocurrent_A`, either way. Default `1.5`: a 10x change in counts is + caught with a wide margin, a scale drop under 1.5x passes, and the reading is + proportional to (I - I_th), so a small margin above threshold is sensitive to + ordinary drift. Choose the reference at least 20 mA above threshold. +- `scale_checked::Bool`: state, `true` once this initialize's re-check passed or + was skipped for want of a reference. +- `scale_refused::Bool`: state, `true` once this initialize's re-check found a mismatch. + Every later power-mode `light_on` refuses without lighting the diode until the next `initialize`. +- `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. """ mutable struct PhotodiodeLoop wa_calibration::Float64 @@ -150,29 +184,50 @@ mutable struct PhotodiodeLoop ramp_step_s::Float64 lock_check_s::Float64 lock_ratio::Float64 + ref_current_mA::Float64 + ref_photocurrent_A::Float64 + ref_ratio::Float64 + scale_checked::Bool + scale_refused::Bool + zero_failed::Bool end """ PhotodiodeLoop(; wa_calibration, tia_range, tec_stabilised, - ramp_step_mW=Inf, ramp_step_s=0.01, lock_check_s=0.2, lock_ratio=1.5) + ramp_step_mW=Inf, ramp_step_s=0.01, lock_check_s=0.2, lock_ratio=1.5, + ref_current_mA=nothing, ref_photocurrent_A=nothing, ref_ratio=1.5) The first three are required, and none has a default: a power-mode laser cannot be built without a measured calibration, a stated amplifier range and an answer -(possibly `missing`) to whether the diode's temperature is stabilised. The last -four are documented on [`PhotodiodeLoop`](@ref). +(possibly `missing`) to whether the diode's temperature is stabilised. The rest +are documented on [`PhotodiodeLoop`](@ref). """ function PhotodiodeLoop(; wa_calibration::Real, tia_range::Real, tec_stabilised::Union{Bool,Missing}, ramp_step_mW::Real=Inf, ramp_step_s::Real=0.01, - lock_check_s::Real=0.2, lock_ratio::Real=1.5) + lock_check_s::Real=0.2, lock_ratio::Real=1.5, + ref_current_mA::Union{Nothing,Real}=nothing, + ref_photocurrent_A::Union{Nothing,Real}=nothing, ref_ratio::Real=1.5) (isfinite(wa_calibration) && wa_calibration > 0) || throw(ArgumentError( "PhotodiodeLoop: wa_calibration is W/A measured at the laser output and must be finite and positive, got $(wa_calibration)")) (isfinite(tia_range) && tia_range > 0) || throw(ArgumentError( "PhotodiodeLoop: tia_range is the photodiode amplifier's full scale in A and must be finite and positive, got $(tia_range)")) ramp_step_mW > 0 || throw(ArgumentError("PhotodiodeLoop: ramp_step_mW must be positive (Inf for no ramp), got $(ramp_step_mW)")) (isfinite(ramp_step_s) && ramp_step_s >= 0) || throw(ArgumentError("PhotodiodeLoop: ramp_step_s must be finite and non-negative, got $(ramp_step_s)")) - (isfinite(lock_check_s) && lock_check_s >= 0) || throw(ArgumentError("PhotodiodeLoop: lock_check_s must be finite and non-negative, got $(lock_check_s)")) + (isfinite(lock_check_s) && lock_check_s >= LOCK_CHECK_MIN_S) || throw(ArgumentError("PhotodiodeLoop: lock_check_s must be finite and at least $(LOCK_CHECK_MIN_S) s, got $(lock_check_s)")) (isfinite(lock_ratio) && lock_ratio > 1) || throw(ArgumentError("PhotodiodeLoop: lock_ratio must be finite and above 1, got $(lock_ratio)")) + (ref_current_mA === nothing) == (ref_photocurrent_A === nothing) || throw(ArgumentError( + "PhotodiodeLoop: ref_current_mA and ref_photocurrent_A are one calibration reference; give both or neither, got ref_current_mA = $(repr(ref_current_mA)), ref_photocurrent_A = $(repr(ref_photocurrent_A))")) + if ref_current_mA !== nothing + (isfinite(ref_current_mA) && ref_current_mA > 0) || throw(ArgumentError( + "PhotodiodeLoop: ref_current_mA is the open-loop drive current of the calibration reference in mA and must be finite and positive, got $(ref_current_mA)")) + (isfinite(ref_photocurrent_A) && 0 < ref_photocurrent_A <= tia_range) || throw(ArgumentError( + "PhotodiodeLoop: ref_photocurrent_A is the photocurrent in A measured at ref_current_mA, decoded with tia_range, and must be finite with 0 < ref_photocurrent_A <= tia_range ($(tia_range) A), got $(ref_photocurrent_A)")) + end + (isfinite(ref_ratio) && ref_ratio > 1) || throw(ArgumentError("PhotodiodeLoop: ref_ratio must be finite and above 1, got $(ref_ratio)")) return PhotodiodeLoop(Float64(wa_calibration), Float64(tia_range), tec_stabilised, NaN, NaN, NaN, - Float64(ramp_step_mW), Float64(ramp_step_s), Float64(lock_check_s), Float64(lock_ratio)) + Float64(ramp_step_mW), Float64(ramp_step_s), Float64(lock_check_s), Float64(lock_ratio), + ref_current_mA === nothing ? NaN : Float64(ref_current_mA), + ref_photocurrent_A === nothing ? NaN : Float64(ref_photocurrent_A), + Float64(ref_ratio), false, false, false) end diff --git a/src/hardware_interfaces/objective_positioner_interface/gui.jl b/src/hardware_interfaces/objective_positioner_interface/gui.jl index 9bfe98f..abee4b8 100644 --- a/src/hardware_interfaces/objective_positioner_interface/gui.jl +++ b/src/hardware_interfaces/objective_positioner_interface/gui.jl @@ -1,12 +1,9 @@ using GLMakie function gui(positioner::Zpositioner) - try - initialize(positioner) - catch - @error "Failed to initialize the positioner. Please check the connection." - return - end + # 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)) diff --git a/src/hardware_interfaces/stage_interface/gui.jl b/src/hardware_interfaces/stage_interface/gui.jl index e186911..9785e19 100644 --- a/src/hardware_interfaces/stage_interface/gui.jl +++ b/src/hardware_interfaces/stage_interface/gui.jl @@ -164,7 +164,7 @@ function gui1d(stage::Stage) stopmotion(stage) end on(control_buttons[3].clicks) do initialize_click - initialize(stage) + MicroscopeControl.gui_initialize(stage, "stage") || return getposition(stage) xposition[] = stage.real_x xtarget[] = stage.targ_x @@ -453,7 +453,7 @@ function gui2d(stage::Stage) stopmotion(stage) end on(control_buttons[3].clicks) do initialize_click - initialize(stage) + MicroscopeControl.gui_initialize(stage, "stage") || return getposition(stage) xposition[], yposition[] = stage.real_x, stage.real_y xtarget[], ytarget[] = stage.targ_x, stage.targ_y @@ -795,7 +795,7 @@ function gui3d(stage::Stage) stopmotion(stage) end on(control_buttons[3].clicks) do initialize_click - initialize(stage) + 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/dcam4_pure.jl b/test/dcam4_pure.jl new file mode 100644 index 0000000..bf61e38 --- /dev/null +++ b/test/dcam4_pure.jl @@ -0,0 +1,58 @@ +# 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, 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, 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 + @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 "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 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/pi_stage.jl b/test/pi_stage.jl new file mode 100644 index 0000000..ee76a00 --- /dev/null +++ b/test/pi_stage.jl @@ -0,0 +1,235 @@ +# 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) + @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 + + @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; a retry reclaims it" 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.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) + 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..c5b1482 --- /dev/null +++ b/test/pi_stage_fake_sdk.jl @@ -0,0 +1,183 @@ +# 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) + +"`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!() + 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 + moving_polls[] = 0 + moving_count[] = 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") + st == 1 && (values[1] = values[2] = 1.0) + return st + end + function PI_IsMoving(ID, axes, values) + st = Main.FakePIStage.status!("PI_IsMoving") + 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") + st == 1 && (values[1] = values[2] = 0.0) + return st + end + function PI_qTMX(ID, axes, values) + st = Main.FakePIStage.status!("PI_qTMX") + st == 1 && (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 893616a..b80fd45 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -11,6 +11,8 @@ include("tcube_fake_sdk.jl") # Likewise for the PI N-472's GCS2 wrappers, so `initialize`/`shutdown` run # against a recorder and never a rig's controller. Top level, before the testsets. include("pi_n472_fake_sdk.jl") +# 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") @@ -132,7 +134,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 +240,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 +362,8 @@ lab_summary("Core") do # The output is zeroed and disabled BEFORE the mode command. @test FakeKinesis.calls[1:7] == ["TLI_BuildDeviceList", "TLI_GetDeviceListSize", "LD_Open", "LD_StartPolling", "LD_SetLaserSetPoint", "LD_DisableOutput", "LD_SetOpenLoopMode"] - @test FakeKinesis.calls[8:9] == ["LD_RequestReadings", "LD_RequestLaserDiodeMaxCurrentLimit"] + @test FakeKinesis.calls[8:10] == ["LD_RequestStatusBits", "LD_RequestStatusBits", "LD_GetStatusBits"] # open loop confirmed by a fresh status read + @test FakeKinesis.calls[11:13] == ["LD_RequestLaserDiodeMaxCurrentLimit", "LD_RequestLaserDiodeMaxCurrentLimit", "LD_GetLaserDiodeMaxCurrentLimit"] @test laser.max_current == 80.0 # survived initialize @test laser.controller_max_current <= 80.0 # lowered, never above the caller's ceiling @test TCube.effective_max_current(laser) == laser.controller_max_current # the lowered limit is now the tighter one @@ -375,13 +379,13 @@ lab_summary("Core") do empty!(FakeKinesis.calls) setcurrent!(laser, 40.0) @test isempty(FakeKinesis.setpoints) - @test FakeKinesis.calls == ["LD_RequestStatusBits", "LD_GetStatusBits"] # a fresh read: the output was recorded off + @test FakeKinesis.calls == ["LD_RequestStatusBits", "LD_RequestStatusBits", "LD_GetStatusBits"] # a fresh read: the output was recorded off @test laser.drive_current == 40.0 # the accepted current, in mA # light_on enables, THEN sends it, and confirms it. empty!(FakeKinesis.calls) light_on(laser) # A fresh read of the stored current limit first (Codex C1): the enable is refused above max_current. - @test FakeKinesis.calls == ["LD_RequestLaserDiodeMaxCurrentLimit", "LD_GetLaserDiodeMaxCurrentLimit", + @test FakeKinesis.calls == ["LD_RequestLaserDiodeMaxCurrentLimit", "LD_RequestLaserDiodeMaxCurrentLimit", "LD_GetLaserDiodeMaxCurrentLimit", "LD_EnableOutput", "LD_SetLaserSetPoint", "LD_GetLaserSetPoint"] @test FakeKinesis.setpoints == [UInt16(5957)] @test FakeKinesis.setpoint_held[] == 5957 @@ -519,26 +523,27 @@ lab_summary("Core") do laser.properties.is_on = true initialize(laser) - clamp_read = "LD_RequestMaxCurrentDigPot", "LD_GetMaxCurrentDigPot" - limit_read = "LD_RequestLaserDiodeMaxCurrentLimit", "LD_GetLaserDiodeMaxCurrentLimit" + clamp_read = "LD_RequestMaxCurrentDigPot", "LD_RequestMaxCurrentDigPot", "LD_GetMaxCurrentDigPot" + limit_read = "LD_RequestLaserDiodeMaxCurrentLimit", "LD_RequestLaserDiodeMaxCurrentLimit", "LD_GetLaserDiodeMaxCurrentLimit" + status_read = "LD_RequestStatusBits", "LD_RequestStatusBits", "LD_GetStatusBits" + pot_set = ("LD_EnableMaxCurrentAdjust", "LD_SetMaxCurrentDigPot", clamp_read..., "LD_EnableMaxCurrentAdjust", limit_read...) @test FakeKinesis.calls == ["TLI_BuildDeviceList", "TLI_GetDeviceListSize", "LD_Open", # polling first: the setpoint read-back only refreshes through it "LD_StartPolling", # the output left on is zeroed, then disabled, before any mode command "LD_SetLaserSetPoint", "LD_DisableOutput", # 1-2: key, interlock and the amplifier range, from a fresh read - "LD_RequestStatusBits", "LD_GetStatusBits", + status_read..., # 3: the clamp. At position 204 the controller reports 160.74 mA, # over the 160 mA ceiling, so it steps to 203 (159.91 mA) and stops. clamp_read..., limit_read..., - "LD_EnableMaxCurrentAdjust", "LD_SetMaxCurrentDigPot", clamp_read..., "LD_EnableMaxCurrentAdjust", - limit_read..., + pot_set..., # 4: closed loop, confirmed from the status word - "LD_SetClosedLoopMode", "LD_RequestStatusBits", "LD_GetStatusBits", + "LD_SetClosedLoopMode", status_read..., # 5: the display calibration, confirmed - "LD_SetWACalibFactor", "LD_RequestWACalibFactor", "LD_GetWACalibFactor", + "LD_SetWACalibFactor", "LD_RequestWACalibFactor", "LD_RequestWACalibFactor", "LD_GetWACalibFactor", # shared tail: the controller's limit - "LD_RequestReadings", limit_read...] + limit_read...] @test "LD_EnableOutput" ∉ FakeKinesis.calls # initialize never emits @test FakeKinesis.bits[] & FakeKinesis.ENABLED == 0 @test laser.properties.is_on == false @@ -704,7 +709,6 @@ lab_summary("Core") do # PREVIOUS request's code: neither 0 nor the stale 65530. laser = ready(; ramp_step_mW=3.0) setoutputpower!(laser, 10.0); light_on(laser) - FakeKinesis.photocurrent_raw[] = 0 prev = code_for(laser, 10.0) step = round(Int, 3.0 / 1000 / 224.2 / 1e-3 * 32767) empty!(FakeKinesis.setpoints) @@ -717,16 +721,326 @@ lab_summary("Core") do @test cp().pd.lock_ratio == 1.5 && cp().pd.ramp_step_mW == Inf end + @testset "fresh reads request twice: the fake answers one request behind (fake SDK)" begin + FK = FakeKinesis + LSI = MicroscopeControl.HardwareInterfaces.LightSourceInterface + ready_cp(; kw...) = (FK.reset!(); FK.limit_follows_pot[] = true; l = cp(; kw...); initialize(l); l) + enabled() = FK.bits[] & FK.ENABLED != 0 + # N1: the fake models fact 2, and readings are signed. + FK.reset!() + TCube.LD_RequestStatusBits("0") + FK.setbits!(FK.ENABLED) + TCube.LD_RequestStatusBits("0") + @test TCube.LD_GetStatusBits("0") & FK.ENABLED == 0 # one behind + TCube.LD_RequestStatusBits("0") + @test TCube.LD_GetStatusBits("0") & FK.ENABLED != 0 + FK.reset!() + @test TCube.read_photocurrent_word(cc()) == FK.PD_DARK_RAW # -4: dark, signed + # N2 (open loop): a pot raised between two `light_on`s makes the second refuse. + FK.reset!(limit_raw = floor(Int, 90 / 220 * 32767)) + l = cc(; max_current=100.0) + initialize(l) + setcurrent!(l, 10.0); light_on(l); light_off(l) + FK.diode_limit_raw[] = 23830 # the pot raised to 160 mA at the front panel + n = count(==("LD_EnableOutput"), FK.calls) + m = length(FK.calls) + @test_throws r"current limit stored in the controller" light_on(l) + @test count(==("LD_EnableOutput"), FK.calls) == n && !enabled() && !l.properties.is_on + @test "LD_DisableOutput" ∉ FK.calls[m+1:end] # M1: a refusal with the output off makes no disable + # M1: clamp drift while lit, open loop: the refusal also turns the diode off. + FK.reset!(limit_raw = floor(Int, 90 / 220 * 32767)) + l = cc(; max_current=100.0) + initialize(l) + setcurrent!(l, 50.0); light_on(l) + @test enabled() && l.properties.is_on + FK.diode_limit_raw[] = 23830 + @test_throws r"current limit stored in the controller" light_on(l) + @test !enabled() && !l.properties.is_on + # N3: the same in power mode. + l = ready_cp() + setoutputpower!(l, 10.0); light_on(l); light_off(l) + FK.digpot[] = 204 + n = count(==("LD_EnableOutput"), FK.calls) + @test_throws "clamp" light_on(l) + @test count(==("LD_EnableOutput"), FK.calls) == n + # N4: `initialize` records a limit that changed just before it. + FK.reset!() + l = cc() + TCube.LD_RequestLaserDiodeMaxCurrentLimit("0") # the cache now holds 160 mA + FK.diode_limit_raw[] = floor(Int, 90 / 220 * 32767) + initialize(l) + @test l.controller_max_current ≈ 90.0 atol = 0.01 + end + + @testset "check_lock is two-sided and makes its own reads (fake SDK)" begin + FK = FakeKinesis + LSI = MicroscopeControl.HardwareInterfaces.LightSourceInterface + ready_cp(; kw...) = (FK.reset!(); FK.limit_follows_pot[] = true; l = cp(; kw...); initialize(l); l) + enabled() = FK.bits[] & FK.ENABLED != 0 + code50(l) = Int(TCube.photocurrent_code(l, 50.0 / 1000 / 224.2)) + # N5: a photodiode scale drop between two `light_on`s trips `check_lock`. + l = ready_cp(); setoutputpower!(l, 50.0); light_on(l) + FK.pd_scale[] = 0.2 + @test_throws r"0x400" light_on(l) + @test !enabled() && !l.properties.is_on + i = findlast(==("LD_DisableOutput"), FK.calls) + @test FK.calls[i-1] == "LD_SetLaserSetPoint" && FK.setpoints[end] == 0 + # N6: the limit reached alone (0x400, photocurrent above 1/lock_ratio of the request). + l = ready_cp(); setoutputpower!(l, 50.0) + FK.pd_scale[] = 0.5 + @test_throws r"0x400" light_on(l) + @test !enabled() + # N7: the low side alone, by ratio. + l = ready_cp(); setoutputpower!(l, 50.0) + FK.photocurrent_raw[] = code50(l) ÷ 2 + @test_throws r"below 1/" light_on(l) + @test !enabled() + # N8: the low side for a dark photodiode. + l = ready_cp(); setoutputpower!(l, 50.0) + FK.photocurrent_raw[] = FK.PD_DARK_RAW + @test_throws r"below 1/" light_on(l) + @test !enabled() + # N9: 1.2x and 1/1.2x of the request pass. + l = ready_cp(); setoutputpower!(l, 50.0) + c = code50(l) + for w in (round(Int, 1.2c), round(Int, c / 1.2)) + FK.photocurrent_raw[] = w + light_on(l) + @test enabled() && l.properties.is_on + light_off(l) + end + # N10: at code 0 `check_lock` reads no photocurrent. + l = ready_cp(); empty!(FK.calls) + @test_logs (:warn, r"no calibration reference") (:warn, r"before any setpoint") match_mode = :any light_on(l) + @test "LD_RequestReadings" ∉ FK.calls[findfirst(==("LD_EnableOutput"), FK.calls):end] + # N11: the floor. + @test_throws ArgumentError PhotodiodeLoop(; wa_calibration=224.2, tia_range=1e-3, tec_stabilised=missing, lock_check_s=0.0) + @test_throws ArgumentError PhotodiodeLoop(; wa_calibration=224.2, tia_range=1e-3, tec_stabilised=missing, lock_check_s=0.099) + @test PhotodiodeLoop(; wa_calibration=224.2, tia_range=1e-3, tec_stabilised=missing, lock_check_s=0.1).lock_check_s == 0.1 + @test_throws ArgumentError cp(; lock_check_s=0.0) + FK.reset!() + end + + @testset "the calibration reference (fake SDK)" begin + FK = FakeKinesis + LSI = MicroscopeControl.HardwareInterfaces.LightSourceInterface + ready_cp(; kw...) = (FK.reset!(); FK.limit_follows_pot[] = true; l = cp(; kw...); initialize(l); l) + enabled() = FK.bits[] & FK.ENABLED != 0 + FK.reset!() + ref90A = FK.pd_word_at(90.0) / 32767 * 1e-3 # the fake's photocurrent at 90 mA, decoded with tia_range = 1 mA + cpr(; kw...) = cp(; ref_current_mA=90.0, ref_photocurrent_A=ref90A, kw...) + readyr(; kw...) = (FK.reset!(); FK.limit_follows_pot[] = true; l = cpr(; kw...); initialize(l); + setoutputpower!(l, 10.0); empty!(FK.calls); empty!(FK.setpoints); empty!(FK.enable_log); l) + guard = ["LD_RequestStatusBits", "LD_RequestStatusBits", "LD_GetStatusBits", + "LD_RequestLaserDiodeMaxCurrentLimit", "LD_RequestLaserDiodeMaxCurrentLimit", "LD_GetLaserDiodeMaxCurrentLimit"] + lock_tail = ["LD_RequestReadings", "LD_RequestReadings", "LD_GetPhotoCurrentReading", "LD_RequestStatusBits", "LD_RequestStatusBits", "LD_GetStatusBits"] + loopkw = (; wa_calibration=224.2, tia_range=1e-3, tec_stabilised=missing) + # N12: construction. + @test_throws ArgumentError cp(; ref_current_mA=90.0) + @test_throws ArgumentError cp(; ref_photocurrent_A=ref90A) + @test_throws ArgumentError PhotodiodeLoop(; loopkw..., ref_current_mA=90.0) + @test_throws ArgumentError PhotodiodeLoop(; loopkw..., ref_photocurrent_A=ref90A) + @test_throws ArgumentError cc(; ref_current_mA=90.0, ref_photocurrent_A=ref90A) + @test_throws r"max_current" cpr(; max_current=80.0) + for bad in (0.0, -1e-6, 2e-3, NaN) + @test_throws ArgumentError cpr(; ref_photocurrent_A=bad) + end + @test_throws ArgumentError cpr(; ref_ratio=1.0) + @test_throws r"max_power" cpr(; ref_photocurrent_A=0.5e-3) # about 112 mW > 70 + @test cpr().pd.ref_ratio == 1.5 && !cpr().pd.scale_checked + @test isnan(cp().pd.ref_current_mA) + @test SimDiodeLaser(; mode=ConstantPhotocurrent(), max_current=160.0, wa_calibration=224.2, tia_range=1e-3, tec_stabilised=missing, + properties=LightSourceProperties("mW", 0.0, false, 1.0, 70.0), + ref_current_mA=90.0, ref_photocurrent_A=ref90A).pd.ref_photocurrent_A == ref90A + # N13: a matching reference: the exact sequence, then closed-loop emission. + l = readyr(); light_on(l) + @test FK.calls == [guard..., "LD_SetLaserSetPoint", "LD_DisableOutput", "LD_SetOpenLoopMode", "LD_RequestStatusBits", "LD_RequestStatusBits", "LD_GetStatusBits", + "LD_EnableOutput", "LD_SetLaserSetPoint", "LD_GetLaserSetPoint", + "LD_RequestReadings", "LD_RequestReadings", "LD_GetPhotoCurrentReading", "LD_SetLaserSetPoint", "LD_DisableOutput", + "LD_SetClosedLoopMode", "LD_RequestStatusBits", "LD_RequestStatusBits", "LD_GetStatusBits", + "LD_EnableOutput", "LD_SetLaserSetPoint", "LD_GetLaserSetPoint", lock_tail...] + @test FK.setpoints == UInt16[0, TCube.setpoint_code(l, 90.0), 0, TCube.photocurrent_code(l, 10.0 / 1000 / 224.2)] + @test length(FK.enable_log) == 2 && last(FK.enable_log) == 0 + @test enabled() && FK.bits[] & FK.CLOSED != 0 && l.properties.is_on && l.pd.scale_checked + # N14: once per initialize. + light_off(l); empty!(FK.calls); light_on(l) + @test "LD_SetOpenLoopMode" ∉ FK.calls + initialize(l); empty!(FK.calls); light_on(l) + @test "LD_SetOpenLoopMode" ∈ FK.calls + # N15: a mismatch low refuses, off and in closed loop, and latches until `initialize`. + l = readyr(); FK.pd_scale[] = 0.5 + @test_throws r"calibration reference" light_on(l) + @test !enabled() && FK.bits[] & FK.CLOSED != 0 && !l.properties.is_on && !l.pd.scale_checked && l.pd.scale_refused + n = count(==("LD_SetOpenLoopMode"), FK.calls) + @test_throws r"not lit again" light_on(l) + @test count(==("LD_SetOpenLoopMode"), FK.calls) == n + FK.pd_scale[] = 1.0; initialize(l); light_on(l) + @test enabled() && l.pd.scale_checked + # N16: a mismatch high refuses too. + l = readyr(); FK.pd_scale[] = 2.0 + @test_throws r"calibration reference" light_on(l) + @test !enabled() + # N17: a command failure during the re-check is cleaned up, and the laser needs `initialize`. + l = readyr(); FK.setpoint_readback[] = UInt16(3) + @test_logs (:error, r"calibration-reference re-check") match_mode = :any @test_throws ErrorException light_on(l) + @test !enabled() && !l.properties.is_on && !l.pd.scale_checked && FK.bits[] & FK.CLOSED == 0 + FK.setpoint_readback[] = nothing + @test_throws "closed loop" light_on(l) + # N18: a reference above the programmed clamp refuses before anything is sent. + l = readyr(; ref_current_mA=159.95) + @test_throws ArgumentError light_on(l) + @test "LD_SetOpenLoopMode" ∉ FK.calls && "LD_EnableOutput" ∉ FK.calls + # 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) + @test "LD_SetOpenLoopMode" ∉ FK.calls + light_off(l) + @test_logs light_on(l) + initialize(l) + @test_logs (:warn, r"no calibration reference") match_mode = :any light_on(l) + # Fact 6: a relabelled tia_range fails the re-check. + FK.reset!(); FK.limit_follows_pot[] = true + FK.bits[] = (FK.bits[] & ~FK.TIA_1mA) | FK.TIA_10mA # the amplifier reports the 10 mA range; its words are unchanged + l = cpr(; tia_range=1e-2) # the reference was recorded on 1 mA; the config now states 10 mA + initialize(l); setoutputpower!(l, 10.0) + @test_throws r"calibration reference" light_on(l) + @test !enabled() && !l.pd.scale_checked + # L7: a range change the words follow passes. + FK.reset!(); FK.limit_follows_pot[] = true + FK.bits[] = (FK.bits[] & ~FK.TIA_1mA) | FK.TIA_10mA + FK.pd_scale[] = 0.1 # ten times the range, a tenth of the words: the photocurrent in amps is the reference's + l = cp(; tia_range=1e-2, ref_current_mA=90.0, ref_photocurrent_A=ref90A) + initialize(l); setoutputpower!(l, 10.0) + light_on(l) + @test enabled() && l.pd.scale_checked && !l.pd.scale_refused + setoutputpower!(l, 10.0) + # L1: the re-check confirms open loop before it enables. + l = readyr(); FK.open_loop_ignored[] = true + @test_throws r"still reports closed loop" light_on(l) + @test "LD_EnableOutput" ∉ FK.calls && !l.properties.is_on && !l.pd.scale_checked && !enabled() + # L2: every initialize starts from a cleared clamp and re-check state, failed or not. + l = readyr(); light_on(l) + @test l.pd.scale_checked + FK.fail!("LD_Open") + @test_throws Exception initialize(l) + @test !l.pd.scale_checked && !l.pd.scale_refused && isnan(l.pd.max_current_clamp) + # L10: a mismatch latches until `initialize`, and the diode is not lit again to re-check it. + l = readyr(); FK.pd_scale[] = 0.2 + @test_throws r"calibration reference" light_on(l) + @test l.pd.scale_refused + empty!(FK.calls) + @test_throws r"not lit again" light_on(l) + @test "LD_EnableOutput" ∉ FK.calls && "LD_SetOpenLoopMode" ∉ FK.calls + FK.pd_scale[] = 1.0; initialize(l); light_on(l) + @test l.pd.scale_checked && !l.pd.scale_refused + # M5: a refusal from set_open_loop! inside the re-check latches too. + l = readyr(); FK.open_loop_ignored[] = true + @test_throws r"still reports closed loop" light_on(l) + @test l.pd.scale_refused + empty!(FK.calls) + @test_throws r"not lit again" light_on(l) + @test "LD_SetOpenLoopMode" ∉ FK.calls && "LD_EnableOutput" ∉ FK.calls + # 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 + # 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.*call light_on") match_mode = :any setoutputpower!(l, 10.0) + l.pd.scale_checked = true; l.pd.scale_refused = true + # 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) + 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"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 + l = cc() + @test_throws r"still reports closed loop" initialize(l) + @test "LD_EnableOutput" ∉ FK.calls && "LD_SetOpenLoopMode" ∈ FK.calls + @test last(FK.calls, 2) == ["LD_StopPolling", "LD_Close"] + # L3: `light_on` checks the photodiode range against the fresh status word. + l = ready_cp(); setoutputpower!(l, 10.0) + FK.bits[] = (FK.bits[] & ~FK.TIA_1mA) | FK.TIA_10mA; empty!(FK.calls) + @test_throws r"photodiode range" light_on(l) + @test "LD_EnableOutput" ∉ FK.calls + # M1: DIP relabel while lit: the refusal zeroes and disables the output. + l = ready_cp(); setoutputpower!(l, 10.0); light_on(l); setoutputpower!(l, 10.0) + @test enabled() && l.properties.is_on + FK.bits[] = (FK.bits[] & ~FK.TIA_1mA) | FK.TIA_10mA; empty!(FK.calls); empty!(FK.setpoints) + @test_throws r"photodiode range" setoutputpower!(l, 10.0) + @test !enabled() && !l.properties.is_on + iD = findlast(==("LD_DisableOutput"), FK.calls) + @test iD !== nothing && findlast(==("LD_SetLaserSetPoint"), FK.calls[1:iD]) !== nothing && last(FK.setpoints) == 0 + # L6: `setoutputpower!` decides on fresh reads: the polled word is healthy, the fresh one says over range. + l = ready_cp(); setoutputpower!(l, 10.0) + @test_logs (:warn, r"no calibration reference") match_mode = :any light_on(l) + FK.poll!(); FK.stale_bits[] = FK.bits[]; FK.setbits!(FK.TIA_OVER) + @test_throws r"OVER range" setoutputpower!(l, 20.0) + # L4: at code 0 `check_lock` still tests the current limit. + l = ready_cp(; properties=LightSourceProperties("mW", 0.0, false, 0.0, 70.0)); setoutputpower!(l, 10.0) + @test_logs (:warn, r"no calibration reference") match_mode = :any light_on(l) + setoutputpower!(l, 0.0) + setoutputpower!(l, 10.0) + FK.force_limit_bit[] = true + @test_throws r"0x400" setoutputpower!(l, 0.0) + # L5: a high-side trip makes no status request after its last photocurrent read. + l = readyr(); light_on(l) + FK.photocurrent_raw[] = 30000; empty!(FK.calls) + @test_throws r"loop lock" setoutputpower!(l, 20.0) + @test "LD_RequestStatusBits" ∉ FK.calls[findlast(==("LD_GetPhotoCurrentReading"), FK.calls):end] + FK.reset!() + end + @testset "setoutputpower! (fake SDK)" begin # Refused before initialize: the clamp is not programmed, and it is # the only real protection in closed loop. - guard = ["LD_RequestStatusBits", "LD_GetStatusBits", - "LD_RequestLaserDiodeMaxCurrentLimit", "LD_GetLaserDiodeMaxCurrentLimit"] + guard = ["LD_RequestStatusBits", "LD_RequestStatusBits", "LD_GetStatusBits", + "LD_RequestLaserDiodeMaxCurrentLimit", "LD_RequestLaserDiodeMaxCurrentLimit", "LD_GetLaserDiodeMaxCurrentLimit"] + lock_tail = ["LD_RequestReadings", "LD_RequestReadings", "LD_GetPhotoCurrentReading", "LD_RequestStatusBits", "LD_RequestStatusBits", "LD_GetStatusBits"] # check_lock's own reads FakeKinesis.reset!() laser = cp() @test_throws "clamp" setoutputpower!(laser, 10.0) @test_throws "clamp" light_on(laser) - @test isempty(FakeKinesis.calls) + @test "LD_EnableOutput" ∉ FakeKinesis.calls && "LD_SetLaserSetPoint" ∉ FakeKinesis.calls # only the may-be-on status read initialize(laser) # Out of the declared [1, 70] mW is refused before anything is sent. @@ -745,7 +1059,7 @@ lab_summary("Core") do setoutputpower!(laser, 50.0) i_pd = 50.0 / 1000 / 224.2 # ... after the two fresh reads `require_clamp` makes before emitting. - @test FakeKinesis.calls == [guard..., "LD_GetStatusBits"] + @test FakeKinesis.calls == [guard...] @test isempty(FakeKinesis.setpoints) @test laser.pd.output_power_requested == 50.0 empty!(FakeKinesis.calls) @@ -754,7 +1068,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 +1080,11 @@ lab_summary("Core") do try empty!(FakeKinesis.calls); empty!(FakeKinesis.setpoints) light_on(laser) - @test FakeKinesis.calls[1:5] == [guard..., "LD_EnableOutput"] - @test FakeKinesis.calls[end] == "LD_GetPhotoCurrentReading" - @test FakeKinesis.calls[end-1] == "LD_GetLaserSetPoint" - @test all(==("LD_SetLaserSetPoint"), FakeKinesis.calls[6:end-2]) + g, t = length(guard), length(lock_tail) + @test FakeKinesis.calls[1:g+1] == [guard..., "LD_EnableOutput"] + @test FakeKinesis.calls[end-t+1:end] == lock_tail + @test FakeKinesis.calls[end-t] == "LD_GetLaserSetPoint" + @test all(==("LD_SetLaserSetPoint"), FakeKinesis.calls[g+2:end-t-1]) @test FakeKinesis.setpoints[end] == code step = round(Int, 3.0 / 1000 / 224.2 / 1e-3 * 32767) @test 15 <= length(FakeKinesis.setpoints) <= 18 @@ -782,8 +1097,8 @@ lab_summary("Core") do # checking the photodiode is not reading 0x8000, over range). empty!(FakeKinesis.calls) setoutputpower!(laser, 50.0) - @test FakeKinesis.calls == [guard..., "LD_GetStatusBits", "LD_GetPhotoCurrentReading", "LD_SetLaserSetPoint", "LD_GetLaserSetPoint", - "LD_GetPhotoCurrentReading"] + @test FakeKinesis.calls == [guard..., "LD_RequestReadings", "LD_RequestReadings", "LD_GetPhotoCurrentReading", "LD_SetLaserSetPoint", "LD_GetLaserSetPoint", + lock_tail...] # Downward steps are sent directly, no ramp. empty!(FakeKinesis.calls); empty!(FakeKinesis.setpoints) setoutputpower!(laser, 10.0) @@ -794,7 +1109,7 @@ lab_summary("Core") do @test_throws "OVER" setoutputpower!(laser, 20.0) @test measured_photocurrent(laser) == Inf @test loop_status(laser).tia_over - FakeKinesis.photocurrent_raw[] = 0 + FakeKinesis.photocurrent_raw[] = nothing light_off(laser) # The DECODED setpoint: never above the request, within one code of it. @test laser.pd.photocurrent_requested == Float64(code) / 32767 * 1e-3 @@ -826,7 +1141,9 @@ lab_summary("Core") do # ... and a controller that left closed loop behind the driver's back. FakeKinesis.setbits!(FakeKinesis.CLOSED; on=false) @test_throws "closed loop" setoutputpower!(laser, 30.0) + @test !laser.properties.is_on && !FakeKinesis.enabled() # M1: the refusal turned the lit output off FakeKinesis.setbits!(FakeKinesis.CLOSED) + light_on(laser) # lit again for the readback test below # A setpoint the controller does not confirm is not recorded. FakeKinesis.setpoint_readback[] = UInt16(3) @test_throws ErrorException setoutputpower!(laser, 30.0) @@ -931,17 +1248,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 +1281,12 @@ lab_summary("Core") do @test_logs (:warn, r"lowering the potentiometer.*software only") match_mode = :any initialize(laser) @test FK.digpot[] == 204 && isempty(FK.digpot_sets) @test TCube.effective_max_current(laser) == 100.0 + # A ceiling the removed 17.25 mA gate refused: on the fake's pot it settles at position 31 (~16.98 mA). + FK.reset!(); FK.limit_follows_pot[] = true + l = cc(; max_current=17.0); initialize(l) + @test l.controller_max_current <= 17.0 + setcurrent!(l, 10.0); light_on(l) + @test l.properties.is_on FK.reset!() end @@ -1033,8 +1357,8 @@ lab_summary("Core") do # The diode current reading is signed: -5957 is -40 mA, not ~440. FakeKinesis.current_raw[] = -5957 @test measured_current(laser) ≈ -40.0 rtol = 1e-3 - FakeKinesis.current_raw[] = 40000 - @test_throws "protocol" measured_current(laser) + FakeKinesis.current_raw[] = -32768 + @test measured_current(laser) == TCube.setpoint_current(laser, -32768) FakeKinesis.current_raw[] = 5957 @test measured_current(laser) == TCube.setpoint_current(laser, 5957) @@ -1524,6 +1848,9 @@ lab_summary("Core") do end include("pi_n472.jl") + include("pi_stage.jl") + + include("dcam4_pure.jl") include("contract.jl") include("skills.jl") diff --git a/test/tcube_fake_sdk.jl b/test/tcube_fake_sdk.jl index 14e6797..f31ee0e 100644 --- a/test/tcube_fake_sdk.jl +++ b/test/tcube_fake_sdk.jl @@ -52,6 +52,19 @@ # verified write from an unverified one. Each piece of state can be made to # misbehave (a pot that does not take, a mode bit that does not set, a # setpoint that reads back wrong) so that every refusal has a test. +# +# Since 0.2.6 the fake answers ONE REQUEST BEHIND, as the 642 nm rig's TLD001 +# does (2026-09-29): a request captures the controller's state, and the getters +# return the state captured at the PREVIOUS request of the same kind, so two +# requests in a row answer the state at the first. This holds for the status +# word, the readings, the current limit, the potentiometer position and the W/A +# factor. The exception is the setpoint read-back, which stays live: the driver +# never requests it and the rig verified that polling refreshes it. The fake +# also models the photodiode on the rig's numbers (threshold 67.6 mA, ~145 +# words per mA above it on the 1 mA range) with a `pd_scale` knob, and sets the +# current-limit status bit (0x400) whenever the model saturates. +# `photocurrent_raw` is an override of the model. `poll!` makes the status word +# and the readings current, as a background poll that had caught up would. """ FakeKinesis @@ -91,7 +104,7 @@ driver's default scale. """ const diode_limit_raw = Ref{Int}(23830) -"Raw limit values the next `LD_GetLaserDiodeMaxCurrentLimit` reads return, first in first out, before `diode_limit_raw` applies again." +"Raw limit values, first in first out, before `diode_limit_raw` applies again: consumed one per capture (each `LD_RequestLaserDiodeMaxCurrentLimit`, or a limit read before any request). A driver read is two requests and answers the first, so give each value twice." const limit_raw_queue = Int[] "A stale polled status word: while set, `LD_GetStatusBits` returns it instead of `bits`, until an `LD_RequestStatusBits` refreshes it (sets it back to `nothing`)." @@ -100,13 +113,33 @@ const stale_bits = Ref{Union{Nothing,UInt32}}(nothing) "Raw diode-current reading; `nothing` returns `diode_limit_raw`, as before 0.2.5." const current_raw = Ref{Union{Nothing,Int}}(nothing) -"Raw photocurrent reading (`LD_GetPhotoCurrentReading`)." -const photocurrent_raw = Ref{Int}(0) +"Raw photocurrent reading (`LD_GetPhotoCurrentReading`): an override of the photodiode model; `nothing` uses the model." +const photocurrent_raw = Ref{Union{Nothing,Int}}(nothing) + +"What each kind's getters return (set by its requests), and the state captured at its last request." +const answered = Dict{Symbol,Any}() +const pending = Dict{Symbol,Any}() + +"The status bit the controller sets when the drive current is at its limit." +const LIMIT = 0x00000400 +"The photocurrent word with no light: raw 65532 read signed (642 nm rig, 2026-09-29)." +const PD_DARK_RAW = -4 +"The 642 nm rig's photodiode (2026-09-29): threshold 67.6 mA, ~145 words per mA above it on the 1 mA range." +const pd_threshold_mA = Ref(67.6) +const pd_words_per_mA = Ref(145.0) +"Counts per unit light relative to calibration: 1.0 is the calibrated setup; 0.5 gives half the words for the same light." +const pd_scale = Ref(1.0) const KEY, CLOSED, INTERLOCK, ENABLED = 0x00000002, 0x00000004, 0x00000008, 0x00000001 const TIA_1mA, TIA_10mA, PSU_OK = 0x00000040, 0x00000080, 0x00001000 const TIA_OVER, TIA_UNDER = 0x00002000, 0x00004000 +"While `true`, `LD_SetOpenLoopMode` records the call and returns success but leaves the closed-loop bit set." +const open_loop_ignored = Ref(false) + +"While `true`, the captured status word has the current-limit bit (`LIMIT`, 0x400) set whatever the model says." +const force_limit_bit = Ref(false) + "The status word: key, interlock, PSU OK and the 1 mA range by default." const bits = Ref{UInt32}(KEY | INTERLOCK | PSU_OK | TIA_1mA) @@ -174,9 +207,16 @@ function reset!(; limit_raw::Integer=23830, stored::Integer=0) empty!(limit_raw_queue) stale_bits[] = nothing current_raw[] = nothing - photocurrent_raw[] = 0 + photocurrent_raw[] = nothing + empty!(answered) + empty!(pending) + pd_scale[] = 1.0 + pd_threshold_mA[] = 67.6 + pd_words_per_mA[] = 145.0 bits[] = KEY | INTERLOCK | PSU_OK | TIA_1mA closed_loop_takes[] = true + open_loop_ignored[] = false + force_limit_bit[] = false setpoint_held[] = stored setpoint_readback[] = nothing digpot[] = 204 @@ -210,6 +250,36 @@ throw!(op::AbstractString, msg::AbstractString="fake Kinesis failure in $op") = "Set or clear status bits." setbits!(mask; on::Bool=true) = (bits[] = on ? (bits[] | UInt32(mask)) : (bits[] & ~UInt32(mask)); nothing) +enabled() = bits[] & ENABLED != 0 +closed() = bits[] & CLOSED != 0 +"The controller's limit in mA, ignoring `limit_raw_queue` (the queue shapes what reads return, not the physics)." +limit_mA_live() = limit_follows_pot[] ? limit_mA_for(digpot[]) : diode_limit_raw[] / 32767 * 220 +setpoint_mA() = setpoint_held[] / 32767 * 220 +"The drive current the closed loop needs to hold the held setpoint word." +needed_mA() = pd_threshold_mA[] + setpoint_held[] / (pd_scale[] * pd_words_per_mA[]) +pd_word_at(mA) = mA <= pd_threshold_mA[] ? PD_DARK_RAW : + round(Int, pd_scale[] * pd_words_per_mA[] * (mA - pd_threshold_mA[])) +saturated_now() = enabled() && (closed() ? needed_mA() > limit_mA_live() : setpoint_mA() > limit_mA_live()) +function photocurrent_now() + photocurrent_raw[] === nothing || return photocurrent_raw[] + enabled() || return PD_DARK_RAW + (closed() && !saturated_now()) && return Int(setpoint_held[]) # the loop holds its setpoint + return pd_word_at(min(closed() ? needed_mA() : setpoint_mA(), limit_mA_live())) +end +function capture(kind::Symbol) + kind === :status && return bits[] | (saturated_now() || force_limit_bit[] ? LIMIT : 0x00000000) + kind === :readings && return (current = something(current_raw[], diode_limit_raw[]), photocurrent = photocurrent_now()) + kind === :limit && return (isempty(limit_raw_queue) ? (limit_follows_pot[] ? + floor(Int, limit_mA_for(digpot[]) / 220 * 32767) : diode_limit_raw[]) : popfirst!(limit_raw_queue)) + kind === :digpot && return digpot[] + kind === :wa && return something(wa_readback[], wa[]) + error("FakeKinesis: unknown kind $kind") +end +request!(kind::Symbol) = (now = capture(kind); answered[kind] = get(pending, kind, now); pending[kind] = now; nothing) +answer(kind::Symbol) = haskey(answered, kind) ? answered[kind] : capture(kind) +"A poll that has caught up: `:status` and `:readings` answer the live state (what the header says polling requests)." +poll!() = (stale_bits[] = nothing; for k in (:status, :readings); now = capture(k); answered[k] = now; pending[k] = now; end; nothing) + end @eval MicroscopeControl.HardwareImplementations.TCubeLaserControl begin @@ -219,7 +289,7 @@ end LD_Close(serialNo) = (Main.FakeKinesis.record!("LD_Close"); nothing) function LD_SetOpenLoopMode(serialNo) s = Main.FakeKinesis.record!("LD_SetOpenLoopMode") - s == 0 && Main.FakeKinesis.setbits!(Main.FakeKinesis.CLOSED; on=false) + (s == 0 && !Main.FakeKinesis.open_loop_ignored[]) && Main.FakeKinesis.setbits!(Main.FakeKinesis.CLOSED; on=false) return s end function LD_SetClosedLoopMode(serialNo) @@ -227,9 +297,9 @@ end (s == 0 && Main.FakeKinesis.closed_loop_takes[]) && Main.FakeKinesis.setbits!(Main.FakeKinesis.CLOSED) return s end - LD_RequestReadings(serialNo) = Main.FakeKinesis.record!("LD_RequestReadings") + LD_RequestReadings(serialNo) = (s = Main.FakeKinesis.record!("LD_RequestReadings"); Main.FakeKinesis.request!(:readings); s) LD_RequestLaserDiodeMaxCurrentLimit(serialNo) = - Main.FakeKinesis.record!("LD_RequestLaserDiodeMaxCurrentLimit") + (s = Main.FakeKinesis.record!("LD_RequestLaserDiodeMaxCurrentLimit"); Main.FakeKinesis.request!(:limit); s) function LD_EnableOutput(serialNo) s = Main.FakeKinesis.record!("LD_EnableOutput") if s == 0 @@ -259,25 +329,25 @@ end end function LD_GetLaserDiodeMaxCurrentLimit(serialNo) Main.FakeKinesis.record!("LD_GetLaserDiodeMaxCurrentLimit") - isempty(Main.FakeKinesis.limit_raw_queue) || return popfirst!(Main.FakeKinesis.limit_raw_queue) - Main.FakeKinesis.limit_follows_pot[] || return Main.FakeKinesis.diode_limit_raw[] - return floor(Int, Main.FakeKinesis.limit_mA_for(Main.FakeKinesis.digpot[]) / 220 * 32767) + return Main.FakeKinesis.answer(:limit) end function LD_GetLaserDiodeCurrentReading(serialNo) Main.FakeKinesis.record!("LD_GetLaserDiodeCurrentReading") - return something(Main.FakeKinesis.current_raw[], Main.FakeKinesis.diode_limit_raw[]) + return Main.FakeKinesis.answer(:readings).current end function LD_GetPhotoCurrentReading(serialNo) Main.FakeKinesis.record!("LD_GetPhotoCurrentReading") - return Main.FakeKinesis.photocurrent_raw[] + return Main.FakeKinesis.answer(:readings).photocurrent end function LD_RequestStatusBits(serialNo) Main.FakeKinesis.stale_bits[] = nothing - return Main.FakeKinesis.record!("LD_RequestStatusBits") + s = Main.FakeKinesis.record!("LD_RequestStatusBits") + Main.FakeKinesis.request!(:status) + return s end function LD_GetStatusBits(serialNo) Main.FakeKinesis.record!("LD_GetStatusBits") - return something(Main.FakeKinesis.stale_bits[], Main.FakeKinesis.bits[]) + return something(Main.FakeKinesis.stale_bits[], Main.FakeKinesis.answer(:status)) end function LD_EnableMaxCurrentAdjust(serialNo, enableAdjust, enableDiode) push!(Main.FakeKinesis.adjust_calls, (enableAdjust, enableDiode)) @@ -289,20 +359,20 @@ end (s == 0 && Main.FakeKinesis.digpot_takes[]) && (Main.FakeKinesis.digpot[] = Int(maxCurrent)) return s end - LD_RequestMaxCurrentDigPot(serialNo) = Main.FakeKinesis.record!("LD_RequestMaxCurrentDigPot") + LD_RequestMaxCurrentDigPot(serialNo) = (s = Main.FakeKinesis.record!("LD_RequestMaxCurrentDigPot"); Main.FakeKinesis.request!(:digpot); s) function LD_GetMaxCurrentDigPot(serialNo) Main.FakeKinesis.record!("LD_GetMaxCurrentDigPot") - return UInt16(Main.FakeKinesis.digpot[]) + return UInt16(Main.FakeKinesis.answer(:digpot)) end function LD_SetWACalibFactor(serialNo, calibFactor) s = Main.FakeKinesis.record!("LD_SetWACalibFactor") s == 0 && (Main.FakeKinesis.wa[] = Float32(calibFactor)) return s end - LD_RequestWACalibFactor(serialNo) = Main.FakeKinesis.record!("LD_RequestWACalibFactor") + LD_RequestWACalibFactor(serialNo) = (s = Main.FakeKinesis.record!("LD_RequestWACalibFactor"); Main.FakeKinesis.request!(:wa); s) function LD_GetWACalibFactor(serialNo) Main.FakeKinesis.record!("LD_GetWACalibFactor") - return something(Main.FakeKinesis.wa_readback[], Main.FakeKinesis.wa[]) + return Main.FakeKinesis.answer(:wa) end function LD_StartPolling(serialNo, milliseconds) Main.FakeKinesis.record!("LD_StartPolling")