async def health_monitor_loop(self) -> None: """Monitor the state of the hardware.""" start_tai = utils.current_tai() while self.summary_state == salobj.State.ENABLED: try: controller_status = await self.model.get_status() await self.evt_status.set_write(status=controller_status) if controller_status == Status.FAULT: await self.fault( code=ErrorCode.HARDWARE_ERROR, report="Hardware controller reported FAULT.", traceback="", ) return curr_tai = utils.current_tai() await self.tel_timestamp.set_write(timestamp=curr_tai) await self.tel_loopTime.set_write(loopTime=curr_tai - start_tai ) await asyncio.sleep(self.heartbeat_interval) except Exception: await self.fault( code=ErrorCode.MISC, report="Health monitor loop unexpectedly died.", traceback=traceback.format_exc(), ) return
async def _script_state_callback(self, state): self.script_state = state.state self.state_delay = current_tai() - state.private_sndStamp if self.script_state == ScriptState.UNCONFIGURED and self.config_task is None: self.timestamp_configure_start = current_tai() self.config_task = asyncio.create_task(self._configure()) self.config_task.add_done_callback(self._trigger_callback) self.start_task.set_result(None) await self._run_callback()
async def do_expose(self, data): """Take an exposure with the connected spectrograph. Parameters ---------- data : `DataType` Command data Raises ------ asyncio.CancelledError Raised if the exposure is cancelled before it completes. lsst.ts.salobj.ExpectedError Raised if an error occurs while taking the exposure. """ self.assert_enabled() msg = self.device.check_expose_ok(data.duration) if msg is not None: raise salobj.ExpectedError(msg) try: date_begin = utils.astropy_time_from_tai_unix(utils.current_tai()) task = asyncio.create_task(self.device.expose(data.duration)) await self.evt_exposureState.set_write(status=ExposureState.INTEGRATING) wavelength, spectrum = await task date_end = utils.astropy_time_from_tai_unix(utils.current_tai()) await self.evt_exposureState.set_write(status=ExposureState.DONE) temperature = self.tel_temperature.data.temperature * u.deg_C setpoint = self.tel_temperature.data.setpoint * u.deg_C n_pixels = self.evt_deviceInfo.data.npixels spec_data = dataManager.SpectrographData( wavelength=wavelength, spectrum=spectrum, duration=data.duration, date_begin=date_begin, date_end=date_end, type=data.type, source=data.source, temperature=temperature, temperature_setpoint=setpoint, n_pixels=n_pixels, ) except asyncio.TimeoutError as e: await self.evt_exposureState.set_write(status=ExposureState.TIMEDOUT) msg = f"Timeout waiting for exposure: {repr(e)}" await self.fault(code=20, report=msg) raise salobj.ExpectedError(msg) except asyncio.CancelledError: await self.evt_exposureState.set_write(status=ExposureState.CANCELLED) raise except Exception as e: await self.evt_exposureState.set_write(status=ExposureState.FAILED) msg = f"Failed to take exposure with fiber spectrograph: {repr(e)}" await self.fault(code=20, report=msg) raise salobj.ExpectedError(msg) await self.save_data(spec_data)
def _basic_write(self) -> None: """Put self.data after setting the private_x fields.""" self.data.private_sndStamp = utils.current_tai() self.data.private_revCode = self.rev_code self.data.private_origin = self.salinfo.domain.origin self.data.private_identity = self.salinfo.identity if self._seq_num_generator is not None: self.data.private_seqNum = next(self._seq_num_generator) # when index is 0 use the default of 0 and give senders a chance # to override it. if self.salinfo.index != 0: self.data.salIndex = self.salinfo.index try: self._writer.write(self.data) except struct.error as e: raise ValueError( f"{self.name} write({self.data}) failed: one or more fields invalid" ) from e except TypeError as e: raise ValueError( f"{self.name} write({self.data}) failed: " f"perhaps one or more array fields has been set to a scalar" ) from e except IndexError as e: raise ValueError( f"{self.name} write({self.data}) failed: " f"probably at least one array field is too short" ) from e
async def telemetry_loop(self): try: while True: tai = utils.current_tai() azimuth_target = self.azimuth_actuator.target.at(tai) azimuth_actual = self.azimuth_actuator.path.at(tai) await self.tel_azimuth.set_write( positionActual=azimuth_actual.position, positionCommanded=azimuth_target.position, velocityActual=azimuth_actual.velocity, velocityCommanded=azimuth_target.velocity, ) await self.tel_lightWindScreen.set_write( positionActual=self.elevation_actuator.position(tai), positionCommanded=self.elevation_actuator.end_position, velocityActual=self.elevation_actuator.velocity(tai), velocityCommanded=0, ) await asyncio.sleep(self.telemetry_interval) except asyncio.CancelledError: raise except Exception: self.log.exception("Telemetry loop failed") raise
def telemetry_callback(self, client): """Called when the TCP/IP controller outputs telemetry. Parameters ---------- client : `CommandTelemetryClient` TCP/IP client. """ # Strangely telemetry.state, offline_substate and enabled_substate # are all floats from the controller. But they should only have # integer value, so I output them as integers. self.evt_controllerState.set_put( controllerState=int(client.telemetry.state), offlineSubstate=int(client.telemetry.offline_substate), enabledSubstate=int(client.telemetry.enabled_substate), ) self.evt_commandableByDDS.set_put( state=bool(client.telemetry.application_status & ApplicationStatus.DDS_COMMAND_SOURCE)) self.tel_rotation.set_put( demandPosition=client.telemetry.cmd_position, actualPosition=client.telemetry.curr_position, timestamp=utils.current_tai(), )
async def end_readout(self, data: salobj.type_hints.BaseMsgType) -> None: """Wait `self.readout_time` and send endReadout event.""" self.log.debug(f"end_readout started: sleep {self.readout_time}") await asyncio.sleep(self.readout_time) self.atcam_image_started = False self.nimages += 1 if hasattr(data, "expTime"): self.exptime_list.append(data.expTime) else: self.exptime_list.append(utils.current_tai() - self.atcam_image_started) date_id = astropy.time.Time.now().tai.isot.split("T")[0].replace( "-", "") image_name = f"test_latiss_{date_id}_{next(index_gen)}" self.log.debug(f"sending endReadout: {image_name} :: {data}") additional_keys, additional_values = list( zip(*[ key_value.strip().split(":", maxsplit=1) for key_value in data.keyValueMap.split(",") ])) await self.atcam.evt_endReadout.set_write( imageName=image_name, additionalKeys=":".join([key.strip() for key in additional_keys]), additionalValues=":".join( [value.strip() for value in additional_values]), ) self.log.debug("sending LFOA") await self.atheaderservice.evt_largeFileObjectAvailable.write() self.log.debug("end_readout done")
def do_setInstrumentPort(self, data): self.assert_enabled("setInstrumentPort") if self._tracking_enabled: raise salobj.ExpectedError( "Cannot setInstrumentPort while tracking is enabled" ) port = data.port try: m3_port_positions_ind = self._port_info_dict[port][0] except IndexError: raise salobj.ExpectedError(f"Invalid port={port}") try: m3_port_positions = self.m3_port_positions[m3_port_positions_ind] except IndexError: raise RuntimeError( f"Bug! invalid m3_port_positions_ind={m3_port_positions_ind} for port={port}" ) self.evt_m3PortSelected.set_put(selected=port) m3actuator = self.actuators[Axis.M3] if ( m3actuator.target.position == m3_port_positions and self.evt_m3InPosition.data.inPosition ): # already there; don't do anything return self.actuators[Axis.M3].set_target( tai=utils.current_tai(), position=m3_port_positions, velocity=0 ) self._axis_enabled[Axis.NA1] = False self._axis_enabled[Axis.NA2] = False self.update_events()
async def closeShutter(self) -> None: """Close the shutter.""" self.log.debug("Received command 'closeShutter'") self.command_time_tai = utils.current_tai() self.status = LlcMotionState.CLOSED # Both positions are expressed in percentage. self.position_actual = np.zeros(_NUM_SHUTTERS, dtype=float) self.position_commanded = 0.0
async def openShutter(self) -> None: """Open the shutter.""" self.log.debug("Received command 'openShutter'") self.command_time_tai = utils.current_tai() self.status = LlcMotionState.OPEN # Both positions are expressed in percentage. self.position_actual = np.full(_NUM_SHUTTERS, 100.0, dtype=float) self.position_commanded = 100.0
async def _finish_stop_tracking(self): """Wait for the main axes to stop.""" end_times = [self.actuators[axis].path[-1].tai for axis in MainAxes] max_end_time = max(end_times) dt = 0.1 + max_end_time - utils.current_tai() if dt > 0: await asyncio.sleep(dt) asyncio.ensure_future(self._run_update_events())
async def test_late_track_target(self): # Use a short tracking interval so the test runs quickly. max_tracking_interval = 0.2 async with self.make_csc(initial_state=salobj.State.ENABLED): # Get the initial summary state, so `fault_to_enabled` sees FAULT. await self.assert_next_summary_state(salobj.State.ENABLED) self.csc.configure( max_tracking_interval=max_tracking_interval, max_velocity=(100,) * 5, max_acceleration=(200,) * 5, ) await self.assert_next_sample( self.remote.evt_atMountState, state=AtMountState.TRACKINGDISABLED ) await self.assert_next_sample( self.remote.evt_m3State, state=M3State.NASMYTH1 ) await self.remote.cmd_startTracking.start(timeout=STD_TIMEOUT) await self.assert_next_sample( self.remote.evt_atMountState, state=AtMountState.TRACKINGENABLED ) # wait too long for trackTarget await self.assert_next_sample( self.remote.evt_atMountState, state=AtMountState.TRACKINGDISABLED, timeout=max_tracking_interval + 0.2, ) await self.fault_to_enabled() # try again, and this time send a trackTarget command # before waiting too long await self.remote.cmd_startTracking.start(timeout=STD_TIMEOUT) await self.assert_next_sample( self.remote.evt_atMountState, state=AtMountState.TRACKINGENABLED ) await self.remote.cmd_trackTarget.set_start( elevation=10, taiTime=utils.current_tai(), trackId=20, timeout=1 ) # wait too long for trackTarget await self.assert_next_sample( self.remote.evt_atMountState, state=AtMountState.STOPPING, timeout=max_tracking_interval + 0.2, ) await self.assert_next_sample( self.remote.evt_atMountState, state=AtMountState.TRACKINGDISABLED )
def _cleanup(self, returncode=None): """Clean up when the Script subprocess exits. Set the timestamp_process_end, cancel the config task, delete the remote, run the callback and delete the callback. """ self.timestamp_process_end = current_tai() if self.config_task is not None: self.config_task.cancel() self._cancel_set_clear_group_id() asyncio.create_task(self._finish_cleanup())
async def _finish_disable_all_drives(self): """Wait for the main axes to stop.""" end_times = [actuator.path[-1].tai for actuator in self.actuators] max_end_time = max(end_times) # give a bit of margin to be sure the axes are stopped dt = 0.1 + max_end_time - utils.current_tai() if dt > 0: await asyncio.sleep(dt) for axis in Axis: self._axis_enabled[axis] = False asyncio.ensure_future(self._run_update_events())
def get_dome_target_azimuth(self): """Get the current dome azimuth target.""" target = self.dome_remote.evt_azTarget.get() if target is None: return None if math.isnan(target.position): return None return simactuators.path.PathSegment( position=target.position, velocity=target.velocity, tai=utils.current_tai(), )
async def test_stop_tracking_while_slewing(self): """Call stopTracking while tracking, before a slew is done.""" async with self.make_csc(initial_state=salobj.State.ENABLED): await self.assert_next_sample( self.remote.evt_atMountState, state=AtMountState.TRACKINGDISABLED ) await self.assert_next_sample( self.remote.evt_m3State, state=M3State.NASMYTH1 ) await self.remote.cmd_startTracking.start(timeout=STD_TIMEOUT) await self.assert_next_sample( self.remote.evt_atMountState, state=AtMountState.TRACKINGENABLED ) start_tai = utils.current_tai() path_dict = dict( elevation=simactuators.path.PathSegment(tai=start_tai, position=45), azimuth=simactuators.path.PathSegment(tai=start_tai, position=100), nasmyth1RotatorAngle=simactuators.path.PathSegment( tai=start_tai, position=90 ), ) trackId = 35 # arbitary tai = start_tai + 0.1 # offset is arbitrary but reasonable target_kwargs = self.compute_track_target_kwargs( tai=tai, path_dict=path_dict, trackId=trackId ) await self.remote.cmd_trackTarget.set_start(**target_kwargs, timeout=1) await asyncio.sleep(0.2) await self.remote.cmd_stopTracking.start(timeout=1) await self.assert_next_sample( self.remote.evt_atMountState, state=AtMountState.STOPPING ) await asyncio.sleep(0.2) # Give events time to arrive. for event in self.in_position_events: desired_in_position = event is self.remote.evt_m3InPosition await self.assert_next_sample(event, inPosition=desired_in_position) await self.assert_next_sample( self.remote.evt_atMountState, state=AtMountState.TRACKINGDISABLED ) for actuator in self.csc.actuators: self.assertEqual(actuator.kind(), actuator.Kind.Stopped)
async def current_target_status(self): while self.run_current_target_status_loop: time = utils.current_tai() self.model.update_state(time) await self.ptg.tel_currentTargetStatus.set_write( timestamp=time, demandAz=self.model.current_state.telaz, demandEl=self.model.current_state.telalt, demandRot=self.model.current_state.telrot, demandRa=self.model.current_state.ra, demandDec=self.model.current_state.dec, parAngle=self.model.current_state.pa, ) await asyncio.sleep(self.telemetry_sleep_time)
async def setTemperature(self, temperature: float) -> None: """Set the preferred temperature in the dome. It should mock cooling down or warming up but it doesn't. Parameters ---------- temperature: `float` The preferred temperature (degrees Celsius). In reality this should be a realistic temperature in the range of about -30 C to +40 C but the provided temperature is not checked against this range. """ self.command_time_tai = utils.current_tai() self.status = LlcMotionState.OPEN self.temperature[:] = temperature
def setUp(self): self.raw_telemetry = dict() self.raw_telemetry["timeHandler"] = None self.raw_telemetry["scheduled_targets"] = [] self.raw_telemetry["observing_queue"] = [] self.raw_telemetry["observatoryState"] = None self.raw_telemetry["bulkCloud"] = 0.0 self.raw_telemetry["seeing"] = 1.0 self.models = dict() self.models["location"] = ObservatoryLocation() self.models["location"].for_lsst() self.models["observatory_model"] = ObservatoryModel(self.models["location"]) self.models["observatory_model"].configure_from_module() self.models["observatory_model"].update_state(current_tai()) self.models["observatory_state"] = ObservatoryState() self.models["observatory_state"].set( self.models["observatory_model"].current_state ) self.models["sky"] = AstronomicalSkyModel(self.models["location"]) self.models["seeing"] = SeeingModel() self.models["cloud"] = CloudModel() self.models["downtime"] = DowntimeModel() self.driver = SequentialScheduler( models=self.models, raw_telemetry=self.raw_telemetry, ) self.config = types.SimpleNamespace( driver_configuration=dict( observing_list=pathlib.Path(__file__) .parents[1] .joinpath("tests", "data", "test_observing_list.yaml"), general_propos=["Test"], default_observing_script_name="standard_visit.py", default_observing_script_is_standard=True, stop_tracking_observing_script_name="stop_tracking.py", stop_tracking_observing_script_is_standard=True, ) ) self.files_to_delete = []
async def start_loading(self, fullpath): """Start the script process and start a task that will configure the script when it is ready. Parameters ---------- fullpath : `str`, `bytes` or `os.PathLike` Full path to the script. Notes ----- If loading is canceled the script process is terminated. If loading fails the script is marked as terminated. """ if self.create_process_task is not None: raise RuntimeError("Already started loading") if self._terminated: # this can happen if the user stops a script with stopScript # while the script is being added return initialpath = os.environ["PATH"] try: scriptdir, scriptname = os.path.split(fullpath) os.environ["PATH"] = scriptdir + ":" + initialpath # save task so process creation can be cancelled if it hangs self.create_process_task = asyncio.create_task( asyncio.create_subprocess_exec(scriptname, str(self.index))) self.process = await self.create_process_task if self._terminated: # Terminated before fully loaded return self.process_task = asyncio.create_task(self.process.wait()) self.timestamp_process_start = current_tai() await self._run_callback() # note: process_task may already be done if the script cannot # be run, in which case the callback will be called immediately self.process_task.add_done_callback(self._cleanup) except asyncio.CancelledError: self.log.info("Loading cancelled") self._terminated = True raise except Exception: self.log.exception("Loading failed") self._terminated = True raise finally: os.environ["PATH"] = initialpath if not self.timestamp_process_start == 0: await self._run_callback()
def run(self): """Start the script running. Raises ------ RuntimeError If the script cannot be run, e.g. because: - The script has not yet been configured. - `run` was already called. - The script process is done. """ if not self.runnable: raise RuntimeError("Script is not runnable") asyncio.create_task(self.remote.cmd_run.set_start(ScriptID=self.index)) self.timestamp_run_start = current_tai()
async def publish_m1m3_topic_samples(self) -> None: """Publish m1m3 topic samples.""" self.log.debug("Publishing m1m3 topic samples.") # Load data without blocking the event loop. loop = asyncio.get_running_loop() m1m3_topic_samples = await loop.run_in_executor( None, get_m1m3_topic_samples_data) for topic_sample in m1m3_topic_samples: if "timestamp" in m1m3_topic_samples[topic_sample]: m1m3_topic_samples[topic_sample]["timestamp"] = current_tai() # Cleanup sample data in case of schema evolution cleanup_topic_attr = [] for topic_attr in m1m3_topic_samples[topic_sample]: if not hasattr( getattr(self.controllers.mtm1m3, f"evt_{topic_sample}").data, topic_attr, ): self.log.debug( f"M1M3 topic {topic_sample} does not have attribute {topic_attr}." ) cleanup_topic_attr.append(topic_attr) if len(cleanup_topic_attr) > 0: self.log.warning( f"The following topic attributes are not available in {topic_sample}: " f"{cleanup_topic_attr}. Need to update topic samples.") for topic_attr in cleanup_topic_attr: self.log.debug( f"Removing topic {topic_attr} from {topic_sample} sample.") m1m3_topic_samples[topic_sample].pop(topic_attr) try: await getattr(self.controllers.mtm1m3, f"evt_{topic_sample}").set_write( **m1m3_topic_samples[topic_sample]) except Exception: self.log.exception(f"Error publishing topic {topic_sample}") self.log.debug("Finished publishing m1m3 topic samples.")
async def handle_summary_state(self): if self.summary_state == salobj.State.ENABLED: axes_to_enable = set((Axis.Elevation, Axis.Azimuth)) tai = utils.current_tai() rot_axis = self.m3_port_rot(tai)[1] if rot_axis is not None: axes_to_enable.add(rot_axis) for axis in Axis: self._axis_enabled[axis] = axis in axes_to_enable else: self.disable_all_drives() if self.summary_state in (salobj.State.DISABLED, salobj.State.ENABLED): if self._events_and_telemetry_task.done(): self._events_and_telemetry_task = asyncio.ensure_future( self.events_and_telemetry_loop() ) else: self._events_and_telemetry_task.cancel()
def disable_all_drives(self): """Stop all drives, disable them and put on brakes.""" self._tracking_enabled = False already_stopped = True tai = utils.current_tai() for axis in Axis: actuator = self.actuators[axis] if actuator.kind(tai) == actuator.Kind.Stopped: self._axis_enabled[axis] = False else: already_stopped = False actuator.stop() self._disable_all_drives_task.cancel() if not already_stopped: self._disable_all_drives_task = asyncio.ensure_future( self._finish_disable_all_drives() ) self.update_events()
async def report_azimuth_done(self, in_position, motion_state): """Wait for azimuth to stop moving and report evt_azMotion. Parameters ---------- in_position : `bool` Is the axis in position at the end of the move? motion_state : `lsst.ts.idl.MotionState` Motion state at end of move. """ end_tai = self.azimuth_actuator.path.segments[-1].tai duration = end_tai - utils.current_tai() if duration > 0: await asyncio.sleep(duration) await self.evt_azMotion.set_write( state=motion_state, inPosition=in_position, )
def test_calculate_parallactic_angle(self) -> None: # TODO: Implement test (DM-21336) radec_icrs = ICRS(Angle(0.0, unit=u.hourangle), Angle(-80.0, unit=u.deg)) location = EarthLocation.from_geodetic(lon=-70.747698 * u.deg, lat=-30.244728 * u.deg, height=2663.0 * u.m) current_time = astropy_time_from_tai_unix(current_tai()) current_time.location = location par_angle = calculate_parallactic_angle( location, current_time.sidereal_time("mean"), radec_icrs, ) assert par_angle is not None
async def setLouvers(self, position: typing.List[float]) -> None: """Set the position of the louver with the given louver_id. Parameters ---------- position: array of float An array with the positions (percentage) to set the louvers to. 0 means closed, 180 means wide open, -1 means do not move. These limits are not checked. """ self.command_time_tai = utils.current_tai() pos: float = 0 for louver_id, pos in enumerate(position): if pos >= 0: if pos > 0: self.status[louver_id] = LlcMotionState.OPEN.name else: self.status[louver_id] = LlcMotionState.CLOSED.name self.position_actual[louver_id] = pos self.position_commanded[louver_id] = pos
async def set_state( self, state: typing.Union[ScriptState, int, None] = None, reason: typing.Optional[str] = None, keep_old_reason: bool = False, force_output: bool = False, ) -> None: """Set the script state. Parameters ---------- state : `ScriptState` or `int`, optional New state, or None if no change reason : `str`, optional Reason for state change. `None` for no new reason. keep_old_reason : `bool` If true, keep old reason; append the ``reason`` argument after ";" if it is is a non-empty string. If false replace with ``reason``, or "" if ``reason`` is `None`. force_output : `bool`, optional If true the output even if not changed. Notes ----- The lastCheckpoint field is set from self.last_checkpoint, and the numCheckpoints field is set from self.num_checkpoints. """ if state is not None: state = ScriptState(state) self.timestamps[state] = utils.current_tai() if keep_old_reason and reason is not None: sepstr = "; " if self.evt_state.data.reason else "" # type: ignore reason = self.evt_state.data.reason + sepstr + reason # type: ignore await self.evt_state.set_write( # type: ignore state=state, reason=reason, lastCheckpoint=self.last_checkpoint, numCheckpoints=self.num_checkpoints, force_output=force_output, )
async def do_moveAz(self, data): self.assert_enabled() if not self.azimuth_done_task.done(): raise salobj.ExpectedError("Azimuth slew not done.") self.azimuth_actuator.set_target( position=data.position, velocity=data.velocity, tai=utils.current_tai(), ) await self.evt_azTarget.set_write( position=data.position, velocity=data.velocity, force_output=True ) await self.evt_azMotion.set_write( state=MotionState.MOVING, inPosition=False, ) end_motion_state = ( MotionState.CRAWLING if data.velocity != 0 else MotionState.STOPPED ) self.azimuth_done_task = asyncio.create_task( self.report_azimuth_done(in_position=True, motion_state=end_motion_state) )
async def _configure(self): """Configure the script. If configuration fails or is cancelled then terminate the script. Raises ------ RuntimeError If the script state is not ScriptState.UNCONFIGURED """ try: if self.script_state != ScriptState.UNCONFIGURED: raise RuntimeError( f"Cannot configure script {self.index} " f"because it is in state {self.script_state} " f"instead of {ScriptState.UNCONFIGURED!r}") await self.remote.cmd_configure.set_start( ScriptID=self.index, config=self.config, logLevel=self.log_level, pauseCheckpoint=self.pause_checkpoint, stopCheckpoint=self.stop_checkpoint, timeout=_CONFIGURE_TIMEOUT, ) except asyncio.CancelledError: self.log.info("Configuration cancelled") asyncio.create_task(self.terminate()) raise except Exception: # terminate the script but first let the configure_task fail self.log.exception("Configuration failed") asyncio.create_task(self.terminate()) raise finally: self.timestamp_configure_end = current_tai()