def send_command( self, command_string: str, command_id: Optional[int] = None, **kwargs, ) -> ArchonCommand: """Sends a command to the Archon. Parameters ---------- command_string The command to send to the Archon. Will be converted to uppercase. command_id The command id to associate with this message. If not provided, a sequential, autogenerated one will be used. kwargs Other keyword arguments to pass to `.ArchonCommand`. """ command_id = command_id or self._get_id() if command_id > MAX_COMMAND_ID or command_id < 0: raise ArchonError( f"Command ID must be in the range [0, {MAX_COMMAND_ID:d}]." ) command = ArchonCommand( command_string, command_id, controller=self, **kwargs, ) self.__running_commands[command_id] = command self.write(command.raw) return command
async def set_param(self, param: str, value: int) -> ArchonCommand: """Sets the parameter ``param`` to value ``value`` calling ``FASTLOADPARAM``.""" cmd = await self.send_command(f"FASTLOADPARAM {param} {value}") if not cmd.succeeded(): raise ArchonError( f"Failed setting parameter {param!r} ({cmd.status.name})." ) return cmd
async def reset(self): """Cancels exposures and resets timing.""" await self.set_param("ContinuousExposures", 0) await self.set_param("Exposures", 0) cmd = await self.send_command("RESETTIMING", timeout=1) if not cmd.succeeded(): self.status = ControllerStatus.ERROR raise ArchonError(f"Failed sending RESETTIMING ({cmd.status.name})") # TODO: here we should do some more checks before we say it's IDLE. self.status = ControllerStatus.IDLE
async def integrate(self, exposure_time=1): """Integrates the CCD for ``exposure_time`` seconds. Returns immediately once the exposure has begun. """ if not self.status == ControllerStatus.IDLE: raise ArchonError("Status must be IDLE to start integrating.") await self.set_param("IntMS", int(exposure_time * 1000)) await self.set_param("Exposures", 1) self.status = ControllerStatus.EXPOSING
async def get_system(self) -> dict[str, Any]: """Returns a dictionary with the output of the ``SYSTEM`` command.""" cmd = await self.send_command("SYSTEM", timeout=1) if not cmd.succeeded(): raise ArchonError(f"Command finished with status {cmd.status.name!r}") keywords = str(cmd.replies[0].reply).split() system = {} for (key, value) in map(lambda k: k.split("="), keywords): system[key.lower()] = value if match := re.match(r"^MOD([0-9]{1,2})_TYPE", key, re.IGNORECASE): name_key = f"mod{match.groups()[0]}_name" system[name_key] = ModType(int(value)).name
async def get_frame(self) -> dict[str, int]: """Returns the frame information. All the returned values in the dictionary are integers in decimal representation. """ cmd = await self.send_command("FRAME", timeout=1) if not cmd.succeeded(): raise ArchonError(f"Command FRAME failed with status {cmd.status.name!r}") keywords = str(cmd.replies[0].reply).split() frame = { key.lower(): int(value) if "TIME" not in key else int(value, 16) for (key, value) in map(lambda k: k.split("="), keywords) } return frame
async def get_status(self) -> dict[str, Any]: """Returns a dictionary with the output of the ``STATUS`` command.""" def check_int(s): if s[0] in ("-", "+"): return s[1:].isdigit() return s.isdigit() cmd = await self.send_command("STATUS", timeout=1) if not cmd.succeeded(): raise ArchonError(f"Command finished with status {cmd.status.name!r}") keywords = str(cmd.replies[0].reply).split() status = { key.lower(): int(value) if check_int(value) else float(value) for (key, value) in map(lambda k: k.split("="), keywords) } return status
def __init__(self, raw_reply: bytes, command: ArchonCommand): parsed = REPLY_RE.match(raw_reply) if not parsed: raise ArchonError(f"Received unparseable reply to command " f"{command.raw}: {raw_reply.decode()}") self.command = command self.raw_reply = raw_reply rtype, rcid, rbin, rmessage = parsed.groups() self.type: str = rtype.decode() self.command_id: int = int(rcid, 16) self.is_binary: bool = rbin.decode() == ":" self.reply: str | bytes if self.is_binary: # If the reply is binary, remove the prefixes and save the full # content as the reply. self.reply = raw_reply.replace(b"<" + rcid + b":", b"") else: self.reply = rmessage.decode().strip()
def __str__(self) -> str: if isinstance(self.reply, bytes): raise ArchonError( "The reply is binary and cannot be converted to string.") return self.reply
def _get_id(self) -> int: """Returns an identifier from the pool.""" if len(self._id_pool) == 0: raise ArchonError("No ids reamining in the pool!") return self._id_pool.pop()
async def fetch( self, buffer_no: int = -1, notifier: Optional[Callable[[str], None]] = None, ) -> numpy.ndarray: """Fetches a frame buffer and returns a Numpy array. Parameters ---------- buffer_no The frame buffer number to read. Use ``-1`` to read the most recently complete frame. notifier A callback that receives a message with the current operation. Useful when `.fetch` is called by the actor to report progress to the users. """ notifier = notifier or (lambda x: None) frame_info = await self.get_frame() if buffer_no not in [1, 2, 3, -1]: raise ArchonError(f"Invalid frame buffer {buffer_no}.") if buffer_no == -1: buffers = [ (n, frame_info[f"buf{n}timestamp"]) for n in [1, 2, 3] if frame_info[f"buf{n}complete"] == 1 ] if len(buffers) == 0: raise ArchonError("There are no buffers ready to be read.") sorted_buffers = sorted(buffers, key=lambda x: x[1], reverse=True) buffer_no = sorted_buffers[0][0] else: if frame_info[f"buf{buffer_no}complete"] == 0: raise ArchonError(f"Buffer frame {buffer_no} cannot be read.") self.status = ControllerStatus.FETCHING # Lock for reading notifier(f"Locking buffer {buffer_no}") await self.send_command(f"LOCK{buffer_no}") width = frame_info[f"buf{buffer_no}width"] height = frame_info[f"buf{buffer_no}height"] bytes_per_pixel = 2 if frame_info[f"buf{buffer_no}sample"] == 0 else 4 n_bytes = width * height * bytes_per_pixel n_blocks: int = int(numpy.ceil(n_bytes / 1024.0)) start_address = frame_info[f"buf{buffer_no}base"] notifier("Reading frame buffer ...") # Set the expected length of binary buffer to read, including the prefixes. self.set_binary_reply_size((1024 + 4) * n_blocks) cmd: ArchonCommand = await self.send_command( f"FETCH{start_address:08X}{n_blocks:08X}", timeout=None, ) # Unlock all notifier("Frame buffer readout complete. Unlocking all buffers.") await self.send_command("LOCK0") # The full read buffer probably contains some extra bytes to complete the 1024 # reply. We get only the bytes we know are part of the buffer. frame = cmd.replies[0].reply[0:n_bytes] # Convert to uint16 array and reshape. dtype = f"<u{bytes_per_pixel}" # Buffer is little-endian arr = numpy.frombuffer(frame, dtype=dtype) arr = arr.reshape(height, width) self.status = ControllerStatus.IDLE return arr
async def write_config( self, path: str | os.PathLike[str], applyall: bool = False, poweron: bool = False, timeout: float = 1, notifier: Optional[Callable[[str], None]] = None, ): """Writes a configuration file to the contoller. Parameters ---------- path The path to the configuration file to load. It must be in INI format with a section called ``[CONFIG]``. applyall Whether to run ``APPLYALL`` after successfully sending the configuration. poweron Whether to run ``POWERON`` after successfully sending the configuration. Requires ``applyall=True``. timeout The amount of time to wait for each command to succeed. notifier A callback that receives a message with the current operation. Useful when `.write_config` is called by the actor to report progress to the users. """ notifier = notifier or (lambda x: None) notifier("Reading configuration file") if not os.path.exists(path): raise ArchonError(f"File {path} does not exist.") c = configparser.ConfigParser() c.read(path) if not c.has_section("CONFIG"): raise ArchonError("The config file does not have a CONFIG section.") # Undo the INI format: revert \ to / and remove quotes around values. config = c["CONFIG"] lines = list( map( lambda k: k.upper().replace("\\", "/") + "=" + config[k].strip('"'), config, ) ) notifier("Clearing previous configuration") if not (await self.send_command("CLEARCONFIG", timeout=timeout)).succeeded(): self.status = ControllerStatus.ERROR raise ArchonError("Failed running CLEARCONFIG.") notifier("Sending configuration lines") cmd_strs = [f"WCONFIG{n_line:04X}{line}" for n_line, line in enumerate(lines)] done, failed = await self.send_many(cmd_strs, max_chunk=200, timeout=timeout) if len(failed) > 0: ff = failed[0] self.status = ControllerStatus.ERROR raise ArchonError(f"Failed sending line {ff.raw!r} ({ff.status.name})") notifier("Sucessfully sent config lines") if applyall: notifier("Sending APPLYALL") cmd = await self.send_command("APPLYALL", timeout=5) if not cmd.succeeded(): self.status = ControllerStatus.ERROR raise ArchonError(f"Failed sending APPLYALL ({cmd.status.name})") if poweron: notifier("Sending POWERON") cmd = await self.send_command("POWERON", timeout=timeout) if not cmd.succeeded(): self.status = ControllerStatus.ERROR raise ArchonError(f"Failed sending POWERON ({cmd.status.name})") self.status = ControllerStatus.IDLE
async def read_config(self, save: str | bool = False) -> list[str]: """Reads the configuration from the controller. Parameters ---------- save Save the configuration to a file. If ``save=True``, the configuration will be saved to ``~/archon_<controller_name>.acf``, or set ``save`` to the path of the file to save. """ key_value_re = re.compile("^(.+?)=(.*)$") def parse_line(line): k, v = key_value_re.match(line).groups() # It seems the GUI replaces / with \ even if that doesn't seem # necessary in the INI format. k = k.replace("/", "\\") if ";" in v or "=" in v or "," in v: v = f'"{v}"' return k, v cmd_strs = [f"RCONFIG{n_line:04X}" for n_line in range(MAX_CONFIG_LINES)] done, failed = await self.send_many(cmd_strs, max_chunk=200, timeout=0.5) if len(failed) > 0: ff = failed[0] status = ff.status.name raise ArchonError(f"An RCONFIG command returned with code {status!r}") if any([len(cmd.replies) != 1 for cmd in done]): raise ArchonError("Some commands did not get any reply.") lines = [str(cmd.replies[0]) for cmd in done] # Trim possible empty lines at the end. config = "\n".join(lines).strip().splitlines() if not save: return config # The GUI ACF file includes the system information, so we get it. system = await self.get_system() c = configparser.ConfigParser() c.optionxform = str # Make it case-sensitive c.add_section("SYSTEM") for sk, sv in system.items(): if "_name" in sk.lower(): continue sl = f"{sk.upper()}={sv}" k, v = parse_line(sl) c.set("SYSTEM", k, v) c.add_section("CONFIG") for cl in config: k, v = parse_line(cl) c.set("CONFIG", k, v) if isinstance(save, str): path = save else: path = os.path.expanduser(f"~/archon_{self.name}.acf") with open(path, "w") as f: c.write(f, space_around_delimiters=False) return config
async def _do_one_controller( command: Command[archon.actor.ArchonActor], controller: ArchonController, exposure_params: dict[str, Any], exp_no: int, mjd_dir: pathlib.Path, ) -> bool: """Does the heavy lifting of exposing and writing a single controller.""" observatory = command.actor.observatory.lower() hemisphere = "n" if observatory == "apo" else "s" config = command.actor.config path: pathlib.Path = mjd_dir / config["files"]["template"] file_path = str(path.absolute()).format( exposure_no=exp_no, controller=controller.name, observatory=observatory, hemisphere=hemisphere, ) exp_time = exposure_params["exposure_time"] command.debug(text=dict( controller=controller.name, text="Starting exposure sequence " "(flavour={flavour!r}, exp_time={exposure_time}).".format( **exposure_params), )) # Open shutter (placeholder) # Use command to access the actor and command the shutter shutter_cmd_open = await command.actor.send_command("osu_actor", "open") await shutter_cmd_open # Block until the command is done (finished or failed) if shutter_cmd_open.status.did_fail: # Do cleanup return command.fail(text="Shutter failed to open") # Report status of the shutter replies_open = shutter_cmd_open.replies shutter_status_open = replies_open[-1].body["shutter"] if shutter_status_open not in ["open", "closed"]: return command.fail( text=f"Unknown shutter status {shutter_status_open!r}.") command.info(f"Shutter is now {shutter_status_open!r}.") # Start integration. _Changgon await controller.integrate(exposure_time=exp_time) # Wait until the exposure is complete. # TODO: Here we should take into account the network and mechanical delay in # opening the shutter. await asyncio.sleep(exp_time) # Close shutter (placeholder) # Close the shutter. Note the double await. shutter_cmd_close = await (await command.actor.send_command( "osu_actor", "close")) await shutter_cmd_close # Block until the command is done (finished or failed) if shutter_cmd_close.status.did_fail: # Do cleanup return command.fail(text="Shutter failed to close") # Report status of the shutter replies_close = shutter_cmd_close.replies shutter_status_close = replies_close[-1].body["shutter"] if shutter_status_close not in ["closed", "open"]: return command.fail( text=f"Unknown shutter status {shutter_status_close!r}.") command.info(f"Shutter is now {shutter_status_close!r}.") # Wait a little bit and check that we are reading out to a new buffer await asyncio.sleep(0.1) # Get new frame info frame_info = await controller.get_frame() wbuf = frame_info["wbuf"] if frame_info[f"buf{wbuf}complete"] != 0: controller.status = ControllerStatus.ERROR raise ArchonError("Read-out failed to start.") controller.status = ControllerStatus.READING command.debug(text=dict( controller=controller.name, text=f"Reading frame into buffer {wbuf}.", )) # Wait until buffer is complete. elapsed = 0 while True: frame_info = await controller.get_frame() if frame_info[f"buf{wbuf}complete"] == 1: break if elapsed > config["timeouts"]["readout_max"]: controller.status = ControllerStatus.ERROR raise ArchonError("Timed out waiting for read-out to finish.") await asyncio.sleep(1.0) # Sleep for one second before asking again. elapsed += 1 # Reset timing await controller.reset() # Fetch buffer data command.debug(text=dict( controller=controller.name, text=f"Fetching buffer {wbuf}.", )) data = await controller.fetch(wbuf) # Divide array into CCDs and create FITS. # TODO: add at least a placeholder header with some basics. command.debug(text=dict( controller=controller.name, text="Saving data to disk.", )) loop = asyncio.get_running_loop() ccd_info = config["controllers"][controller.name]["ccds"] fits = fitsio.FITS(file_path, "rw") for ccd_name in ccd_info: region = ccd_info[ccd_name] ccd_data = data[region[1]:region[3], region[0]:region[2]] fits_write = partial(fits.create_image_hdu, extname=ccd_name) await loop.run_in_executor(None, fits_write, ccd_data) await loop.run_in_executor(None, fits.close) command.info(text=f"File {os.path.basename(file_path)} written to disk.") return True