API
xapp.py
Go to the documentation of this file.
1 import xconf
2 import numpy as np
3 import warnings
4 from typing import Optional
5 import datetime
6 from astropy.io import fits
7 import os
8 from purepyindi2 import device, properties, constants
9 from purepyindi2.messages import DefNumber, DefSwitch, DefText, DefLight
10 from magaox.indi.device import XDevice, BaseConfig
11 import ImageStreamIOWrap as ISIO
12 import logging
13 import time
14 import sys
15 
16 from .qhyccd import QHYCCDSDK, QHYCCDCamera
17 
18 log = logging.getLogger(__name__)
19 
20 EXTERNAL_RECORDED_PROPERTIES = {
21  'tcsi.catalog.object': 'OBJECT',
22  'tcsi.catdata.ra': None,
23  'tcsi.catdata.dec': None,
24  'tcsi.catdata.epoch': None,
25  'observers.current_observer.full_name': 'OBSERVER',
26  'tcsi.teldata.pa': 'PARANG',
27  'flipacq.presetName.in': None,
28 }
29 
30 RECORDED_WHEELS = ('fwfpm', 'fwlyot')
31 
32 CAMERA_CONNECT_RETRY_SEC = 5
33 
34 def find_active_filter(client, fwname):
35  fwelems = client[f"{fwname}.filterName"]
36  if fwelems is None:
37  return
38  for elem in fwelems:
39  if fwelems[elem] == constants.SwitchState.ON:
40  return elem
41 
42 @xconf.config
43 class VisXConfig(BaseConfig):
44  full_sdk_path : str = xconf.field(default='/usr/local/lib/libqhyccd.so')
45  temp_on_target_pct_diff : float = xconf.field(default=0.05, help="Absolute percent difference between temperature setpoint and currently reported value")
46  startup_temp : float = xconf.field(default=-15, help='Startup temperature of the camera.')
47 class VisX(XDevice):
48  config : VisXConfig
49  # us
50  data_directory : str = "/opt/MagAOX/rawimages/camvisx"
51  exposure_start_ts : float = 0
52  should_cancel : bool = False
53  currently_exposing : bool = False
54  should_begin_exposure : bool = False
55  last_image_filename : Optional[str] = None
56  shmim : ISIO.Image
57  frame : np.ndarray
58  exposure_start_telem : Optional[dict] = None
59  # them
60  sdk : Optional[QHYCCDSDK] = None
61  camera : Optional[QHYCCDCamera] = None
62  exposure_time_sec : Optional[float] = None
63  camera_gain : Optional[int] = None
64  temp_target_deg_c : Optional[float] = None
65  temp_current_deg_c : Optional[float] = None
66 
67  @property
68  def cooling_on_target(self):
69  temp_on_target = abs((self.cameracamera.temperature - self.cameracamera.target_temperature) / self.cameracamera.target_temperature) < self.config.temp_on_target_pct_diff
70  return temp_on_target
71 
72  def emit_telem_stdcam(self):
73  w, h = 9600, 6422
74  self.telem("telem_stdcam", {
75  "roi": {
76  "xcen": (w - 1) / 2,
77  "ycen": (h - 1) / 2,
78  "w": w,
79  "h": h,
80  "xbin": 1,
81  "ybin": 1,
82  },
83  "exptime": self.exposure_time_secexposure_time_sec,
84  "fps": 1/self.exposure_time_secexposure_time_sec,
85  "emGain": self.cameracamera.gain,
86  "adcSpeed": -1,
87  "tempCtrl": {
88  "temp": self.cameracamera.temperature,
89  "setpt": self.cameracamera.target_temperature,
90  "status": True,
91  "ontarget": self.cooling_on_targetcooling_on_target,
92  "statusStr": "LOCKED" if self.cooling_on_targetcooling_on_target else "UNLOCKED",
93  },
94  "shutter": {"statusStr": None, "state": None},
95  "synchro": 0,
96  "vshift": -1,
97  "cropMode": 0
98  })
99 
100  def handle_exptime(self, existing_property, new_message):
101  log.debug(f"In handle_exptime")
102  if self.currently_exposingcurrently_exposing:
103  self.log.debug("Ignoring exposure time change request while currently exposing")
104  elif self.sdksdk is None:
105  self.log.debug("Ignoring exposure time change while we don't have an SDK handle")
106  elif not self.currently_exposingcurrently_exposing and 'target' in new_message and new_message['target'] != existing_property['current']:
107  existing_property['current'] = new_message['target']
108  existing_property['target'] = new_message['target']
109  self.exposure_time_secexposure_time_sec = new_message['target']
110  self.cameracamera.exposure_time = self.exposure_time_secexposure_time_sec
111  self.log.debug(f"Exposure time changed to {new_message['target']} seconds")
112  self.telem('exptime', {'exptime': self.exposure_time_secexposure_time_sec})
113  self.update_property(existing_property)
114 
115  def handle_gain(self, existing_property, new_message):
116  log.debug(f"In handle_gain")
117  if self.currently_exposingcurrently_exposing:
118  self.log.debug("Ignoring gain change request while currently exposing")
119  elif self.sdksdk is None:
120  self.log.debug("Ignoring gain change while we don't have an SDK handle")
121  elif not self.currently_exposingcurrently_exposing and 'target' in new_message and new_message['target'] != existing_property['current']:
122  existing_property['current'] = new_message['target']
123  existing_property['target'] = new_message['target']
124  self.camera_gaincamera_gain = new_message['target']
125  self.cameracamera.gain = self.camera_gaincamera_gain
126  self.log.debug(f"Gain changed to {new_message['target']}")
127  self.telem('emGain', {'emGain': self.camera_gaincamera_gain})
128  self.update_property(existing_property)
129 
130  def handle_expose(self, existing_property, new_message):
131  if self.sdksdk is None:
132  self.log.debug("Ignoring request for exposure while we don't have an SDK handle")
133  elif 'request' in new_message and new_message['request'] is constants.SwitchState.ON:
134  self.log.debug("Exposure requested!")
135  self.should_begin_exposureshould_begin_exposure = True
136  elif 'cancel' in new_message and new_message['cancel'] is constants.SwitchState.ON:
137  self.log.debug("Exposure cancellation requested")
138  self.should_cancelshould_cancel = True
139  self.update_property(existing_property) # ensure the switch turns back off at the client
140 
141  def handle_temp_ccd(self, existing_property, new_message):
142  if self.sdksdk is None:
143  self.log.debug("Ignoring temperature setpoint change while we don't have an SDK handle")
144  elif 'target' in new_message and new_message['target'] != existing_property['current']:
145  existing_property['current'] = new_message['target']
146  existing_property['target'] = new_message['target']
147  self.temp_target_deg_ctemp_target_deg_c = new_message['target']
148  self.log.debug(f"CCD temperature setpoint changed to {self.temp_target_deg_c} deg C")
149  self.telem('tempcontrol', {'temp_target_deg_c': self.temp_target_deg_ctemp_target_deg_c})
150  self.update_property(existing_property)
151 
152  def _init_camera(self):
153  # Load SDK
154  self.sdksdk = QHYCCDSDK(dll_path=self.config.full_sdk_path)
155  if self.sdksdk.number_of_cameras < 1:
156  del self.sdksdk
157  return False
158  # Find camera
159  self.cameracamera = QHYCCDCamera(self.sdksdk, 0)
160  self.exposure_time_secexposure_time_sec = self.cameracamera.exposure_time
161  self.temp_target_deg_ctemp_target_deg_c = self.config.startup_temp
162  self.camera_gaincamera_gain = self.cameracamera.gain
163  return True
164 
165  def _init_properties(self):
166  fsmstate = properties.TextVector(
167  name='fsm',
168  )
169  fsmstate.add_element(DefText(name="state", _value="NODEVICE"))
170  self.add_property(fsmstate)
171 
172  tv = properties.TextVector(
173  name='last_frame',
174  )
175  tv.add_element(DefText(name="filename", _value=None))
176  self.add_property(tv)
177  sv = properties.SwitchVector(
178  name='expose',
179  rule=constants.SwitchRule.ONE_OF_MANY,
180  perm=constants.PropertyPerm.READ_WRITE,
181  )
182  sv.add_element(DefSwitch(name="request", _value=constants.SwitchState.OFF))
183  sv.add_element(DefSwitch(name="cancel", _value=constants.SwitchState.OFF))
184  self.add_property(sv, callback=self.handle_exposehandle_expose)
185 
186  nv = properties.NumberVector(name='exptime', perm=constants.PropertyPerm.READ_WRITE)
187  nv.add_element(DefNumber(
188  name='current', label='Exposure time (sec)', format='%3.1f',
189  min=0, max=1_000_000, step=0.001, _value=self.exposure_time_secexposure_time_sec
190  ))
191  nv.add_element(DefNumber(
192  name='target', label='Requested exposure time (sec)', format='%3.1f',
193  min=0, max=1_000_000, step=0.001, _value=self.exposure_time_secexposure_time_sec
194  ))
195  self.add_property(nv, callback=self.handle_exptimehandle_exptime)
196 
197  nv = properties.NumberVector(name='gain', perm=constants.PropertyPerm.READ_WRITE)
198  nv.add_element(DefNumber(
199  name='current', label='Gain', format='%d',
200  min=0, max=100, step=1, _value=self.camera_gaincamera_gain
201  ))
202  nv.add_element(DefNumber(
203  name='target', label='Requested gain', format='%d',
204  min=0, max=100, step=1, _value=self.camera_gaincamera_gain
205  ))
206  self.add_property(nv, callback=self.handle_gainhandle_gain)
207 
208  nv = properties.NumberVector(name='temp_ccd', perm=constants.PropertyPerm.READ_WRITE)
209  nv.add_element(DefNumber(
210  name='current', label='Current temperature (deg C)', format='%3.3f',
211  min=-100, max=500, step=0.1, _value=self.temp_target_deg_ctemp_target_deg_c
212  ))
213  nv.add_element(DefNumber(
214  name='target', label='Requested temperature (deg C)', format='%3.3f',
215  min=-100, max=500, step=0.1, _value=self.temp_target_deg_ctemp_target_deg_c
216  ))
217  self.add_property(nv, callback=self.handle_temp_ccdhandle_temp_ccd)
218 
219  nv = properties.NumberVector(name='current_exposure')
220  nv.add_element(DefNumber(
221  name='remaining_sec', label='Time remaining (sec)', format='%3.3f',
222  min=0, max=1_000_000, step=1, _value=0.0
223  ))
224  nv.add_element(DefNumber(
225  name='remaining_pct', label='Percentage remaining', format='%i',
226  min=0, max=100, step=0.1, _value=0.0
227  ))
228  self.add_property(nv)
229 
231  devices = set()
232  for prop in EXTERNAL_RECORDED_PROPERTIES:
233  device = prop.split('.')[0]
234  devices.add(device)
235  self.log.debug(f"subscribe to device: {device}")
236  for fw in RECORDED_WHEELS:
237  devices.add(fw)
238  try:
239  self.client.get_properties_and_wait(devices)
240  except TimeoutError as e:
241  log.warning(f"Timed out waiting to get properties from external INDI devices: {e}")
242 
243  def setup(self):
244  os.makedirs(self.data_directory, exist_ok=True)
245  while self.client.status is not constants.ConnectionStatus.CONNECTED:
246  self.log.info(f"Connecting to INDI as a client to get {list(EXTERNAL_RECORDED_PROPERTIES.keys())} and {RECORDED_WHEELS}...")
247  time.sleep(1)
248  self.log.info(f"INDI client connection: {self.client.status}")
249  self.subscribe_to_other_devicessubscribe_to_other_devices()
250 
251  self._init_properties_init_properties()
252  self.properties['fsm']['state'] = 'NOTCONNECTED'
253  self.log.debug("Set FSM prop")
254  self.update_property(self.properties['fsm'])
255  self.log.debug("Sent FSM prop")
256  self.log.info("Set up complete")
257 
259  if self.cameracamera is None:
260  return
261  self.temp_target_deg_ctemp_target_deg_c = self.cameracamera.target_temperature
262  self.temp_current_deg_ctemp_current_deg_c = self.cameracamera.temperature
263  self.exposure_time_secexposure_time_sec = self.cameracamera.exposure_time
264  self.camera_gaincamera_gain = self.cameracamera.gain
265  self.log.debug(f"Read from camera: target = {self.temp_target_deg_c} deg C, current = {self.temp_current_deg_c} deg C, exptime = {self.exposure_time_sec} s, gain = {self.camera_gain}")
266 
268  now = time.time()
269  current = self.properties['current_exposure']
270  if self.currently_exposingcurrently_exposing:
271  remaining_sec = max((self.exposure_start_tsexposure_start_ts + self.exposure_time_secexposure_time_sec) - now, 0)
272  remaining_pct = 100 * remaining_sec / self.exposure_time_secexposure_time_sec
273  self.properties['fsm']['state'] = 'OPERATING'
274  self.update_property(self.properties['fsm'])
275  else:
276  remaining_sec = 0
277  remaining_pct = 0
278  self.properties['fsm']['state'] = 'READY'
279  self.update_property(self.properties['fsm'])
280  if remaining_sec != current['remaining_sec']:
281  current['remaining_sec'] = remaining_sec
282  current['remaining_pct'] = remaining_pct
283  self.update_property(current)
284 
285  self.update_from_cameraupdate_from_camera()
286 
287  self.properties['temp_ccd']['current'] = self.temp_current_deg_ctemp_current_deg_c
288  self.properties['temp_ccd']['target'] = self.temp_target_deg_ctemp_target_deg_c
289  self.update_property(self.properties['temp_ccd'])
290 
291  self.properties['exptime']['current'] = self.exposure_time_secexposure_time_sec
292  self.properties['exptime']['target'] = self.exposure_time_secexposure_time_sec
293  self.update_property(self.properties['exptime'])
294 
295  self.properties['gain']['current'] = self.camera_gaincamera_gain
296  self.properties['gain']['target'] = self.camera_gaincamera_gain
297  self.update_property(self.properties['gain'])
298 
299 
301  '''User code must close the loop on temperature control'''
302  if self.cameracamera is None:
303  return
304  self.cameracamera.target_temperature = self.temp_target_deg_ctemp_target_deg_c
305 
306  def _gather_metadata(self):
307  meta = {
308  'CCDTEMP': self.cameracamera.temperature,
309  'CCDSETP': self.cameracamera.target_temperature,
310  'GAIN': self.cameracamera.gain,
311  'EXPTIME': self.cameracamera.exposure_time,
312  }
313  for indi_prop in EXTERNAL_RECORDED_PROPERTIES:
314  if EXTERNAL_RECORDED_PROPERTIES[indi_prop] is None:
315  new_kw = indi_prop.upper().replace('.', ' ')
316  else:
317  new_kw = EXTERNAL_RECORDED_PROPERTIES[indi_prop]
318  #value = self.client.get(indi_prop)
319  value = self.client[indi_prop]
320  if hasattr(value, 'value'):
321  value = value.value
322  meta[new_kw] = value
323  for fwname in RECORDED_WHEELS:
324  meta[f"{fwname.upper()} PRESET NAME"] = find_active_filter(self.client, fwname)
325  return meta
326 
327  def begin_exposure(self):
328  self.currently_exposingcurrently_exposing = True
329  self.should_begin_exposureshould_begin_exposure = False
330  self.exposure_start_tsexposure_start_ts = time.time()
331  self.exposure_start_telemexposure_start_telem = self._gather_metadata_gather_metadata()
332  self.cameracamera.start_exposure()
333  self.log.debug("Asking camera to begin exposure")
334 
335  def finalize_exposure(self, actual_exptime_sec=None):
336  img = self.cameracamera.readout()
337  # Create FITS structure
338  hdul = fits.HDUList([
339  fits.PrimaryHDU(img)
340  ])
341  # Populate headers
342  meta = self._gather_metadata_gather_metadata()
343  self.log.debug(f"{meta=}")
344  meta['DATE-OBS'] = datetime.datetime.fromtimestamp(self.exposure_start_tsexposure_start_ts).isoformat()
345  exposure_time = self.cameracamera.exposure_time if actual_exptime_sec is None else actual_exptime_sec
346  meta['DATE-END'] = datetime.datetime.fromtimestamp(self.exposure_start_tsexposure_start_ts + exposure_time).isoformat()
347  meta['DATE'] = datetime.datetime.utcnow().isoformat()
348  for key in self.exposure_start_telemexposure_start_telem:
349  meta[f"BEGIN {key}"] = self.exposure_start_telemexposure_start_telem[key]
350  meta['INSTRUME'] = 'MagAO-X'
351  meta['CAMERA'] = 'VIS-X'
352  meta['TELESCOP'] = "Magellan Clay, Las Campanas Obs."
353  with warnings.catch_warnings():
354  warnings.simplefilter('ignore')
355  hdul[0].header.update(meta)
356  # Note if exposure was canceled
357  if actual_exptime_sec is not None:
358  hdul[0].header['CANCELD'] = True
359  # Write to /data path
360  timestamp = datetime.datetime.utcnow().strftime("%Y-%m-%dT%H%M%S")
361  self.last_image_filenamelast_image_filename = f"camvisx_{timestamp}.fits"
362  outpath = f"{self.data_directory}/{self.last_image_filename}"
363  self.log.info(f"Saving to {outpath}")
364  try:
365  hdul.writeto(outpath)
366  except Exception:
367  self.log.exception(f"Unable to save frame!")
368  self.currently_exposingcurrently_exposing = False
369 
370  def cancel_exposure(self):
371  self.currently_exposingcurrently_exposing = False
372  self.should_cancelshould_cancel = False
373  self.log.debug("Asking camera to cancel exposure")
374  # TODO actually cancel
375  actual_exptime_sec = time.time() - self.exposure_start_tsexposure_start_ts
376  self.finalize_exposurefinalize_exposure(actual_exptime_sec=actual_exptime_sec)
377 
378  def loop(self):
379  if self.client.interested_properties_missing:
380  self.subscribe_to_other_devicessubscribe_to_other_devices()
381  self.log.debug(f"Repeating subscription because some external devices we use for headers are not showing up")
382 
383  if self.sdksdk is None:
384  self.log.debug("Initializing camera SDK...")
385  success = self._init_camera_init_camera()
386  if not success:
387  self.log.debug("No camera found yet, retrying on next loop")
388  return
389  self.log.debug(f"Have camera: {self.camera}")
390  self.properties['fsm']['state'] = 'CONNECTED'
391  self.update_property(self.properties['fsm'])
392 
393  now = time.time()
394  if self.should_cancelshould_cancel:
395  self.cancel_exposurecancel_exposure()
396  elif not self.currently_exposingcurrently_exposing and self.should_begin_exposureshould_begin_exposure:
397  self.begin_exposurebegin_exposure()
398  elif self.currently_exposingcurrently_exposing and now > (self.exposure_time_secexposure_time_sec + self.exposure_start_tsexposure_start_ts):
399  self.currently_exposingcurrently_exposing = False
400  self.log.debug("Exposure finished")
401  self.finalize_exposurefinalize_exposure()
402  self.maintain_temperature_controlmaintain_temperature_control()
403  self.refresh_propertiesrefresh_properties()
def handle_expose(self, existing_property, new_message)
Definition: xapp.py:130
def loop(self)
Definition: xapp.py:378
def begin_exposure(self)
Definition: xapp.py:327
def subscribe_to_other_devices(self)
Definition: xapp.py:230
def finalize_exposure(self, actual_exptime_sec=None)
Definition: xapp.py:335
def maintain_temperature_control(self)
Definition: xapp.py:300
def _init_camera(self)
Definition: xapp.py:152
def cooling_on_target(self)
Definition: xapp.py:68
def _gather_metadata(self)
Definition: xapp.py:306
def cancel_exposure(self)
Definition: xapp.py:370
def emit_telem_stdcam(self)
Definition: xapp.py:72
def update_from_camera(self)
Definition: xapp.py:258
def setup(self)
Definition: xapp.py:243
def refresh_properties(self)
Definition: xapp.py:267
def handle_temp_ccd(self, existing_property, new_message)
Definition: xapp.py:141
def handle_gain(self, existing_property, new_message)
Definition: xapp.py:115
def _init_properties(self)
Definition: xapp.py:165
def handle_exptime(self, existing_property, new_message)
Definition: xapp.py:100
def find_active_filter(client, fwname)
Definition: xapp.py:34