diff --git a/script/scan_guard.py b/script/scan_guard.py new file mode 100644 index 00000000..5ef02629 --- /dev/null +++ b/script/scan_guard.py @@ -0,0 +1,949 @@ +#!/usr/bin/env python + +""" +Scan guard utility for PEARL scans + +This utility watches the scan facilities at X03DA +and responds to beam loss and other incidents: + +IMPLEMENTED: + +* Beam loss: Pause the sscan records. +* Beam loss: Pause the Scienta analyser. +* Beam loss: Repeat the last Scienta measurement when the beam is restored. +* Beam loss: Close/open the station shutter. +* Beam loss: Independent low and high trip levels. +* Beam loss: Beam loss actions are done only if beamline operation is set to "unattended" +* Beam loss: Closed absorber or front end shutter will also trigger a beam loss + +PLANNED: + +* Update the data folder and file names according to the current date. +* Serve operation parameters as EPICS channels + +TODO: + +Author: + Matthias Muntwiler + +Created: + Oct 03, 2013 + +Copyright: + (c) 2013 Paul Scherrer Institut + +$Id: scan_guard.py 43 2015-10-07 16:38:05Z muntwiler_m $ +""" +import time +import logging +import epicsPV + +##### MonitoredDevice class ##### + +class MonitoredDevice(object): + """ + Base class for devices which detect beam loss/return. + """ + + def __init__(self, name): + """ + self.name = device name from which the PV names can be derived. + self.pv = an epicsPV.epicsPV object connecting to an EPICS PV + self.device_active = the device is consulted to decide whether the beam is on or off. + self.severity = log level to report on beam loss (logging.WARNING, logging.ERROR, or logging.CRITICAL) + """ + self.name = name + self.pv = None + self.device_active = True + self.severity = logging.WARNING + self._loss_log_func = logging.warning + + def connect(self): + """ + Connects to the EPICS PVs. + Name of PVs can be derived from self.name. + pend_io() should be done by caller. + """ + logging.info("setting up monitored device: %s", self.name) + if self.severity == logging.ERROR: + self._loss_log_func = logging.error + elif self.severity == logging.CRITICAL: + self._loss_log_func = logging.critical + else: + self._loss_log_func = logging.warning + + def poll(self): + """ + Polls the PVs used by the class if necessary. + """ + pass + + def set_monitors(self): + """ + Sets monitors on the PVs used by the class if necessary. + """ + pass + + def beam_okay(self, last_beam_status): + """ + Returns True if the beam is okay from the perspective of this device, False otherwise. + """ + return False + + +##### TripDevice class ##### + +class TripDevice(MonitoredDevice): + """ + Base class for devices which detect beam loss/return by checking that a float value is within the allowed range. + """ + + def __init__(self, name, trip_lo, trip_hi, dead_band): + """ + name = complete name of the EPICS PV to be monitored + trip_lo = beam loss is triggered if value falls below this level + trip_hi = beam loss is triggered if value rises above this level + dead_band = beam is restored if value returns to within trip_lo + dead_band ... trip_hi - dead_band + """ + super(TripDevice, self).__init__(name) + self.trip_lo = trip_lo + self.trip_hi = trip_hi + self.dead_band = dead_band + + def connect(self): + self.pv = epicsPV.epicsPV(self.name, wait=0) + logging.info("%s low trip %g", self.name, self.trip_lo) + logging.info("%s high trip %g", self.name, self.trip_hi) + + def set_monitors(self): + self.pv.array_get() + self.pv.setMonitor() + + def poll(self): + logging.debug("caget %s: %g", self.pv.pvname, self.pv.getValue()) + + def beam_okay(self, last_beam_status): + if not self.device_active: + return True + + val = self.pv.getValue() + if last_beam_status: + result = self.trip_lo <= val <= self.trip_hi + else: + result = self.trip_lo + self.dead_band <= val <= self.trip_hi - self.dead_band + + logging.debug("beam_okay = %s (%g mA)", str(result), val) + if last_beam_status and not result: + self._loss_log_func("%s trip (%g mA)", self.name, val) + + return result + + +##### BeamDrop class ##### + +class BeamDrop(MonitoredDevice): + def __init__(self, name): + super(BeamDrop, self).__init__(name) + + def connect(self): + self.pv = epicsPV.epicsPV(self.name, wait=0) + + def set_monitors(self): + self.pv.array_get() + self.pv.setMonitor() + + def poll(self): + logging.debug("caget %s: %g", self.pv.pvname, self.pv.getValue()) + + def beam_okay(self, last_beam_status): + if not self.device_active: + return True + + val = self.pv.getValue() + result = val == 0 + + logging.debug("operation_mode_okay() = %s (%g)", str(result), val) + if last_beam_status and not result: + self._loss_log_func("operation mode trip (%g)", val) + + return result + + +##### BeamlineMode class ##### + +class BeamlineMode(MonitoredDevice): + OFFLINE = 0 + ATTENDED = 1 + UNATTENDED = 2 + + def __init__(self, name): + super(BeamlineMode, self).__init__(name) + + def connect(self): + self.pv = epicsPV.epicsPV(self.name, wait=0) + + def set_monitors(self): + self.pv.array_get() + self.pv.setMonitor() + + def poll(self): + logging.debug("caget %s: %g", self.pv.pvname, self.pv.getValue()) + + def beam_okay(self, last_beam_status): + if not self.device_active: + return True + + val = self.pv.getValue() + result = val == self.ATTENDED or val == self.UNATTENDED + + logging.debug("beamline_mode_okay = %s (%g)", str(result), val) + + return result + + +##### FrontendShutter class ##### + +class FrontendShutter(MonitoredDevice): + """ + Monitors a front end shutter. + This can be any on/off device with a {name}:OPEN_EPS readback + which reports 1 in the open state, and 0 in the closed state. + """ + CLOSED = 0 + OPEN = 1 + + def __init__(self, name): + super(FrontendShutter, self).__init__(name) + + def connect(self): + """ + Connects to the process variables. + name = name of the shutter, e.g. "X03DA-FE-PH1" + """ + MonitoredDevice.connect(self) + self.pv = epicsPV.epicsPV(self.name + ":OPEN_EPS", wait=0) + + def set_monitors(self): + self.pv.array_get() + self.pv.setMonitor() + + def poll(self): + """ + Polls the shutter position + """ + logging.debug("caget %s: %g", self.pv.pvname, self.pv.getValue()) + + def beam_okay(self, last_beam_status): + """ + Returns True if the shutter is open, False otherwise. + """ + if not self.device_active: + return True + + val = self.pv.getValue() + result = val == self.OPEN + + logging.debug("shutter %s = %s (%g)", self.name, str(result), val) + if last_beam_status and not result: + self._loss_log_func("shutter trip %s", self.name) + + return result + + +##### ControlledDevice class ##### + +class ControlledDevice(object): + """ + Base class for devices which are controlled in response to beam loss/return. + """ + + def __init__(self, name): + """ + self.name = device name from which the PV names can be derived. + self.device_active = the device participated in the measurement before it was paused. + self.paused = the device is paused due to a call to the self.pause() method. + """ + self.name = name + self.device_active = False + self.paused = False + + def connect(self): + """ + Connects to the EPICS PVs. + Name of PVs can be derived from self.name. + pend_io() should be done by caller. + """ + logging.info("setting up controlled device: %s", self.name) + pass + + def poll(self): + """ + Polls the PVs used by the class if necessary. + """ + pass + + def set_monitors(self): + """ + Sets monitors on the PVs used by the class if necessary. + """ + pass + + def pause(self): + """ + Pauses the device, e.g. in response to beam loss. + The method should update self.device_active and self.paused. + """ + pass + + def resume(self): + """ + Resumes the device. + This method should act only if the previous pause() actually changed the device, + i.e. self.pause == True. + The method must reset self.paused. + """ + pass + + +##### ScanRecord class ##### + +class ScanRecord(ControlledDevice): + def __init__(self, name): + """ + self.faze: epicsPV.epicsPV connecting to the FAZE field of the scan record. + self.wait: epicsPV.epicsPV connecting to the WAIT field of the scan record. + """ + super(ScanRecord, self).__init__(name) + self.faze = None + self.wait = None + + def connect(self): + """ + Connects to the process variables of the scan record + name = complete name of the scan record + """ + ControlledDevice.connect(self) + self.faze = epicsPV.epicsPV(self.name + '.FAZE', wait=0) + self.wait = epicsPV.epicsPV(self.name + '.WAIT', wait=0) + + def set_monitors(self): + # before we can set a monitor, we have to get the value once. + self.faze.array_get() + self.faze.setMonitor() + self.wait.array_get() + + def poll(self): + logging.debug("caget %s: %g", self.faze.pvname, self.faze.getValue()) + logging.debug("caget %s: %g", self.wait.pvname, self.wait.getValue()) + + def pause(self): + self.device_active = self.faze.getValue() >= 1 + if self.device_active: + logging.info("pausing scan on %s", self.name) + self.wait.array_put(1) + self.wait.pend_io() + self.paused = True + else: + self.paused = False + + def resume(self): + if self.paused: + # do not resume if the user has aborted the scan + if self.faze.getValue() >= 1: + logging.info("resuming scan on %s", self.name) + self.wait.array_put(0) + self.wait.pend_io() + else: + logging.warning("scan on %s aborted by user", self.name) + self.paused = False + + +##### AreaDetector class ##### + +class AreaDetector(ControlledDevice): + """ + This class pauses and resumes an Area Detector device. + While pausing, array callbacks are disabled + so that the incomplete measurement is not passed to the plugins. + If the detector resumes before the scan advances, + the incomplete measurement is effectively repeated. + + Pause: Callbacks are disabled. Acquisition is aborted. + Resume: Callbacks are enabled. Acquisition is restarted if it was interrupted. + + The detector is paused only if all of the following conditions are true, + otherwise pause() will have no effect: + 1) Array callbacks are enabled on cam1 + 2) HDF1 plugin is capturing + + resume() has no effect unless the previous pause() stopped the detector. + """ + + def __init__(self, name): + super(AreaDetector, self).__init__(name) + # indicates whether we stopped a running acquisition when the pause started + self.acq_active = False + self.ArrayCallbacks = None + self.DetectorState = None + self.Acquire = None + self.AcquireTime = None + self.Capturing = None + + def connect(self): + """ + Connects to the process variables. + name = prefix of the area detector (everything before "cam1", e.g. "X03DA-SCIENTA") + """ + ControlledDevice.connect(self) + self.ArrayCallbacks = epicsPV.epicsPV(self.name + ':cam1:ArrayCallbacks', wait=0) + self.DetectorState = epicsPV.epicsPV(self.name + ':cam1:DetectorState_RBV', wait=0) + self.Acquire = epicsPV.epicsPV(self.name + ':cam1:Acquire', wait=0) + self.AcquireTime = epicsPV.epicsPV(self.name + ':cam1:AcquireTime_RBV', wait=0) + self.Capturing = epicsPV.epicsPV(self.name + ':HDF1:Capture_RBV', wait=0) + + def poll(self): + """ + Polls the detector state. + This class doesn't set monitors on the PVs. + Thus, we have to explicitly poll them before reading their values. + """ + self.DetectorState.array_get() + logging.debug("caget %s: %g", self.DetectorState.pvname, self.DetectorState.getValue()) + self.ArrayCallbacks.array_get() + logging.debug("caget %s: %g", self.ArrayCallbacks.pvname, self.ArrayCallbacks.getValue()) + self.Capturing.array_get() + logging.debug("caget %s: %g", self.Capturing.pvname, self.Capturing.getValue()) + + def pause(self): + """ + Pauses the area detector. + The goal is that no corrupted data gets into a data file in stream mode. + + The detector is considered active if the HDF5 plugin is in capture mode, + and if the array callbacks are enabled. In this case, + 1) the callbacks are disabled so that the corrupted data is not passed on to the plugins, + 2) the acquisition is stopped if running. + """ + self.device_active = ((self.ArrayCallbacks.getValue() == 1) and + (self.Capturing.getValue() == 1)) + self.acq_active = self.DetectorState.getValue() == 1 + logging.debug("caget %s: %g", self.DetectorState.pvname, self.DetectorState.getValue()) + logging.debug("acq_active = %s", str(self.acq_active)) + if self.device_active: + logging.info("disabling callbacks on %s", self.name) + self.ArrayCallbacks.array_put(0) + self.Acquire.pend_io() + logging.info("stopping acquisition on %s", self.name) + self.Acquire.array_put(0) + self.Acquire.pend_io() + self.paused = True + else: + logging.debug("device %s not active", self.name) + self.paused = False + + def resume(self): + """ + Resumes the area detector. + + 1) The callbacks are re-enabled. + 2) If the acquisition was interruped by the pause, it is restarted + unless the user has stopped the capture mode. + """ + if self.paused: + self.AcquireTime.array_get() + timeout = self.AcquireTime.getValue() * 2.0 + 10.0 + self.wait_idle(timeout=timeout) + logging.info("re-enabling callbacks on %s", self.name) + self.ArrayCallbacks.array_put(1) + logging.debug("acq_active = %s", str(self.acq_active)) + + self.Capturing.array_get() + if self.Capturing.getValue() >= 1: + # note: we need to restart the acquisition regardless of previous state. + # if self.acq_active: + logging.info("repeating acquisition on %s", self.name) + self.Acquire.array_put(1) + self.Acquire.pend_io() + if self.wait_acquire(timeout=10.0): + self.wait_idle(timeout=timeout) + else: + # user stopped the capture + logging.warning("file capture on %s aborted by user", self.name) + self.paused = False + + def wait_idle(self, timeout=60.0): + """ + waits for the detector to become idle. + returns True if detector is idle at the end, + False if the timeout exceeded while the detector is not idle. + """ + self.DetectorState.array_get() + if self.DetectorState.getValue() == 1: + logging.info("waiting until acquisition stops on %s", self.name) + if self.DetectorState.getValue() > 1: + logging.error("unexpected detector state on %s: %g", self.name, self.DetectorState.getValue()) + while (self.DetectorState.getValue() >= 1) and (timeout > 0.0): + self.DetectorState.pend_event(timeout=1.0) + self.DetectorState.array_get() + timeout -= 1.0 + if timeout <= 0.0: + logging.error("timeout while waiting for detector %s to become idle. current state: %g", self.name, + self.DetectorState.getValue()) + return False + else: + return True + + def wait_acquire(self, timeout=60.0): + """ + waits for the detector to start acquisition. + returns True if detector is acquiring at the end, + False if the timeout exceeded while the detector is idle. + """ + self.DetectorState.array_get() + if self.DetectorState.getValue() < 1: + logging.info("waiting until acquisition starts on %s", self.name) + if self.DetectorState.getValue() > 1: + logging.error("unexpected detector state on %s: %g", self.name, self.DetectorState.getValue()) + while (self.DetectorState.getValue() < 1) and (timeout > 0.0): + self.DetectorState.pend_event(timeout=1.0) + self.DetectorState.array_get() + timeout -= 1.0 + if timeout <= 0.0: + logging.error("timeout while waiting for detector %s. current state: %g", self.name, + self.DetectorState.getValue()) + return False + else: + return True + + +##### AnalogChannels class ##### + +class AnalogChannels(ControlledDevice): + """ + This class pauses and resumes the Analog Channels device. + + We don't have much control over this devices. All we can do is trigger it. + Triggering is allowed at any time even if the previous interval hasn't ended. + + Pause: no action. + Resume: Device is triggered. + + resume() has no effect unless the previous pause() stopped the detector. + """ + + def __init__(self, name): + super(AnalogChannels, self).__init__(name) + self.Trigger = None + + def connect(self): + """ + Connects to the process variables. + name = prefix of the analog channels (everything before the colon ":", e.g. "X03DA-OP-10ADC") + """ + ControlledDevice.connect(self) + self.Trigger = epicsPV.epicsPV(self.name + ':TRG', wait=0) + + def poll(self): + """ + Polls the detector state. + This class doesn't set monitors on the PVs. + Thus, we have to explicitly poll them before reading their values. + """ + self.Trigger.array_get() + logging.debug("caget %s: %g", self.Trigger.pvname, self.Trigger.getValue()) + + def pause(self): + """ + Pauses the detector. + This method does not change the state of the device. + + It is not possible (and not necessary) to stop the device. + Writing any value to self.Trigger will trigger a new measurement at any time. + """ + self.device_active = True + self.paused = True + + def resume(self): + """ + Triggers the detector. + """ + if self.paused: + logging.info("triggering analog channels %s", self.name) + self.Trigger.array_put(1) + self.paused = False + + +##### StationShutter class ##### + +class StationShutter(ControlledDevice): + CLOSED = 2 + MOVING = 4 + OPEN = 5 + + def __init__(self, name): + super(StationShutter, self).__init__(name) + self.ShutterOpen = None + self.ShutterClose = None + self.ShutterRbv = None + + def connect(self): + """ + Connects to the process variables. + name = name of the station shutter, e.g. "X03DA-OP-ST1" + """ + ControlledDevice.connect(self) + self.ShutterOpen = epicsPV.epicsPV(self.name + ":WT_SET_OPEN", wait=0) + self.ShutterClose = epicsPV.epicsPV(self.name + ":WT_SET_CLOSE", wait=0) + self.ShutterRbv = epicsPV.epicsPV(self.name + ":POSITION", wait=0) + + def set_monitors(self): + self.ShutterRbv.array_get() + self.ShutterRbv.setMonitor() + + def poll(self): + """ + Polls the shutter position + """ + logging.debug("caget %s: %g", self.ShutterRbv.pvname, self.ShutterRbv.getValue()) + + def pause(self): + """ + Closes the shutter. + """ + self.device_active = self.ShutterRbv.getValue() == self.OPEN + if self.device_active: + logging.info("closing shutter %s", self.name) + self.set_shutter(self.CLOSED) + self.paused = True + else: + self.paused = False + + def resume(self): + """ + Opens the shutter if it was open before the pause. + """ + if self.paused: + logging.info("opening shutter %s", self.name) + self.set_shutter(self.OPEN) + self.paused = False + + def set_shutter(self, newpos): + """ + Opens or closes the shutter. + + newpos = StationShutter.OPEN or StationShutter.CLOSED + """ + # try 5 times before giving up + tries = 5 + while (self.ShutterRbv.getValue() != newpos) and (tries > 0): + oldpos = self.ShutterRbv.getValue() + curpos = oldpos + logging.debug("shutter position before trigger: %g", curpos) + # send the command sequence + self.ShutterOpen.array_put(0) + self.ShutterClose.array_put(0) + self.ShutterRbv.pend_event(timeout=0.01) + if newpos == self.OPEN: + self.ShutterOpen.array_put(1) + else: + self.ShutterClose.array_put(1) + self.ShutterRbv.pend_event(timeout=0.5) + self.ShutterOpen.array_put(0) + self.ShutterClose.array_put(0) + self.ShutterRbv.pend_event(timeout=0.01) + curpos = self.ShutterRbv.getValue() + logging.debug("shutter position after command: %g", curpos) + # wait until it starts moving + timeout = 2.0 + while (self.ShutterRbv.getValue() == oldpos) and (timeout > 0.0): + self.ShutterRbv.pend_event(timeout=0.1) + timeout -= 0.1 + # wait while it is moving + timeout = 5.0 + while (self.ShutterRbv.getValue() == self.MOVING) and (timeout > 0.0): + self.ShutterRbv.pend_event(timeout=0.1) + timeout -= 0.1 + # wait longer if the readback is unexpected + timeout = 10.0 + while (self.ShutterRbv.getValue() not in [self.OPEN, self.CLOSED]) and (timeout > 0.0): + self.ShutterRbv.pend_event(timeout=0.5) + timeout -= 0.5 + curpos = self.ShutterRbv.getValue() + logging.debug("shutter position after wait: %g", curpos) + tries -= 1 + + curpos = self.ShutterRbv.getValue() + result = curpos == newpos + if not result: + logging.error("failed to set station shutter (requested %g, actual %g)", newpos, curpos) + return result + + +##### ScanGuard class ##### + +class ScanGuard(object): + """ + Info strings for operation mode. + Similar to those used by ACOAU-ACCU:OP-MODE without spaces and punctuation. + + Notes: + OP-MODE = 5 is signalled for orbit-feedback problems. + """ + OPMODES = ["MachineDown", "InjStopped", "Accumulating_", "Accumulating", "TopUpReady", "LightAvailable_", + "LightAvailable"] + + def __init__(self): + # list of epicsPV objects monitored by this class + self.monitors = [] + # list of ControlledDevice objects to be controlled + self.devices = [] + # False = test mode, do not actually send out commands + self.controls_active = False + # True = beam is okay + self.beam_ok = False + # True while executing the main loop + self.running = False + # For test and demo, simulate a beam loss after 15 seconds. + self.test = False + self.test_time_seconds = 15 + self.test_start_seconds = time.time() + + # for testing, use a local PV + # self._ringcurrent = TripDevice('X03DA-OP-EXS:AP', 10.0, 500.0, 10.0) + self._ringcurrent = TripDevice('ARIDI-PCT:CURRENT', 390.0, 410.0, 10.0) + self.monitors.append(self._ringcurrent) + # guard against erratic motors + self._fmu_rx = TripDevice('X03DA-OP-FMU:oRx', -1.0, 1.0, 0.1) + self._fmu_rx.severity = logging.ERROR + self.monitors.append(self._fmu_rx) + self._fmu_ry = TripDevice('X03DA-OP-FMU:oRy', -1.1, 0.9, 0.1) + self._fmu_ry.severity = logging.ERROR + self.monitors.append(self._fmu_ry) + self._fmu_rz = TripDevice('X03DA-OP-FMU:oRz', -0.5, 0.5, 0.1) + self._fmu_rz.severity = logging.ERROR + self.monitors.append(self._fmu_rz) + # self.operation_mode = OperationMode('ACOAU-ACCU:OP-MODE') + # self.monitors.append(self.operation_mode) + self._beam_drop = BeamDrop('ACOAU-ACCU:OP-BEAMDROP') + self.monitors.append(self._beam_drop) + self._beamline_mode = BeamlineMode('ACOAU-ACCU:OP-X03DA') + self.monitors.append(self._beamline_mode) + self._frontend_shutter = FrontendShutter('X03DA-FE-PH1') + self.monitors.append(self._frontend_shutter) + self._frontend_absorber = FrontendShutter('X03DA-FE-AB1') + self.monitors.append(self._frontend_absorber) + + # devices are paused in the order set here + # they are resumed in reversed order + self.devices.append(ScanRecord('X03DA-PC:scan1')) + self.devices.append(ScanRecord('X03DA-PC:scan2')) + self.devices.append(ScanRecord('X03DA-PC:scan3')) + self.devices.append(ScanRecord('X03DA-PC:scan4')) + # how can we detect whether channels are available? + self.devices.append(AreaDetector('X03DA-SCIENTA')) + self.devices.append(AnalogChannels('X03DA-OP-10ADC')) + self.devices.append(StationShutter('X03DA-OP-ST1')) + + @property + def ringcurrent_trip_lo(self): + """ + trip if ring current falls below this level. + """ + return self._ringcurrent.trip_lo + + @ringcurrent_trip_lo.setter + def ringcurrent_trip_lo(self, value): + self._ringcurrent.trip_lo = value + + @property + def ringcurrent_trip_hi(self): + """ + trip if ring current rises above this level. + """ + return self._ringcurrent.trip_hi + + @ringcurrent_trip_hi.setter + def ringcurrent_trip_hi(self, value): + self._ringcurrent.trip_hi = value + + @property + def ringcurrent_dead_band(self): + """ + resume if ring current returns above ringcurrent_trip_lo + ringcurrent_dead_band. + """ + return self._ringcurrent.dead_band + + @ringcurrent_dead_band.setter + def ringcurrent_dead_band(self, value): + self._ringcurrent.dead_band = value + + @property + def unattended_only(self): + """ + Scan guard is active during unattended operation only + """ + return self._beamline_mode.device_active + + @unattended_only.setter + def unattended_only(self, value): + self._beamline_mode.device_active = value + + def connect(self): + for monitor in self.monitors: + monitor.connect() + for device in self.devices: + device.connect() + + # Wait for all PVs to connect + try: + self._ringcurrent.pv.pend_io() + except: + logging.error('EPICS channels not available') + return False + + # get first value, initialize data structures, set monitors + for monitor in self.monitors: + monitor.set_monitors() + monitor.poll() + for device in self.devices: + device.set_monitors() + device.poll() + + return True + + def mainloop(self): + self._ringcurrent.pv.pend_io(timeout=0.01) + self.beam_ok = self.test or self.check_beam() + self.test_start_seconds = time.time() + self.running = True + + if self.unattended_only: + logging.info("guard active in unattended operation mode only") + else: + logging.info("guard active regardless of beamline operation mode") + if self.test: + logging.info("test mode. simulated beam loss in %g seconds.", self.test_time_seconds) + + while self.running: + self._ringcurrent.pv.pend_event(timeout=1) + new_beam_ok = self.check_beam() + if new_beam_ok != self.beam_ok: + self.beam_ok = new_beam_ok + if new_beam_ok: + logging.warning("beam restored") + self.resume() + else: + logging.warning("beam down") + self.pause() + + def check_beam(self): + beam_ok = self.beam_ok + if self.test: + rem_time = self.test_time_seconds - (time.time() - self.test_start_seconds) + if rem_time < 0: + beam_ok = not beam_ok + self.test_start_seconds = time.time() + # end test after one instance + self.test = not beam_ok + if self.test and not beam_ok: + logging.info("simulated beam restore in %g seconds.", self.test_time_seconds) + if not self.test: + logging.info("test done - resuming normal operation") + else: + beam_ok = True + for monitor in self.monitors: + beam_ok = beam_ok and monitor.beam_okay(self.beam_ok) + + logging.debug("check_beam() = %s", str(beam_ok)) + return beam_ok + + def pause(self): + for device in self.devices: + device.poll() + if self.controls_active: + for device in self.devices: + device.pause() + + def resume(self): + for device in reversed(self.devices): + device.poll() + if self.controls_active: + for device in reversed(self.devices): + device.resume() + + +##### main frame ##### + +def main(args): + nll = getattr(logging, args.loglevel.upper(), None) + logging.basicConfig(format="%(asctime)s %(levelname)s: %(message)s", level=nll) + logging.info("PEARL Scan Guard") + logging.info("version 1.1.1") + + guard = ScanGuard() + guard.ringcurrent_trip_lo = args.riculo + logging.debug(guard.ringcurrent_trip_lo) + if args.ricuhi >= args.riculo: + guard.ringcurrent_dead_band = args.ricuhi - guard.ringcurrent_trip_lo + else: + raise ValueError("invalid ring current trip arguments") + logging.debug(guard.ringcurrent_trip_hi) + guard.controls_active = args.controls_active + logging.debug(guard.controls_active) + guard.unattended_only = not args.attended + logging.debug(guard.unattended_only) + guard.test = args.test + logging.debug(guard.test) + guard.connect() + try: + pass + guard.mainloop() + except KeyboardInterrupt: + logging.info("scan guard aborted by user") + + +def test(): + """ + ad-hoc test function + """ + logging.basicConfig(format="%(asctime)s %(levelname)s: %(message)s", level=logging.DEBUG) + sh = StationShutter("X03DA-OP-ST1:") + sh.connect() + sh.ShutterRbv.pend_io() + sh.set_monitors() + return sh + + +def operate(): + """ + execute the scan guard in normal operation + + this is the normal entry point of a stable version in normal operation. + the function parameters should be stable. + do not modify the parameters for tests, use the test() function instead. + """ + args.controls_active = True + args.loglevel = logging.INFO + main(args) + +##### command line ##### + +import argparse + +if __name__ == '__main__': + parser = argparse.ArgumentParser( + description="Pause the measurements (scan records, Scienta detector) for beam loss") + parser.add_argument("-o", "--ring-current-lo", type=float, default=390, dest="riculo", + help="beam loss is triggered if ring current (mA) falls below this level") + parser.add_argument("-i", "--ring-current-hi", type=float, default=400, dest="ricuhi", + help="beam is restored if ring current (mA) rises above this level") + parser.add_argument("-a", "--attended", action="store_true", dest="attended", + help="guard beam during attended operation (default: unattended only)") + parser.add_argument("-p", "--passive", action="store_false", dest="controls_active", + help="check beam conditions but do not send out commands") + parser.add_argument("-t", "--test", action="store_true", dest="test", + help="test controls (simulate beam loss after 15 seconds)") + parser.add_argument("-l", "--loglevel", dest="loglevel", default="INFO", + choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]) + args = parser.parse_args() + main(args)