Skip to content

Commit 5219ad2

Browse files
authored
v1.2.0
version 1.2.0
2 parents 1dcaf50 + 68ad89a commit 5219ad2

File tree

5 files changed

+117
-104
lines changed

5 files changed

+117
-104
lines changed

pyobs_qhyccd/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
from .qhyccdcamera import QHYCCDCamera
1+
from .qhyccdcamera import QHYCCDCamera

pyobs_qhyccd/libqhyccd.pxd

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,4 +103,5 @@ cdef extern from "../lib/usr/local/include/qhyccd.h":
103103
unsigned int CancelQHYCCDExposingAndReadout(libusb_device_handle *handle)
104104
unsigned int GetQHYCCDExposureRemaining(libusb_device_handle *handle)
105105
unsigned int GetQHYCCDCameraStatus(libusb_device_handle *handle, unsigned char *buf);
106-
void SetQHYCCDLogLevel(unsigned int logLevel);
106+
unsigned int ControlQHYCCDTemp(libusb_device_handle *handle, double targettemp);
107+
void SetQHYCCDLogLevel(unsigned int logLevel);

pyobs_qhyccd/qhyccdcamera.py

Lines changed: 105 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -17,20 +17,18 @@
1717
log = logging.getLogger(__name__)
1818

1919

20-
class QHYCCDCamera(BaseCamera, ICamera, IWindow, IBinning, IAbortable):
20+
class QHYCCDCamera(BaseCamera, ICamera, IWindow, IBinning, IAbortable, ICooling):
2121
"""A pyobs module for QHYCCD cameras."""
2222

2323
__module__ = "pyobs_qhyccd"
2424

25-
def __init__(self, **kwargs: Any):
25+
def __init__(self, setpoint: float=-10, **kwargs: Any):
2626
"""Initializes a new QHYCCDCamera.
2727
"""
2828
BaseCamera.__init__(self, **kwargs)
2929

30-
# driver
3130
self._driver: Optional[QHYCCDDriver] = None
32-
33-
# window and binning
31+
self._setpoint = setpoint
3432
self._window = (0, 0, 0, 0)
3533
self._binning = (1, 1)
3634

@@ -39,7 +37,7 @@ async def open(self) -> None:
3937
await BaseCamera.open(self)
4038

4139
# disable logs
42-
set_log_level(0)
40+
set_log_level(0) #TODO:
4341

4442
# get devices
4543
devices = QHYCCDDriver.list_devices()
@@ -72,8 +70,8 @@ async def open(self) -> None:
7270
self._window = self._driver.get_effective_area()
7371

7472
# set cooling
75-
#if self._temp_setpoint is not None:
76-
# await self.set_cooling(True, self._temp_setpoint)
73+
if self._setpoint is not None:
74+
await self.set_cooling(True, self._setpoint)
7775

7876
async def close(self) -> None:
7977
"""Close the module."""
@@ -142,33 +140,24 @@ async def list_binnings(self, **kwargs: Any) -> List[Tuple[int, int]]:
142140
Returns:
143141
List of available binnings as (x, y) tuples.
144142
"""
145-
return [(1, 1), (2, 2), (3, 3), (4, 4)]
146-
147-
async def _expose(self, exposure_time: float, open_shutter: bool, abort_event: asyncio.Event) -> Image:
148-
"""Actually do the exposure, should be implemented by derived classes.
149-
150-
Args:
151-
exposure_time: The requested exposure time in seconds.
152-
open_shutter: Whether to open the shutter.
153-
abort_event: Event that gets triggered when exposure should be aborted.
154-
155-
Returns:
156-
The actual image.
157143

158-
Raises:
159-
GrabImageError: If exposure was not successful.
160-
"""
161-
# check driver
144+
binnings = []
145+
if self._driver.is_control_available(Control.CAM_BIN1X1MODE):
146+
binnings.append((1, 1))
147+
if self._driver.is_control_available(Control.CAM_BIN2X2MODE):
148+
binnings.append((2, 2))
149+
if self._driver.is_control_available(Control.CAM_BIN3X3MODE):
150+
binnings.append((3, 3))
151+
if self._driver.is_control_available(Control.CAM_BIN4X4MODE):
152+
binnings.append((4, 4))
153+
return binnings
154+
155+
async def _prepare_driver_for_exposure(self, exposure_time) -> None:
162156
if self._driver is None:
163157
raise ValueError("No camera driver.")
164-
165-
# set binning
166158
log.info("Set binning to %dx%d.", self._binning[0], self._binning[1])
167159
self._driver.set_bin_mode(*self._binning)
168160

169-
# set window, divide width/height by binning, from libfli:
170-
# "Note that the given lower-right coordinate must take into account the horizontal and
171-
# vertical bin factor settings, but the upper-left coordinate is absolute."
172161
width = int(math.floor(self._window[2]) / self._binning[0])
173162
height = int(math.floor(self._window[3]) / self._binning[1])
174163
log.info(
@@ -181,101 +170,117 @@ async def _expose(self, exposure_time: float, open_shutter: bool, abort_event: a
181170
self._window[1],
182171
)
183172
self._driver.set_resolution(self._window[0], self._window[1], width, height)
173+
self._driver.set_param(Control.CONTROL_EXPOSURE, int(exposure_time * 1000.0 * 1000.0))
184174

185-
# exposure time
186-
self._driver.set_param(Control.CONTROL_EXPOSURE, int(exposure_time * 1000.0))
187-
188-
# get date obs
189-
log.info(
190-
"Starting exposure with %s shutter for %.2f seconds...", "open" if open_shutter else "closed", exposure_time
191-
)
192-
date_obs = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")
193-
194-
# expose
195-
print("before expose", time.time())
196-
self._driver.expose_single_frame()
197-
print("after expose", time.time())
198-
199-
# wait for exposure
200-
if exposure_time > 0.5:
201-
await event_wait(abort_event, exposure_time - 0.5)
202-
#if abort_event.is_set():
203-
# raise
204-
205-
# get image
206-
print("before get", time.time())
207-
img = self._driver.get_single_frame()
208-
#loop = asyncio.get_running_loop()
209-
#img = await loop.run_in_executor(None, self._driver.get_single_frame)
210-
print("after get", time.time())
211-
212-
# wait exposure
213-
await self._wait_exposure(abort_event, exposure_time, open_shutter)
214-
215-
# create FITS image and set header
216-
image = Image(img)
175+
async def _get_image_with_header(self, image_data, date_obs, exposure_time) -> Image:
176+
image = Image(image_data)
217177
image.header["DATE-OBS"] = (date_obs, "Date and time of start of exposure")
218178
image.header["EXPTIME"] = (exposure_time, "Exposure time [s]")
219-
#image.header["DET-TEMP"] = (self._driver.get_temp(FliTemperature.CCD), "CCD temperature [C]")
220-
#image.header["DET-COOL"] = (self._driver.get_cooler_power(), "Cooler power [percent]")
221-
#image.header["DET-TSET"] = (self._temp_setpoint, "Cooler setpoint [C]")
222-
223-
# instrument and detector
224-
#image.header["INSTRUME"] = (self._driver.name, "Name of instrument")
225-
226-
# binning
179+
image.header["DET-TEMP"] = (await self._get_ccd_temperature(), "CCD temperature [C]")
180+
image.header["DET-COOL"] = (await self._get_cooling_power(), "Cooler power [percent]")
181+
image.header["DET-TSET"] = (self._setpoint, "Cooler setpoint [C]")
182+
# image.header["INSTRUME"] = (self._driver.name, "Name of instrument")
227183
image.header["XBINNING"] = image.header["DET-BIN1"] = (self._binning[0], "Binning factor used on X axis")
228184
image.header["YBINNING"] = image.header["DET-BIN2"] = (self._binning[1], "Binning factor used on Y axis")
229-
230-
# window
231185
image.header["XORGSUBF"] = (self._window[0], "Subframe origin on X axis")
232186
image.header["YORGSUBF"] = (self._window[1], "Subframe origin on Y axis")
233-
234-
# statistics
235-
image.header["DATAMIN"] = (float(np.min(img)), "Minimum data value")
236-
image.header["DATAMAX"] = (float(np.max(img)), "Maximum data value")
237-
image.header["DATAMEAN"] = (float(np.mean(img)), "Mean data value")
187+
image.header["DATAMIN"] = (float(np.min(image_data)), "Minimum data value")
188+
image.header["DATAMAX"] = (float(np.max(image_data)), "Maximum data value")
189+
image.header["DATAMEAN"] = (float(np.mean(image_data)), "Mean data value")
238190

239191
# biassec/trimsec
240-
#full = self._driver.get_visible_frame()
241-
#self.set_biassec_trimsec(image.header, *full)
242-
243-
# return FITS image
192+
# full = self._driver.get_visible_frame()
193+
# self.set_biassec_trimsec(image.header, *full)
244194
log.info("Readout finished.")
245195
return image
246196

247-
async def _wait_exposure(self, abort_event: asyncio.Event, exposure_time: float, open_shutter: bool) -> None:
248-
"""Wait for exposure to finish.
197+
async def _expose(self, exposure_time: float, open_shutter: bool, abort_event: asyncio.Event) -> Image:
198+
"""Actually do the exposure, should be implemented by derived classes.
249199
250-
Params:
251-
abort_event: Event that aborts the exposure.
252-
exposure_time: Exp time in sec.
253-
"""
200+
Args:
201+
exposure_time: The requested exposure time in seconds.
202+
open_shutter: Whether to open the shutter.
203+
abort_event: Event that gets triggered when exposure should be aborted.
254204
255-
"""
256-
while True:
257-
# aborted?
258-
if abort_event.is_set():
259-
await self._change_exposure_status(ExposureStatus.IDLE)
260-
raise InterruptedError("Aborted exposure.")
261-
262-
# is exposure finished?
263-
if self._driver.is_exposing():
264-
break
265-
else:
266-
# sleep a little
267-
await asyncio.sleep(0.01)
205+
Returns:
206+
The actual image.
207+
208+
Raises:
209+
GrabImageError: If exposure was not successful.
268210
"""
269211

212+
await self._prepare_driver_for_exposure(exposure_time)
213+
log.info("Starting exposure with %s shutter for %.2f seconds...", "open" if open_shutter else "closed", exposure_time)
214+
date_obs = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")
215+
self._driver.expose_single_frame()
216+
await event_wait(abort_event, exposure_time-0.5)
217+
loop = asyncio.get_running_loop()
218+
image_data = await loop.run_in_executor(None, self._driver.get_single_frame)
219+
return await self._get_image_with_header(image_data, date_obs, exposure_time)
220+
270221
async def _abort_exposure(self) -> None:
271222
"""Abort the running exposure. Should be implemented by derived class.
272-
273223
Raises:
274224
ValueError: If an error occured.
275225
"""
276226
if self._driver is None:
277227
raise ValueError("No camera driver.")
278228
#self._driver.cancel_exposure()
279229

230+
async def _get_cooling_power(self):
231+
return self._driver.get_param(Control.CONTROL_CURPWM) /256 * 100 # TODO:
232+
233+
async def get_cooling(self, **kwargs: Any) -> Tuple[bool, float, float]:
234+
enabled = self._driver.is_control_available(Control.CONTROL_COOLER)
235+
setpoint = self._setpoint
236+
power = await self._get_cooling_power()
237+
return enabled, setpoint, power
238+
239+
async def set_cooling(self, enabled: bool, setpoint: float, **kwargs: Any) -> None:
240+
#if not enabled:
241+
# self._driver.set_param(Control.CONTROL_CURPWM, 0) #TODO: einfach PWM auf 0?
242+
self._setpoint = setpoint
243+
await self._cool_stepwise(setpoint)
244+
245+
async def _wait_for_reaching_temperature(self, target_temperature, wait_step=1):
246+
while await self._get_ccd_temperature() > target_temperature:
247+
print("Current temperature is", await self._get_ccd_temperature(), "Target temperature is", target_temperature)
248+
if await self._cooling_bug_occured():
249+
break
250+
time.sleep(wait_step)
251+
252+
async def _cooling_bug_occured(self):
253+
return (self._driver.get_param(Control.CONTROL_CURPWM) > 250) & (await self._get_ccd_temperature() < 0)
254+
255+
async def _get_ccd_temperature(self):
256+
return self._driver.get_param(Control.CONTROL_CURTEMP)
257+
258+
async def _cool_stepwise(self, target_temperature, temperature_stepwidth=1):
259+
print("Start stepwise cooling to ", target_temperature)
260+
while await self._get_ccd_temperature() - target_temperature > temperature_stepwidth:
261+
intermediate_temperature = await self._get_ccd_temperature() - temperature_stepwidth
262+
print("Set temperature to", intermediate_temperature)
263+
self._driver.set_temperature(intermediate_temperature)
264+
await self._wait_for_reaching_temperature(intermediate_temperature)
265+
if await self._cooling_bug_occured():
266+
await self._handle_cooling_bug(intermediate_temperature)
267+
return
268+
print("Set temperature to", target_temperature)
269+
self._driver.set_temperature(target_temperature)
270+
print("End stepwise cooling to", target_temperature)
271+
272+
async def _handle_cooling_bug(self, original_target_temperature, puffer=5, correction_step=1):
273+
print(f"Setpoint of {original_target_temperature:.2f} °C too low for cooler. Temporarily resetting it to {await self._get_ccd_temperature() + puffer:.2f} °C.")
274+
while await self._cooling_bug_occured():
275+
time.sleep(1)
276+
await self._cool_stepwise(await self._get_ccd_temperature() + puffer)
277+
print("Wait a minute")
278+
time.sleep(60)
279+
self._setpoint = original_target_temperature + correction_step
280+
print("Retry stepwise cooling with new setpoint of", self._setpoint)
281+
await self._cool_stepwise(self._setpoint)
282+
283+
async def get_temperatures(self, **kwargs: Any) -> Dict[str, float]:
284+
return {"CCD": await self._get_ccd_temperature()}
280285

281286
__all__ = ["QHYCCDCamera"]

pyobs_qhyccd/qhyccddriver.pyx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,12 +188,19 @@ cdef class QHYCCDDriver:
188188
cdef unsigned int imagew, imageh, bpp
189189
if GetQHYCCDChipInfo(self._device, &chipw, &chiph, &imagew, &imageh, &pixelw, &pixelh, &bpp) != 0:
190190
raise ValueError('Could not fetch chip info.')
191-
return chipw, chiph, imagew, imageh, pixelw, pixelh, bpp
191+
return chipw, chiph, imagew, imageh, pixelw, pixelh,
192+
193+
def get_param(self, param: Control):
194+
return GetQHYCCDParam(self._device, param.value[0])
192195

193196
def set_param(self, param: Control, value: float):
194197
if SetQHYCCDParam(self._device, param.value[0], value) != 0:
195198
raise ValueError('Could not set parameter %s to %f.' % (param, value))
196199

200+
def set_temperature(self, temperature: float):
201+
if ControlQHYCCDTemp(self._device, temperature) != 0:
202+
raise ValueError("Could not set temperature to %f °C" % temperature)
203+
197204
def set_resolution(self, x, y, width, height):
198205
if SetQHYCCDResolution(self._device, x, y, width, height) != 0:
199206
raise ValueError('Could not set resolution to %dx%d at %d,%d.' % (width, height, x, y))

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "pyobs-qhyccd"
3-
version = "1.1.1"
3+
version = "1.2.0"
44
description = "pyobs module for QHYCCD cameras"
55
authors = ["Tim-Oliver Husser <[email protected]>"]
66
license = "MIT"

0 commit comments

Comments
 (0)