def output_on_off(form_output): action = '{action} {controller}'.format( action=gettext("Actuate"), controller=TRANSLATIONS['output']['title']) error = [] try: control = DaemonControl() output = Output.query.filter_by(unique_id=form_output.output_id.data).first() if output.output_type == 'wired' and int(form_output.output_pin.data) == 0: error.append(gettext("Cannot modulate output with a GPIO of 0")) elif form_output.on_submit.data: if output.output_type in ['wired', 'wireless_rpi_rf', 'command']: if float(form_output.sec_on.data) <= 0: error.append(gettext("Value must be greater than 0")) else: return_value = control.output_on(form_output.output_id.data, duration=float(form_output.sec_on.data)) flash(gettext("Output turned on for %(sec)s seconds: %(rvalue)s", sec=form_output.sec_on.data, rvalue=return_value), "success") if output.output_type == 'pwm': if int(form_output.output_pin.data) == 0: error.append(gettext("Invalid pin")) if output.pwm_hertz <= 0: error.append(gettext("PWM Hertz must be a positive value")) if float(form_output.pwm_duty_cycle_on.data) <= 0: error.append(gettext("PWM duty cycle must be a positive value")) if not error: return_value = control.output_on( form_output.output_id.data, duty_cycle=float(form_output.pwm_duty_cycle_on.data)) flash(gettext("PWM set to %(dc)s %% at %(hertz)s Hz: %(rvalue)s", dc=float(form_output.pwm_duty_cycle_on.data), hertz=output.pwm_hertz, rvalue=return_value), "success") elif form_output.turn_on.data: return_value = control.output_on(form_output.output_id.data, 0) flash(gettext("Output turned on: %(rvalue)s", rvalue=return_value), "success") elif form_output.turn_off.data: return_value = control.output_off(form_output.output_id.data) flash(gettext("Output turned off: %(rvalue)s", rvalue=return_value), "success") except ValueError as except_msg: error.append('{err}: {msg}'.format( err=gettext("Invalid value"), msg=except_msg)) except Exception as except_msg: error.append(except_msg) flash_success_errors(error, action, url_for('routes_page.page_output'))
class InputModule(AbstractInput): """ A sensor support class that measures the AM2315's humidity and temperature and calculates the dew point """ def __init__(self, input_dev, testing=False): super(InputModule, self).__init__() self.logger = logging.getLogger('mycodo.inputs.am2315') self.powered = False self.am = None if not testing: from mycodo.mycodo_client import DaemonControl self.logger = logging.getLogger( 'mycodo.am2315_{id}'.format(id=input_dev.unique_id.split('-')[0])) self.device_measurements = db_retrieve_table_daemon( DeviceMeasurements).filter( DeviceMeasurements.device_id == input_dev.unique_id) self.i2c_bus = input_dev.i2c_bus self.power_output_id = input_dev.power_output_id self.control = DaemonControl() self.start_sensor() self.am = AM2315(self.i2c_bus) def get_measurement(self): """ Gets the humidity and temperature """ return_dict = measurements_dict.copy() temperature = None humidity = None dew_point = None measurements_success = False # Ensure if the power pin turns off, it is turned back on if (self.power_output_id and db_retrieve_table_daemon(Output, unique_id=self.power_output_id) and self.control.output_state(self.power_output_id) == 'off'): self.logger.error( 'Sensor power output {rel} detected as being off. ' 'Turning on.'.format(rel=self.power_output_id)) self.start_sensor() time.sleep(2) # Try twice to get measurement. This prevents an anomaly where # the first measurement fails if the sensor has just been powered # for the first time. for _ in range(2): dew_point, humidity, temperature = self.return_measurements() if dew_point is not None: measurements_success = True break time.sleep(2) # Measurement failure, power cycle the sensor (if enabled) # Then try two more times to get a measurement if self.power_output_id and not measurements_success: self.stop_sensor() time.sleep(2) self.start_sensor() for _ in range(2): dew_point, humidity, temperature = self.return_measurements() if dew_point is not None: measurements_success = True break time.sleep(2) if measurements_success: if self.is_enabled(0): return_dict[0]['value'] = temperature if self.is_enabled(1): return_dict[1]['value'] = humidity if (self.is_enabled(2) and self.is_enabled(0) and self.is_enabled(1)): return_dict[2]['value'] = calculate_dewpoint( return_dict[0]['value'], return_dict[1]['value']) if (self.is_enabled(3) and self.is_enabled(0) and self.is_enabled(1)): return_dict[3]['value'] = calculate_vapor_pressure_deficit( return_dict[0]['value'], return_dict[1]['value']) return return_dict else: self.logger.debug("Could not acquire a measurement") def return_measurements(self): # Retry measurement if CRC fails for num_measure in range(3): humidity, temperature = self.am.data() if humidity is None: self.logger.debug( "Measurement {num} returned failed CRC".format( num=num_measure)) pass else: dew_pt = calculate_dewpoint(temperature, humidity) return dew_pt, humidity, temperature time.sleep(2) self.logger.error("All measurements returned failed CRC") return None, None, None def start_sensor(self): """ Turn the sensor on """ if self.power_output_id: self.logger.info("Turning on sensor") self.control.output_on(self.power_output_id, 0) time.sleep(2) self.powered = True def stop_sensor(self): """ Turn the sensor off """ if self.power_output_id: self.logger.info("Turning off sensor") self.control.output_off(self.power_output_id) self.powered = False
class PIDController(AbstractController, threading.Thread): """ Class to operate discrete PID controller in Mycodo """ def __init__(self, ready, unique_id): threading.Thread.__init__(self) super(PIDController, self).__init__(ready, unique_id=unique_id, name=__name__) self.unique_id = unique_id self.sample_rate = None self.control = DaemonControl() self.device_measurements = None self.device_id = None self.measurement_id = None self.raise_output_type = None self.lower_output_type = None self.log_level_debug = None self.PID_Controller = None self.control_variable = 0.0 self.derivator = 0.0 self.integrator = 0.0 self.error = 0.0 self.P_value = None self.I_value = None self.D_value = None self.lower_seconds_on = 0.0 self.raise_seconds_on = 0.0 self.lower_duty_cycle = 0.0 self.raise_duty_cycle = 0.0 self.last_time = None self.last_measurement = None self.last_measurement_success = False self.is_activated = None self.is_held = None self.is_paused = None self.measurement = None self.setpoint_tracking_type = None self.setpoint_tracking_id = None self.setpoint_tracking_max_age = None self.direction = None self.raise_output_id = None self.raise_min_duration = None self.raise_max_duration = None self.raise_min_off_duration = None self.lower_output_id = None self.lower_min_duration = None self.lower_max_duration = None self.lower_min_off_duration = None self.Kp = 0 self.Ki = 0 self.Kd = 0 self.integrator_min = None self.integrator_max = None self.period = 0 self.start_offset = 0 self.max_measure_age = None self.default_setpoint = None self.setpoint = 0 self.store_lower_as_negative = None self.first_start = None self.timer = None # Hysteresis options self.band = None self.allow_raising = False self.allow_lowering = False # PID Autotune self.autotune = None self.autotune_activated = False self.autotune_debug = False self.autotune_noiseband = 0 self.autotune_outstep = 0 self.autotune_timestamp = None # Check if a method is set for this PID self.method_type = None self.method_start_act = None self.method_start_time = None self.method_end_time = None def loop(self): if (self.method_start_act == 'Ended' and self.method_type == 'Duration'): self.stop_controller( ended_normally=False, deactivate_pid=True) self.logger.warning( "Method has ended. " "Activate the PID controller to start it again.") elif time.time() > self.timer: while time.time() > self.timer: self.timer = self.timer + self.period self.attempt_execute(self.check_pid) def run_finally(self): # Turn off output used in PID when the controller is deactivated if self.raise_output_id and self.direction in ['raise', 'both']: self.control.output_off( self.raise_output_id, trigger_conditionals=True) if self.lower_output_id and self.direction in ['lower', 'both']: self.control.output_off( self.lower_output_id, trigger_conditionals=True) def initialize_variables(self): """Set PID parameters""" self.sample_rate = db_retrieve_table_daemon( Misc, entry='first').sample_rate_controller_pid self.device_measurements = db_retrieve_table_daemon( DeviceMeasurements) pid = db_retrieve_table_daemon(PID, unique_id=self.unique_id) self.device_id = pid.measurement.split(',')[0] self.measurement_id = pid.measurement.split(',')[1] self.is_activated = pid.is_activated self.is_held = pid.is_held self.is_paused = pid.is_paused self.log_level_debug = pid.log_level_debug self.setpoint_tracking_type = pid.setpoint_tracking_type self.setpoint_tracking_id = pid.setpoint_tracking_id self.setpoint_tracking_max_age = pid.setpoint_tracking_max_age self.direction = pid.direction self.raise_output_id = pid.raise_output_id self.raise_min_duration = pid.raise_min_duration self.raise_max_duration = pid.raise_max_duration self.raise_min_off_duration = pid.raise_min_off_duration self.lower_output_id = pid.lower_output_id self.lower_min_duration = pid.lower_min_duration self.lower_max_duration = pid.lower_max_duration self.lower_min_off_duration = pid.lower_min_off_duration self.Kp = pid.p self.Ki = pid.i self.Kd = pid.d self.integrator_min = pid.integrator_min self.integrator_max = pid.integrator_max self.period = pid.period self.start_offset = pid.start_offset self.max_measure_age = pid.max_measure_age self.default_setpoint = pid.setpoint self.setpoint = pid.setpoint self.band = pid.band self.store_lower_as_negative = pid.store_lower_as_negative self.first_start = True self.timer = time.time() + self.start_offset # Autotune self.autotune_activated = pid.autotune_activated self.autotune_noiseband = pid.autotune_noiseband self.autotune_outstep = pid.autotune_outstep self.set_log_level_debug(self.log_level_debug) try: self.raise_output_type = db_retrieve_table_daemon( Output, unique_id=self.raise_output_id).output_type except AttributeError: self.raise_output_type = None try: self.lower_output_type = db_retrieve_table_daemon( Output, unique_id=self.lower_output_id).output_type except AttributeError: self.lower_output_type = None # Initialize PID Controller self.PID_Controller = PIDControl( self.period, self.Kp, self.Ki, self.Kd, integrator_min=self.integrator_min, integrator_max=self.integrator_max) # If activated, initialize PID Autotune if self.autotune_activated: self.autotune_timestamp = time.time() try: self.autotune = PIDAutotune( self.setpoint, out_step=self.autotune_outstep, sampletime=self.period, out_min=0, out_max=self.period, noiseband=self.autotune_noiseband) except Exception as msg: self.logger.error(msg) self.stop_controller(deactivate_pid=True) if self.setpoint_tracking_type == 'method' and self.setpoint_tracking_id != '': self.setup_method(self.setpoint_tracking_id) if self.is_paused: self.logger.info("Starting Paused") elif self.is_held: self.logger.info("Starting Held") self.logger.info("PID Settings: {}".format(self.pid_parameters_str())) return "success" def check_pid(self): """ Get measurement and apply to PID controller """ # If PID is active, retrieve measurement and update # the control variable. # A PID on hold will sustain the current output and # not update the control variable. if self.is_activated and (not self.is_paused or not self.is_held): self.get_last_measurement() if self.last_measurement_success: if self.setpoint_tracking_type == 'method' and self.setpoint_tracking_id != '': # Update setpoint using a method this_pid = db_retrieve_table_daemon( PID, unique_id=self.unique_id) setpoint, ended = calculate_method_setpoint( self.setpoint_tracking_id, PID, this_pid, Method, MethodData, self.logger) if ended: self.method_start_act = 'Ended' if setpoint is not None: self.setpoint = setpoint else: self.setpoint = self.default_setpoint if self.setpoint_tracking_type == 'input-math' and self.setpoint_tracking_id != '': # Update setpoint using an Input or Math device_id = self.setpoint_tracking_id.split(',')[0] measurement_id = self.setpoint_tracking_id.split(',')[1] measurement = get_measurement(measurement_id) if not measurement: return False, None last_measurement = read_last_influxdb( device_id, measurement.unit, measurement.measurement, measurement.channel, self.setpoint_tracking_max_age) if last_measurement[1] is not None: self.setpoint = last_measurement[1] else: self.logger.debug( "Could not find measurement for Setpoint " "Tracking. Max Age of {} exceeded for measuring " "device ID {} (measurement {})".format( self.setpoint_tracking_max_age, device_id, measurement_id)) self.setpoint = None # If autotune activated, determine control variable (output) from autotune if self.autotune_activated: if not self.autotune.run(self.last_measurement): self.control_variable = self.autotune.output if self.autotune_debug: self.logger.info('') self.logger.info("state: {}".format(self.autotune.state)) self.logger.info("output: {}".format(self.autotune.output)) else: # Autotune has finished timestamp = time.time() - self.autotune_timestamp self.logger.info('') self.logger.info('time: {0} min'.format(round(timestamp / 60))) self.logger.info('state: {0}'.format(self.autotune.state)) if self.autotune.state == PIDAutotune.STATE_SUCCEEDED: for rule in self.autotune.tuning_rules: params = self.autotune.get_pid_parameters(rule) self.logger.info('') self.logger.info('rule: {0}'.format(rule)) self.logger.info('Kp: {0}'.format(params.Kp)) self.logger.info('Ki: {0}'.format(params.Ki)) self.logger.info('Kd: {0}'.format(params.Kd)) self.stop_controller(deactivate_pid=True) else: # Calculate new control variable (output) from PID Controller # Original PID method self.control_variable = self.update_pid_output( self.last_measurement) # New PID method (untested) # self.control_variable = self.PID_Controller.calc( # self.last_measurement, self.setpoint) self.write_pid_values() # Write variables to database # Is PID in a state that allows manipulation of outputs if (self.is_activated and self.setpoint is not None and (not self.is_paused or self.is_held)): self.manipulate_output() def setup_method(self, method_id): """ Initialize method variables to start running a method """ self.setpoint_tracking_id = '' method = db_retrieve_table_daemon(Method, unique_id=method_id) method_data = db_retrieve_table_daemon(MethodData) method_data = method_data.filter(MethodData.method_id == method_id) method_data_repeat = method_data.filter( MethodData.duration_sec == 0).first() pid = db_retrieve_table_daemon(PID, unique_id=self.unique_id) self.method_type = method.method_type self.method_start_act = pid.method_start_time self.method_start_time = None self.method_end_time = None if self.method_type == 'Duration': if self.method_start_act == 'Ended': # Method has ended and hasn't been instructed to begin again pass elif (self.method_start_act == 'Ready' or self.method_start_act is None): # Method has been instructed to begin now = datetime.datetime.now() self.method_start_time = now if method_data_repeat and method_data_repeat.duration_end: self.method_end_time = now + datetime.timedelta( seconds=float(method_data_repeat.duration_end)) with session_scope(MYCODO_DB_PATH) as db_session: mod_pid = db_session.query(PID).filter( PID.unique_id == self.unique_id).first() mod_pid.method_start_time = self.method_start_time mod_pid.method_end_time = self.method_end_time db_session.commit() else: # Method neither instructed to begin or not to # Likely there was a daemon restart ot power failure # Resume method with saved start_time self.method_start_time = datetime.datetime.strptime( str(pid.method_start_time), '%Y-%m-%d %H:%M:%S.%f') if method_data_repeat and method_data_repeat.duration_end: self.method_end_time = datetime.datetime.strptime( str(pid.method_end_time), '%Y-%m-%d %H:%M:%S.%f') if self.method_end_time > datetime.datetime.now(): self.logger.warning( "Resuming method {id}: started {start}, " "ends {end}".format( id=method_id, start=self.method_start_time, end=self.method_end_time)) else: self.method_start_act = 'Ended' else: self.method_start_act = 'Ended' self.setpoint_tracking_id = method_id self.logger.debug("Method enabled: {id}".format(id=self.setpoint_tracking_id)) def write_pid_values(self): """ Write PID values to the measurement database """ if self.band: setpoint_band_lower = self.setpoint - self.band setpoint_band_upper = self.setpoint + self.band else: setpoint_band_lower = None setpoint_band_upper = None list_measurements = [ self.setpoint, setpoint_band_lower, setpoint_band_upper, self.P_value, self.I_value, self.D_value ] measurement_dict = {} measurements = self.device_measurements.filter( DeviceMeasurements.device_id == self.unique_id).all() for each_channel, each_measurement in enumerate(measurements): if (each_measurement.channel not in measurement_dict and each_measurement.channel < len(list_measurements)): # If setpoint, get unit from PID measurement if each_measurement.measurement_type == 'setpoint': setpoint_pid = db_retrieve_table_daemon( PID, unique_id=each_measurement.device_id) if setpoint_pid and ',' in setpoint_pid.measurement: pid_measurement = setpoint_pid.measurement.split(',')[1] setpoint_measurement = db_retrieve_table_daemon( DeviceMeasurements, unique_id=pid_measurement) if setpoint_measurement: conversion = db_retrieve_table_daemon( Conversion, unique_id=setpoint_measurement.conversion_id) _, unit, _ = return_measurement_info( setpoint_measurement, conversion) measurement_dict[each_channel] = { 'measurement': each_measurement.measurement, 'unit': unit, 'value': list_measurements[each_channel] } else: measurement_dict[each_channel] = { 'measurement': each_measurement.measurement, 'unit': each_measurement.unit, 'value': list_measurements[each_channel] } add_measurements_influxdb(self.unique_id, measurement_dict) def update_pid_output(self, current_value): """ Calculate PID output value from reference input and feedback :return: Manipulated, or control, variable. This is the PID output. :rtype: float :param current_value: The input, or process, variable (the actual measured condition by the input) :type current_value: float """ # Determine if hysteresis is enabled and if the PID should be applied setpoint = self.check_hysteresis(current_value) if setpoint is None: # Prevent PID variables form being manipulated and # restrict PID from operating. return 0 self.error = setpoint - current_value # Calculate P-value self.P_value = self.Kp * self.error # Calculate I-value self.integrator += self.error # First method for managing integrator if self.integrator > self.integrator_max: self.integrator = self.integrator_max elif self.integrator < self.integrator_min: self.integrator = self.integrator_min # Second method for regulating integrator # if self.period is not None: # if self.integrator * self.Ki > self.period: # self.integrator = self.period / self.Ki # elif self.integrator * self.Ki < -self.period: # self.integrator = -self.period / self.Ki self.I_value = self.integrator * self.Ki # Prevent large initial D-value if self.first_start: self.derivator = self.error self.first_start = False # Calculate D-value self.D_value = self.Kd * (self.error - self.derivator) self.derivator = self.error # Produce output form P, I, and D values pid_value = self.P_value + self.I_value + self.D_value self.logger.debug( "PID: Input: {inp}, " "Output: P: {p}, I: {i}, D: {d}, Out: {o}".format( inp=current_value, p=self.P_value, i=self.I_value, d=self.D_value, o=pid_value)) return pid_value def check_hysteresis(self, measure): """ Determine if hysteresis is enabled and if the PID should be applied :return: float if the setpoint if the PID should be applied, None to restrict the PID :rtype: float or None :param measure: The PID input (or process) variable :type measure: float """ if self.band == 0: # If band is disabled, return setpoint return self.setpoint band_min = self.setpoint - self.band band_max = self.setpoint + self.band if self.direction == 'raise': if (measure < band_min or (band_min < measure < band_max and self.allow_raising)): self.allow_raising = True setpoint = band_max # New setpoint return setpoint # Apply the PID elif measure > band_max: self.allow_raising = False return None # Restrict the PID elif self.direction == 'lower': if (measure > band_max or (band_min < measure < band_max and self.allow_lowering)): self.allow_lowering = True setpoint = band_min # New setpoint return setpoint # Apply the PID elif measure < band_min: self.allow_lowering = False return None # Restrict the PID elif self.direction == 'both': if measure < band_min: setpoint = band_min # New setpoint if not self.allow_raising: # Reset integrator and derivator upon direction switch self.integrator = 0.0 self.derivator = 0.0 self.allow_raising = True self.allow_lowering = False elif measure > band_max: setpoint = band_max # New setpoint if not self.allow_lowering: # Reset integrator and derivator upon direction switch self.integrator = 0.0 self.derivator = 0.0 self.allow_raising = False self.allow_lowering = True else: return None # Restrict the PID return setpoint # Apply the PID def get_last_measurement(self): """ Retrieve the latest input measurement from InfluxDB :rtype: None """ self.last_measurement_success = False # Get latest measurement from influxdb try: device_measurement = get_measurement(self.measurement_id) if device_measurement: conversion = db_retrieve_table_daemon( Conversion, unique_id=device_measurement.conversion_id) else: conversion = None channel, unit, measurement = return_measurement_info( device_measurement, conversion) self.last_measurement = read_last_influxdb( self.device_id, unit, measurement, channel, int(self.max_measure_age)) if self.last_measurement: self.last_time = self.last_measurement[0] self.last_measurement = self.last_measurement[1] utc_dt = datetime.datetime.strptime( self.last_time.split(".")[0], '%Y-%m-%dT%H:%M:%S') utc_timestamp = calendar.timegm(utc_dt.timetuple()) local_timestamp = str(datetime.datetime.fromtimestamp(utc_timestamp)) self.logger.debug("Latest (CH{ch}, Unit: {unit}): {last} @ {ts}".format( ch=channel, unit=unit, last=self.last_measurement, ts=local_timestamp)) if calendar.timegm(time.gmtime()) - utc_timestamp > self.max_measure_age: self.logger.error( "Last measurement was {last_sec} seconds ago, however" " the maximum measurement age is set to {max_sec}" " seconds.".format( last_sec=calendar.timegm(time.gmtime()) - utc_timestamp, max_sec=self.max_measure_age )) self.last_measurement_success = True else: self.logger.warning("No data returned from influxdb") except requests.ConnectionError: self.logger.error("Failed to read measurement from the " "influxdb database: Could not connect.") except Exception as except_msg: self.logger.exception( "Exception while reading measurement from the influxdb " "database: {err}".format(err=except_msg)) def manipulate_output(self): """ Activate output based on PID control variable and whether the manipulation directive is to raise, lower, or both. :rtype: None """ # If the last measurement was able to be retrieved and was entered within the past minute if self.last_measurement_success: # # PID control variable is positive, indicating a desire to raise # the environmental condition # if self.direction in ['raise', 'both'] and self.raise_output_id: if self.control_variable > 0: # Determine if the output should be PWM or a duration if self.raise_output_type in OUTPUTS_PWM: self.raise_duty_cycle = float("{0:.1f}".format( self.control_var_to_duty_cycle(self.control_variable))) # Ensure the duty cycle doesn't exceed the min/max if (self.raise_max_duration and self.raise_duty_cycle > self.raise_max_duration): self.raise_duty_cycle = self.raise_max_duration elif (self.raise_min_duration and self.raise_duty_cycle < self.raise_min_duration): self.raise_duty_cycle = self.raise_min_duration self.logger.debug( "Setpoint: {sp}, Control Variable: {cv}, Output: PWM output " "{id} to {dc:.1f}%".format( sp=self.setpoint, cv=self.control_variable, id=self.raise_output_id, dc=self.raise_duty_cycle)) # Activate pwm with calculated duty cycle self.control.output_on( self.raise_output_id, duty_cycle=self.raise_duty_cycle) self.write_pid_output_influxdb( 'percent', 'duty_cycle', 7, self.control_var_to_duty_cycle(self.control_variable)) elif self.raise_output_type in ['command', 'python', 'wired', 'wireless_rpi_rf']: # Ensure the output on duration doesn't exceed the set maximum if (self.raise_max_duration and self.control_variable > self.raise_max_duration): self.raise_seconds_on = self.raise_max_duration else: self.raise_seconds_on = float("{0:.2f}".format( self.control_variable)) if self.raise_seconds_on > self.raise_min_duration: # Activate raise_output for a duration self.logger.debug( "Setpoint: {sp} Output: {cv} to output " "{id}".format( sp=self.setpoint, cv=self.control_variable, id=self.raise_output_id)) self.control.output_on( self.raise_output_id, amount=self.raise_seconds_on, min_off=self.raise_min_off_duration) self.write_pid_output_influxdb( 's', 'duration_time', 6, self.control_variable) elif self.raise_output_type == 'atlas_ezo_pmp': # Activate raise_output for a volume (ml) self.logger.debug( "Setpoint: {sp} Output: {cv} ml to output " "{id}".format( sp=self.setpoint, cv=self.control_variable, id=self.raise_output_id)) self.control.output_on( self.raise_output_id, amount=self.control_variable, min_off=self.raise_min_off_duration) self.write_pid_output_influxdb( 's', 'duration_time', 6, self.control_variable) else: # Turn PWM Off if PWM Output if self.raise_output_type in OUTPUTS_PWM: self.control.output_on( self.raise_output_id, duty_cycle=0) # # PID control variable is negative, indicating a desire to lower # the environmental condition # if self.direction in ['lower', 'both'] and self.lower_output_id: if self.control_variable < 0: # Determine if the output should be PWM or a duration if self.lower_output_type in OUTPUTS_PWM: self.lower_duty_cycle = float("{0:.1f}".format( self.control_var_to_duty_cycle(abs(self.control_variable)))) # Ensure the duty cycle doesn't exceed the min/max if (self.lower_max_duration and self.lower_duty_cycle > self.lower_max_duration): self.lower_duty_cycle = self.lower_max_duration elif (self.lower_min_duration and self.lower_duty_cycle < self.lower_min_duration): self.lower_duty_cycle = self.lower_min_duration self.logger.debug( "Setpoint: {sp}, Control Variable: {cv}, " "Output: PWM output {id} to {dc:.1f}%".format( sp=self.setpoint, cv=self.control_variable, id=self.lower_output_id, dc=self.lower_duty_cycle)) if self.store_lower_as_negative: stored_duty_cycle = -abs(self.lower_duty_cycle) stored_control_variable = -self.control_var_to_duty_cycle(abs(self.control_variable)) else: stored_duty_cycle = abs(self.lower_duty_cycle) stored_control_variable = self.control_var_to_duty_cycle(abs(self.control_variable)) # Activate pwm with calculated duty cycle self.control.output_on( self.lower_output_id, duty_cycle=stored_duty_cycle) self.write_pid_output_influxdb( 'percent', 'duty_cycle', 7, stored_control_variable) elif self.lower_output_type in ['command', 'python', 'wired', 'wireless_rpi_rf']: # Ensure the output on duration doesn't exceed the set maximum if (self.lower_max_duration and abs(self.control_variable) > self.lower_max_duration): self.lower_seconds_on = self.lower_max_duration else: self.lower_seconds_on = float("{0:.2f}".format( abs(self.control_variable))) if self.store_lower_as_negative: stored_amount_on = -abs(self.lower_seconds_on) stored_control_variable = -abs(self.control_variable) else: stored_amount_on = abs(self.lower_seconds_on) stored_control_variable = abs(self.control_variable) if self.lower_seconds_on > self.lower_min_duration: # Activate lower_output for a duration self.logger.debug("Setpoint: {sp} Output: {cv} to " "output {id}".format( sp=self.setpoint, cv=self.control_variable, id=self.lower_output_id)) self.control.output_on( self.lower_output_id, amount=stored_amount_on, min_off=self.lower_min_off_duration) self.write_pid_output_influxdb( 's', 'duration_time', 6, stored_control_variable) elif self.lower_output_type == 'atlas_ezo_pmp': if self.store_lower_as_negative: stored_amount_on = -abs(self.lower_seconds_on) stored_control_variable = -abs(self.control_variable) else: stored_amount_on = abs(self.lower_seconds_on) stored_control_variable = abs(self.control_variable) # Activate lower_output for a volume (ml) self.logger.debug("Setpoint: {sp} Output: {cv} to " "output {id}".format( sp=self.setpoint, cv=self.control_variable, id=self.lower_output_id)) self.control.output_on( self.lower_output_id, amount=stored_amount_on, min_off=self.lower_min_off_duration) self.write_pid_output_influxdb( 's', 'duration_time', 6, stored_control_variable) else: # Turn PWM Off if PWM Output if self.lower_output_type in OUTPUTS_PWM: self.control.output_on( self.lower_output_id, duty_cycle=0) else: self.logger.debug("Last measurement unsuccessful. Turning outputs off.") if self.direction in ['raise', 'both'] and self.raise_output_id: self.control.output_off( self.raise_output_id) if self.direction in ['lower', 'both'] and self.lower_output_id: self.control.output_off( self.lower_output_id) def pid_parameters_str(self): return "Device ID: {did}, " \ "Measurement ID: {mid}, " \ "Direction: {dir}, " \ "Period: {per}, " \ "Setpoint: {sp}, " \ "Band: {band}, " \ "Kp: {kp}, " \ "Ki: {ki}, " \ "Kd: {kd}, " \ "Integrator Min: {imn}, " \ "Integrator Max {imx}, " \ "Output Raise: {opr}, " \ "Output Raise Min On: {oprmnon}, " \ "Output Raise Max On: {oprmxon}, " \ "Output Raise Min Off: {oprmnoff}, " \ "Output Lower: {opl}, " \ "Output Lower Min On: {oplmnon}, " \ "Output Lower Max On: {oplmxon}, " \ "Output Lower Min Off: {oplmnoff}, " \ "Setpoint Tracking Type: {sptt}, " \ "Setpoint Tracking ID: {spt}".format( did=self.device_id, mid=self.measurement_id, dir=self.direction, per=self.period, sp=self.setpoint, band=self.band, kp=self.Kp, ki=self.Ki, kd=self.Kd, imn=self.integrator_min, imx=self.integrator_max, opr=self.raise_output_id, oprmnon=self.raise_min_duration, oprmxon=self.raise_max_duration, oprmnoff=self.raise_min_off_duration, opl=self.lower_output_id, oplmnon=self.lower_min_duration, oplmxon=self.lower_max_duration, oplmnoff=self.lower_min_off_duration, sptt=self.setpoint_tracking_type, spt=self.setpoint_tracking_id) def control_var_to_duty_cycle(self, control_variable): # Convert control variable to duty cycle if control_variable > self.period: return 100.0 else: return float((control_variable / self.period) * 100) def write_pid_output_influxdb(self, unit, measurement, channel, value): write_pid_out_db = threading.Thread( target=write_influxdb_value, args=(self.unique_id, unit, value,), kwargs={'measure': measurement, 'channel': channel}) write_pid_out_db.start() def pid_mod(self): if self.initialize_variables(): return "success" else: return "error" def pid_hold(self): self.is_held = True self.logger.info("Hold") return "success" def pid_pause(self): self.is_paused = True self.logger.info("Pause") return "success" def pid_resume(self): self.is_activated = True self.is_held = False self.is_paused = False self.logger.info("Resume") return "success" def set_setpoint(self, setpoint): """ Set the setpoint of PID """ self.setpoint = float(setpoint) with session_scope(MYCODO_DB_PATH) as db_session: mod_pid = db_session.query(PID).filter( PID.unique_id == self.unique_id).first() mod_pid.setpoint = setpoint db_session.commit() return "Setpoint set to {sp}".format(sp=setpoint) def set_method(self, method_id): """ Set the method of PID """ with session_scope(MYCODO_DB_PATH) as db_session: mod_pid = db_session.query(PID).filter( PID.unique_id == self.unique_id).first() mod_pid.setpoint_tracking_id = method_id if method_id == '': self.setpoint_tracking_id = '' db_session.commit() else: mod_pid.method_start_time = 'Ready' mod_pid.method_end_time = None db_session.commit() self.setup_method(method_id) return "Method set to {me}".format(me=method_id) def set_integrator(self, integrator): """ Set the integrator of the controller """ self.integrator = float(integrator) return "Integrator set to {i}".format(i=self.integrator) def set_derivator(self, derivator): """ Set the derivator of the controller """ self.derivator = float(derivator) return "Derivator set to {d}".format(d=self.derivator) def set_kp(self, p): """ Set Kp gain of the controller """ self.Kp = float(p) with session_scope(MYCODO_DB_PATH) as db_session: mod_pid = db_session.query(PID).filter( PID.unique_id == self.unique_id).first() mod_pid.p = p db_session.commit() return "Kp set to {kp}".format(kp=self.Kp) def set_ki(self, i): """ Set Ki gain of the controller """ self.Ki = float(i) with session_scope(MYCODO_DB_PATH) as db_session: mod_pid = db_session.query(PID).filter( PID.unique_id == self.unique_id).first() mod_pid.i = i db_session.commit() return "Ki set to {ki}".format(ki=self.Ki) def set_kd(self, d): """ Set Kd gain of the controller """ self.Kd = float(d) with session_scope(MYCODO_DB_PATH) as db_session: mod_pid = db_session.query(PID).filter( PID.unique_id == self.unique_id).first() mod_pid.d = d db_session.commit() return "Kd set to {kd}".format(kd=self.Kd) def get_setpoint(self): return self.setpoint def get_error(self): return self.error def get_integrator(self): return self.integrator def get_derivator(self): return self.derivator def get_kp(self): return self.Kp def get_ki(self): return self.Ki def get_kd(self): return self.Kd def stop_controller(self, ended_normally=True, deactivate_pid=False): self.thread_shutdown_timer = timeit.default_timer() self.running = False # Unset method start time if (self.setpoint_tracking_type == 'method' and self.setpoint_tracking_id != '' and ended_normally): with session_scope(MYCODO_DB_PATH) as db_session: mod_pid = db_session.query(PID).filter( PID.unique_id == self.unique_id).first() mod_pid.method_start_time = 'Ended' mod_pid.method_end_time = None db_session.commit() # Deactivate PID and Autotune if deactivate_pid: with session_scope(MYCODO_DB_PATH) as db_session: mod_pid = db_session.query(PID).filter( PID.unique_id == self.unique_id).first() mod_pid.is_activated = False mod_pid.autotune_activated = False db_session.commit()
def camera_record(record_type, unique_id, duration_sec=None, tmp_filename=None): """ Record still image from cameras :param record_type: :param unique_id: :param duration_sec: :param tmp_filename: :return: """ daemon_control = None settings = db_retrieve_table_daemon(Camera, unique_id=unique_id) timestamp = datetime.datetime.now().strftime('%Y-%m-%d_%H-%M-%S') assure_path_exists(PATH_CAMERAS) camera_path = assure_path_exists( os.path.join(PATH_CAMERAS, '{uid}'.format(uid=settings.unique_id))) if record_type == 'photo': if settings.path_still != '': save_path = settings.path_still else: save_path = assure_path_exists(os.path.join(camera_path, 'still')) filename = 'Still-{cam_id}-{cam}-{ts}.jpg'.format( cam_id=settings.id, cam=settings.name, ts=timestamp).replace(" ", "_") elif record_type == 'timelapse': if settings.path_timelapse != '': save_path = settings.path_timelapse else: save_path = assure_path_exists( os.path.join(camera_path, 'timelapse')) start = datetime.datetime.fromtimestamp( settings.timelapse_start_time).strftime("%Y-%m-%d_%H-%M-%S") filename = 'Timelapse-{cam_id}-{cam}-{st}-img-{cn:05d}.jpg'.format( cam_id=settings.id, cam=settings.name, st=start, cn=settings.timelapse_capture_number).replace(" ", "_") elif record_type == 'video': if settings.path_video != '': save_path = settings.path_video else: save_path = assure_path_exists(os.path.join(camera_path, 'video')) filename = 'Video-{cam}-{ts}.h264'.format(cam=settings.name, ts=timestamp).replace( " ", "_") else: return assure_path_exists(save_path) if tmp_filename: filename = tmp_filename path_file = os.path.join(save_path, filename) # Turn on output, if configured if settings.output_id: daemon_control = DaemonControl() daemon_control.output_on(settings.output_id) # Pause while the output remains on for the specified duration. # Used for instance to allow fluorescent lights to fully turn on before # capturing an image. if settings.output_duration: time.sleep(settings.output_duration) if settings.library == 'picamera': # Try 5 times to access the pi camera (in case another process is accessing it) for _ in range(5): try: with picamera.PiCamera() as camera: camera.resolution = (settings.width, settings.height) camera.hflip = settings.hflip camera.vflip = settings.vflip camera.rotation = settings.rotation camera.brightness = int(settings.brightness) camera.contrast = int(settings.contrast) camera.exposure_compensation = int(settings.exposure) camera.saturation = int(settings.saturation) camera.start_preview() time.sleep(2) # Camera warm-up time if record_type in ['photo', 'timelapse']: camera.capture(path_file, use_video_port=False) elif record_type == 'video': camera.start_recording(path_file, format='h264', quality=20) camera.wait_recording(duration_sec) camera.stop_recording() else: return break except picamera.exc.PiCameraMMALError: logger.error( "The camera is already open by picamera. Retrying 4 times." ) time.sleep(1) elif settings.library == 'fswebcam': cmd = "/usr/bin/fswebcam --device {dev} --resolution {w}x{h} --set brightness={bt}% " \ "--no-banner --save {file}".format(dev=settings.device, w=settings.width, h=settings.height, bt=settings.brightness, file=path_file) if settings.hflip: cmd += " --flip h" if settings.vflip: cmd += " --flip h" if settings.rotation: cmd += " --rotate {angle}".format(angle=settings.rotation) if settings.custom_options: cmd += " " + settings.custom_options out, err, status = cmd_output(cmd, stdout_pipe=False) # logger.error("TEST01: {}; {}; {}; {}".format(cmd, out, err, status)) # Turn off output, if configured if settings.output_id and daemon_control: daemon_control.output_off(settings.output_id) try: set_user_grp(path_file, 'mycodo', 'mycodo') return save_path, filename except Exception as e: logger.exception( "Exception raised in 'camera_record' when setting user grp: " "{err}".format(err=e))
class CustomModule(AbstractController, threading.Thread): """ Class to operate custom controller """ def __init__(self, ready, unique_id, testing=False): threading.Thread.__init__(self) super(CustomModule, self).__init__(ready, unique_id=unique_id, name=__name__) self.unique_id = unique_id self.log_level_debug = None self.control_variable = None self.timestamp = None self.timer = None self.control = DaemonControl() self.outputIsOn = False self.timer_loop = time.time() # Initialize custom options self.measurement_device_id = None self.measurement_measurement_id = None self.output_device_id = None self.output_measurement_id = None self.output_channel_id = None self.setpoint = None self.hysteresis = None self.direction = None self.output_channel = None self.update_period = None # Set custom options custom_function = db_retrieve_table_daemon(CustomController, unique_id=unique_id) self.setup_custom_options(FUNCTION_INFORMATION['custom_options'], custom_function) self.output_channel = self.get_output_channel_from_channel_id( self.output_channel_id) self.initialize_variables() def initialize_variables(self): controller = db_retrieve_table_daemon(CustomController, unique_id=self.unique_id) self.log_level_debug = controller.log_level_debug self.set_log_level_debug(self.log_level_debug) self.timestamp = time.time() def run(self): try: if self.output_channel is None: self.logger.error( "Cannot start bang-bang controller: Could not find output channel." ) self.deactivate_self() return self.logger.info("Activated in {:.1f} ms".format( (timeit.default_timer() - self.thread_startup_timer) * 1000)) self.ready.set() self.running = True self.timer = time.time() self.logger.info( "Bang-Bang controller started with options: " "Measurement Device: {}, Measurement: {}, Output: {}, " "Output_Channel: {}, Setpoint: {}, Hysteresis: {}, " "Direction: {}, Period: {}".format( self.measurement_device_id, self.measurement_measurement_id, self.output_device_id, self.output_channel, self.setpoint, self.hysteresis, self.direction, self.update_period)) # Start a loop while self.running: if self.timer_loop < time.time(): while self.timer_loop < time.time(): self.timer_loop += self.update_period self.loop() time.sleep(0.1) except: self.logger.exception("Run Error") finally: self.run_finally() self.running = False if self.thread_shutdown_timer: self.logger.info("Deactivated in {:.1f} ms".format( (timeit.default_timer() - self.thread_shutdown_timer) * 1000)) else: self.logger.error("Deactivated unexpectedly") def loop(self): last_measurement = self.get_last_measurement( self.measurement_device_id, self.measurement_measurement_id)[1] outputState = self.control.output_state(self.output_device_id, self.output_channel) self.logger.info("Input: {}, output: {}, target: {}, hyst: {}".format( last_measurement, outputState, self.setpoint, self.hysteresis)) if self.direction == 'raise': if outputState == 'on': # looking to turn output off if last_measurement > (self.setpoint + self.hysteresis): self.control.output_off(self.output_device_id, output_channel=self.output_channel) else: # looking to turn output on if last_measurement < (self.setpoint - self.hysteresis): self.control.output_on(self.output_device_id, output_channel=self.output_channel) elif self.direction == 'lower': if outputState == 'on': # looking to turn output off if last_measurement < (self.setpoint - self.hysteresis): self.control.output_off(self.output_device_id, output_channel=self.output_channel) else: # looking to turn output on if last_measurement > (self.setpoint + self.hysteresis): self.control.output_on(self.output_device_id, output_channel=self.output_channel) else: self.logger.info("Unknown controller direction: {}".format( self.direction)) def deactivate_self(self): self.logger.info("Deactivating bang-bang controller") with session_scope(MYCODO_DB_PATH) as new_session: mod_cont = new_session.query(CustomController).filter( CustomController.unique_id == self.unique_id).first() mod_cont.is_activated = False new_session.commit() deactivate_controller = threading.Thread( target=self.control.controller_deactivate, args=(self.unique_id, )) deactivate_controller.start() def pre_stop(self): self.control.output_off(self.output_device_id, self.output_channel)
class InputModule(AbstractInput): """ A sensor support class that measures the AM2315's humidity and temperature and calculates the dew point """ def __init__(self, input_dev, testing=False): super(InputModule, self).__init__(input_dev, testing=testing, name=__name__) self.sensor = None self.powered = False self.control = None self.power_output_id = None if not testing: self.initialize_input() def initialize_input(self): from mycodo.mycodo_client import DaemonControl self.power_output_id = self.input_dev.power_output_id self.control = DaemonControl() self.start_input() self.sensor = AM2315(self.input_dev.i2c_bus) def get_measurement(self): """ Gets the humidity and temperature """ if not self.sensor: self.logger.error("Input not set up") return self.return_dict = copy.deepcopy(measurements_dict) temperature = None humidity = None dew_point = None measurements_success = False # Ensure if the power pin turns off, it is turned back on if (self.power_output_id and db_retrieve_table_daemon( Output, unique_id=self.power_output_id) and self.control.output_state(self.power_output_id) == 'off'): self.logger.error( 'Sensor power output {rel} detected as being off. Turning on.'. format(rel=self.power_output_id)) self.start_input() time.sleep(2) # Try twice to get measurement. This prevents an anomaly where # the first measurement fails if the sensor has just been powered # for the first time. for _ in range(2): dew_point, humidity, temperature = self.return_measurements() if dew_point is not None: measurements_success = True break time.sleep(2) # Measurement failure, power cycle the sensor (if enabled) # Then try two more times to get a measurement if self.power_output_id and not measurements_success: self.stop_input() time.sleep(2) self.start_input() for _ in range(2): dew_point, humidity, temperature = self.return_measurements() if dew_point is not None: measurements_success = True break time.sleep(2) if measurements_success: self.value_set(0, temperature) self.value_set(1, humidity) if self.is_enabled(0) and self.is_enabled(1): self.value_set( 2, calculate_dewpoint(self.value_get(0), self.value_get(1))) self.value_set( 3, calculate_vapor_pressure_deficit(self.value_get(0), self.value_get(1))) return self.return_dict else: self.logger.debug("Could not acquire a measurement") def return_measurements(self): # Retry measurement if CRC fails for num_measure in range(3): humidity, temperature = self.sensor.data() if humidity is None: self.logger.debug( "Measurement {num} returned failed CRC".format( num=num_measure)) else: dew_pt = calculate_dewpoint(temperature, humidity) return dew_pt, humidity, temperature time.sleep(2) self.logger.error("All measurements returned failed CRC") return None, None, None def start_input(self): """ Turn the sensor on """ if self.power_output_id: self.logger.info("Turning on sensor") self.control.output_on(self.power_output_id) time.sleep(2) self.powered = True def stop_input(self): """ Turn the sensor off """ if self.power_output_id: self.logger.info("Turning off sensor") self.control.output_off(self.power_output_id) self.powered = False
class PIDController(AbstractController, threading.Thread): """ Class to operate discrete PID controller in Mycodo """ def __init__(self, ready, unique_id): threading.Thread.__init__(self) super(PIDController, self).__init__(ready, unique_id=unique_id, name=__name__) self.unique_id = unique_id self.sample_rate = None self.dict_outputs = None self.control = DaemonControl() self.PID_Controller = None self.setpoint = None self.device_measurements = None self.device_id = None self.measurement_id = None self.log_level_debug = None self.lower_seconds_on = 0.0 self.raise_seconds_on = 0.0 self.lower_duty_cycle = 0.0 self.raise_duty_cycle = 0.0 self.last_time = None self.last_measurement = None self.last_measurement_success = False self.is_activated = None self.is_held = None self.is_paused = None self.measurement = None self.setpoint_tracking_type = None self.setpoint_tracking_id = None self.setpoint_tracking_max_age = None self.raise_output_id = None self.raise_output_channel_id = None self.raise_output_channel = None self.raise_output_type = None self.raise_min_duration = None self.raise_max_duration = None self.raise_min_off_duration = None self.raise_always_min_pwm = None self.lower_output_id = None self.lower_output_channel_id = None self.lower_output_channel = None self.lower_output_type = None self.lower_min_duration = None self.lower_max_duration = None self.lower_min_off_duration = None self.lower_always_min_pwm = None self.period = 0 self.start_offset = 0 self.max_measure_age = None self.store_lower_as_negative = None self.timer = 0 # Check if a method is set for this PID self.method_type = None self.method_start_act = None self.method_start_time = None self.method_end_time = None def loop(self): if self.method_start_act == 'Ended' and self.method_type == 'Duration': self.stop_controller(ended_normally=False, deactivate_pid=True) self.logger.warning( "Method has ended. Activate the PID controller to start it again." ) elif time.time() > self.timer: while time.time() > self.timer: self.timer = self.timer + self.period self.attempt_execute(self.check_pid) def run_finally(self): # Turn off output used in PID when the controller is deactivated if self.raise_output_id and self.PID_Controller.direction in [ 'raise', 'both' ]: self.control.output_off(self.raise_output_id, output_channel=self.raise_output_channel, trigger_conditionals=True) if self.lower_output_id and self.PID_Controller.direction in [ 'lower', 'both' ]: self.control.output_off(self.lower_output_id, output_channel=self.lower_output_channel, trigger_conditionals=True) def initialize_variables(self): """Set PID parameters""" self.dict_outputs = parse_output_information() self.sample_rate = db_retrieve_table_daemon( Misc, entry='first').sample_rate_controller_pid self.device_measurements = db_retrieve_table_daemon(DeviceMeasurements) pid = db_retrieve_table_daemon(PID, unique_id=self.unique_id) self.log_level_debug = pid.log_level_debug self.set_log_level_debug(self.log_level_debug) self.device_id = pid.measurement.split(',')[0] self.measurement_id = pid.measurement.split(',')[1] self.is_activated = pid.is_activated self.is_held = pid.is_held self.is_paused = pid.is_paused self.setpoint_tracking_type = pid.setpoint_tracking_type self.setpoint_tracking_id = pid.setpoint_tracking_id self.setpoint_tracking_max_age = pid.setpoint_tracking_max_age if pid.raise_output_id and "," in pid.raise_output_id: self.raise_output_id = pid.raise_output_id.split(",")[0] self.raise_output_channel_id = pid.raise_output_id.split(",")[1] output_channel = db_retrieve_table_daemon( OutputChannel, unique_id=self.raise_output_channel_id) self.raise_output_channel = output_channel.channel self.raise_output_type = pid.raise_output_type self.raise_min_duration = pid.raise_min_duration self.raise_max_duration = pid.raise_max_duration self.raise_min_off_duration = pid.raise_min_off_duration self.raise_always_min_pwm = pid.raise_always_min_pwm if pid.lower_output_id and "," in pid.lower_output_id: self.lower_output_id = pid.lower_output_id.split(",")[0] self.lower_output_channel_id = pid.lower_output_id.split(",")[1] output_channel = db_retrieve_table_daemon( OutputChannel, unique_id=self.lower_output_channel_id) self.lower_output_channel = output_channel.channel self.lower_output_type = pid.lower_output_type self.lower_min_duration = pid.lower_min_duration self.lower_max_duration = pid.lower_max_duration self.lower_min_off_duration = pid.lower_min_off_duration self.lower_always_min_pwm = pid.lower_always_min_pwm self.period = pid.period self.start_offset = pid.start_offset self.max_measure_age = pid.max_measure_age self.store_lower_as_negative = pid.store_lower_as_negative self.timer = time.time() + self.start_offset self.setpoint = pid.setpoint # Initialize PID Controller if self.PID_Controller is None: self.PID_Controller = PIDControl(self.logger, pid.setpoint, pid.p, pid.i, pid.d, pid.direction, pid.band, pid.integrator_min, pid.integrator_max) else: # Set PID options self.PID_Controller.setpoint = pid.setpoint self.PID_Controller.Kp = pid.p self.PID_Controller.Ki = pid.i self.PID_Controller.Kd = pid.d self.PID_Controller.direction = pid.direction self.PID_Controller.band = pid.band self.PID_Controller.integrator_min = pid.integrator_min self.PID_Controller.integrator_max = pid.integrator_max self.PID_Controller.first_start = True if self.setpoint_tracking_type == 'method' and self.setpoint_tracking_id != '': self.setup_method(self.setpoint_tracking_id) if self.is_paused: self.logger.info("Starting Paused") elif self.is_held: self.logger.info("Starting Held") self.logger.info("PID Settings: {}".format(self.pid_parameters_str())) return "success" def check_pid(self): """ Get measurement and apply to PID controller """ # If PID is active, retrieve measurement and update # the control variable. # A PID on hold will sustain the current output and # not update the control variable. if self.is_activated and (not self.is_paused or not self.is_held): self.get_last_measurement_pid() if self.last_measurement_success: if self.setpoint_tracking_type == 'method' and self.setpoint_tracking_id != '': # Update setpoint using a method this_pid = db_retrieve_table_daemon( PID, unique_id=self.unique_id) new_setpoint, ended = calculate_method_setpoint( self.setpoint_tracking_id, PID, this_pid, Method, MethodData, self.logger) if ended: self.method_start_act = 'Ended' if new_setpoint is not None: self.PID_Controller.setpoint = new_setpoint else: self.PID_Controller.setpoint = self.setpoint if self.setpoint_tracking_type == 'input-math' and self.setpoint_tracking_id != '': # Update setpoint using an Input or Math device_id = self.setpoint_tracking_id.split(',')[0] measurement_id = self.setpoint_tracking_id.split(',')[1] measurement = get_measurement(measurement_id) if not measurement: return False, None conversion = db_retrieve_table_daemon( Conversion, unique_id=measurement.conversion_id) channel, unit, measurement = return_measurement_info( measurement, conversion) last_measurement = read_last_influxdb( device_id, unit, channel, measure=measurement, duration_sec=self.setpoint_tracking_max_age) if last_measurement[1] is not None: self.PID_Controller.setpoint = last_measurement[1] else: self.logger.debug( "Could not find measurement for Setpoint " "Tracking. Max Age of {} exceeded for measuring " "device ID {} (measurement {})".format( self.setpoint_tracking_max_age, device_id, measurement_id)) self.PID_Controller.setpoint = None # Calculate new control variable (output) from PID Controller self.PID_Controller.update_pid_output(self.last_measurement) self.write_pid_values() # Write variables to database # Is PID in a state that allows manipulation of outputs if (self.is_activated and self.PID_Controller.setpoint is not None and (not self.is_paused or self.is_held)): self.manipulate_output() def setup_method(self, method_id): """ Initialize method variables to start running a method """ self.setpoint_tracking_id = '' method = db_retrieve_table_daemon(Method, unique_id=method_id) method_data = db_retrieve_table_daemon(MethodData) method_data = method_data.filter(MethodData.method_id == method_id) method_data_repeat = method_data.filter( MethodData.duration_sec == 0).first() pid = db_retrieve_table_daemon(PID, unique_id=self.unique_id) self.method_type = method.method_type self.method_start_act = pid.method_start_time self.method_start_time = None self.method_end_time = None if self.method_type == 'Duration': if self.method_start_act == 'Ended': # Method has ended and hasn't been instructed to begin again pass elif self.method_start_act == 'Ready' or self.method_start_act is None: # Method has been instructed to begin now = datetime.datetime.now() self.method_start_time = now if method_data_repeat and method_data_repeat.duration_end: self.method_end_time = now + datetime.timedelta( seconds=float(method_data_repeat.duration_end)) with session_scope(MYCODO_DB_PATH) as db_session: mod_pid = db_session.query(PID).filter( PID.unique_id == self.unique_id).first() mod_pid.method_start_time = self.method_start_time mod_pid.method_end_time = self.method_end_time db_session.commit() else: # Method neither instructed to begin or not to # Likely there was a daemon restart ot power failure # Resume method with saved start_time self.method_start_time = datetime.datetime.strptime( str(pid.method_start_time), '%Y-%m-%d %H:%M:%S.%f') if method_data_repeat and method_data_repeat.duration_end: self.method_end_time = datetime.datetime.strptime( str(pid.method_end_time), '%Y-%m-%d %H:%M:%S.%f') if self.method_end_time > datetime.datetime.now(): self.logger.warning( "Resuming method {id}: started {start}, ends {end}" .format(id=method_id, start=self.method_start_time, end=self.method_end_time)) else: self.method_start_act = 'Ended' else: self.method_start_act = 'Ended' self.setpoint_tracking_id = method_id self.logger.debug( "Method enabled: {id}".format(id=self.setpoint_tracking_id)) def write_pid_values(self): """ Write PID values to the measurement database """ if self.PID_Controller.band: setpoint_band_lower = self.PID_Controller.setpoint - self.PID_Controller.band setpoint_band_upper = self.PID_Controller.setpoint + self.PID_Controller.band else: setpoint_band_lower = None setpoint_band_upper = None list_measurements = [ self.PID_Controller.setpoint, setpoint_band_lower, setpoint_band_upper, self.PID_Controller.P_value, self.PID_Controller.I_value, self.PID_Controller.D_value ] measurement_dict = {} measurements = self.device_measurements.filter( DeviceMeasurements.device_id == self.unique_id).all() for each_channel, each_measurement in enumerate(measurements): if (each_measurement.channel not in measurement_dict and each_measurement.channel < len(list_measurements)): # If setpoint, get unit from PID measurement if each_measurement.measurement_type == 'setpoint': setpoint_pid = db_retrieve_table_daemon( PID, unique_id=each_measurement.device_id) if setpoint_pid and ',' in setpoint_pid.measurement: pid_measurement = setpoint_pid.measurement.split( ',')[1] setpoint_measurement = db_retrieve_table_daemon( DeviceMeasurements, unique_id=pid_measurement) if setpoint_measurement: conversion = db_retrieve_table_daemon( Conversion, unique_id=setpoint_measurement.conversion_id) _, unit, _ = return_measurement_info( setpoint_measurement, conversion) measurement_dict[each_channel] = { 'measurement': each_measurement.measurement, 'unit': unit, 'value': list_measurements[each_channel] } else: measurement_dict[each_channel] = { 'measurement': each_measurement.measurement, 'unit': each_measurement.unit, 'value': list_measurements[each_channel] } add_measurements_influxdb(self.unique_id, measurement_dict) def get_last_measurement_pid(self): """ Retrieve the latest input measurement from InfluxDB :rtype: None """ self.last_measurement_success = False # Get latest measurement from influxdb try: device_measurement = get_measurement(self.measurement_id) if device_measurement: conversion = db_retrieve_table_daemon( Conversion, unique_id=device_measurement.conversion_id) else: conversion = None channel, unit, measurement = return_measurement_info( device_measurement, conversion) self.last_measurement = read_last_influxdb( self.device_id, unit, channel, measure=measurement, duration_sec=int(self.max_measure_age)) if self.last_measurement: self.last_time = self.last_measurement[0] self.last_measurement = self.last_measurement[1] utc_dt = datetime.datetime.strptime( self.last_time.split(".")[0], '%Y-%m-%dT%H:%M:%S') utc_timestamp = calendar.timegm(utc_dt.timetuple()) local_timestamp = str( datetime.datetime.fromtimestamp(utc_timestamp)) self.logger.debug( "Latest (CH{ch}, Unit: {unit}): {last} @ {ts}".format( ch=channel, unit=unit, last=self.last_measurement, ts=local_timestamp)) if calendar.timegm( time.gmtime()) - utc_timestamp > self.max_measure_age: self.logger.error( "Last measurement was {last_sec} seconds ago, however" " the maximum measurement age is set to {max_sec}" " seconds.".format( last_sec=calendar.timegm(time.gmtime()) - utc_timestamp, max_sec=self.max_measure_age)) self.last_measurement_success = True else: self.logger.warning("No data returned from influxdb") except requests.ConnectionError: self.logger.error( "Failed to read measurement from the influxdb database: Could not connect." ) except Exception as except_msg: self.logger.exception( "Exception while reading measurement from the influxdb database: {err}" .format(err=except_msg)) def manipulate_output(self): """ Activate output based on PID control variable and whether the manipulation directive is to raise, lower, or both. :rtype: None """ # If the last measurement was able to be retrieved and was entered within the past minute if self.last_measurement_success: # # PID control variable is positive, indicating a desire to raise # the environmental condition # if self.PID_Controller.direction in ['raise', 'both' ] and self.raise_output_id: if self.PID_Controller.control_variable > 0: # Determine if the output should be PWM or a duration if self.raise_output_type == 'pwm': self.raise_duty_cycle = float("{0:.1f}".format( self.control_var_to_duty_cycle( self.PID_Controller.control_variable))) # Ensure the duty cycle doesn't exceed the min/max if (self.raise_max_duration and self.raise_duty_cycle > self.raise_max_duration): self.raise_duty_cycle = self.raise_max_duration elif (self.raise_min_duration and self.raise_duty_cycle < self.raise_min_duration): self.raise_duty_cycle = self.raise_min_duration self.logger.debug( "Setpoint: {sp}, Control Variable: {cv}, Output: PWM output {id} CH{ch} to {dc:.1f}%" .format(sp=self.PID_Controller.setpoint, cv=self.PID_Controller.control_variable, id=self.raise_output_id, ch=self.raise_output_channel, dc=self.raise_duty_cycle)) # Activate pwm with calculated duty cycle self.control.output_on( self.raise_output_id, output_type='pwm', amount=self.raise_duty_cycle, output_channel=self.raise_output_channel) self.write_pid_output_influxdb( 'percent', 'duty_cycle', 7, self.control_var_to_duty_cycle( self.PID_Controller.control_variable)) elif self.raise_output_type == 'on_off': # Ensure the output on duration doesn't exceed the set maximum if (self.raise_max_duration and self.PID_Controller.control_variable > self.raise_max_duration): self.raise_seconds_on = self.raise_max_duration else: self.raise_seconds_on = float("{0:.2f}".format( self.PID_Controller.control_variable)) if self.raise_seconds_on > self.raise_min_duration: # Activate raise_output for a duration self.logger.debug( "Setpoint: {sp} Output: {cv} sec to output {id} CH{ch}" .format( sp=self.PID_Controller.setpoint, cv=self.PID_Controller.control_variable, id=self.raise_output_id, ch=self.raise_output_channel)) self.control.output_on( self.raise_output_id, output_type='sec', amount=self.raise_seconds_on, min_off=self.raise_min_off_duration, output_channel=self.raise_output_channel) self.write_pid_output_influxdb( 's', 'duration_time', 6, self.PID_Controller.control_variable) elif self.raise_output_type == 'value': # Activate raise_output for a value self.logger.debug( "Setpoint: {sp} Output: {cv} to output {id} CH{ch}" .format(sp=self.PID_Controller.setpoint, cv=self.PID_Controller.control_variable, id=self.raise_output_id, ch=self.raise_output_channel)) self.control.output_on( self.raise_output_id, output_type='value', amount=self.PID_Controller.control_variable, min_off=self.raise_min_off_duration, output_channel=self.raise_output_channel) self.write_pid_output_influxdb( 'none', 'unitless', 9, self.PID_Controller.control_variable) elif self.raise_output_type == 'volume': # Activate raise_output for a volume (ml) self.logger.debug( "Setpoint: {sp} Output: {cv} ml to output {id} CH{ch}" .format(sp=self.PID_Controller.setpoint, cv=self.PID_Controller.control_variable, id=self.raise_output_id, ch=self.raise_output_channel)) self.control.output_on( self.raise_output_id, output_type='vol', amount=self.PID_Controller.control_variable, min_off=self.raise_min_off_duration, output_channel=self.raise_output_channel) self.write_pid_output_influxdb( 'ml', 'volume', 8, self.PID_Controller.control_variable) elif self.raise_output_type == 'pwm' and not self.raise_always_min_pwm: # Turn PWM Off if PWM Output and not instructed to always be at least min self.control.output_on( self.raise_output_id, output_type='pwm', amount=0, output_channel=self.raise_output_channel) # # PID control variable is negative, indicating a desire to lower # the environmental condition # if self.PID_Controller.direction in ['lower', 'both' ] and self.lower_output_id: if self.PID_Controller.control_variable < 0: # Determine if the output should be PWM or a duration if self.lower_output_type == 'pwm': self.lower_duty_cycle = float("{0:.1f}".format( self.control_var_to_duty_cycle( abs(self.PID_Controller.control_variable)))) # Ensure the duty cycle doesn't exceed the min/max if (self.lower_max_duration and self.lower_duty_cycle > self.lower_max_duration): self.lower_duty_cycle = self.lower_max_duration elif (self.lower_min_duration and self.lower_duty_cycle < self.lower_min_duration): self.lower_duty_cycle = self.lower_min_duration self.logger.debug( "Setpoint: {sp}, Control Variable: {cv}, Output: PWM output {id} CH{ch} to {dc:.1f}%" .format(sp=self.PID_Controller.setpoint, cv=self.PID_Controller.control_variable, id=self.lower_output_id, ch=self.lower_output_channel, dc=self.lower_duty_cycle)) if self.store_lower_as_negative: stored_duty_cycle = -abs(self.lower_duty_cycle) stored_control_variable = -self.control_var_to_duty_cycle( abs(self.PID_Controller.control_variable)) else: stored_duty_cycle = abs(self.lower_duty_cycle) stored_control_variable = self.control_var_to_duty_cycle( abs(self.PID_Controller.control_variable)) # Activate pwm with calculated duty cycle self.control.output_on( self.lower_output_id, output_type='pwm', amount=stored_duty_cycle, output_channel=self.lower_output_channel) self.write_pid_output_influxdb( 'percent', 'duty_cycle', 7, stored_control_variable) elif self.lower_output_type == 'on_off': # Ensure the output on duration doesn't exceed the set maximum if (self.lower_max_duration and abs(self.PID_Controller.control_variable) > self.lower_max_duration): self.lower_seconds_on = self.lower_max_duration else: self.lower_seconds_on = float("{0:.2f}".format( abs(self.PID_Controller.control_variable))) if self.store_lower_as_negative: stored_amount_on = -abs(self.lower_seconds_on) stored_control_variable = -abs( self.PID_Controller.control_variable) else: stored_amount_on = abs(self.lower_seconds_on) stored_control_variable = abs( self.PID_Controller.control_variable) if self.lower_seconds_on > self.lower_min_duration: # Activate lower_output for a duration self.logger.debug( "Setpoint: {sp} Output: {cv} sec to output {id} CH{ch}" .format( sp=self.PID_Controller.setpoint, cv=self.PID_Controller.control_variable, id=self.lower_output_id, ch=self.lower_output_channel)) self.control.output_on( self.lower_output_id, output_type='sec', amount=stored_amount_on, min_off=self.lower_min_off_duration, output_channel=self.lower_output_channel) self.write_pid_output_influxdb( 's', 'duration_time', 6, stored_control_variable) elif self.lower_output_type == 'value': if self.store_lower_as_negative: stored_amount_on = -abs(self.lower_seconds_on) stored_control_variable = -abs( self.PID_Controller.control_variable) else: stored_amount_on = abs(self.lower_seconds_on) stored_control_variable = abs( self.PID_Controller.control_variable) # Activate lower_output for a value self.logger.debug( "Setpoint: {sp} Output: {cv} to output {id} CH{ch}" .format(sp=self.PID_Controller.setpoint, cv=self.PID_Controller.control_variable, id=self.lower_output_id, ch=self.lower_output_channel)) self.control.output_on( self.lower_output_id, output_type='value', amount=stored_amount_on, min_off=self.lower_min_off_duration, output_channel=self.lower_output_channel) self.write_pid_output_influxdb( 'none', 'unitless', 9, stored_control_variable) elif self.lower_output_type == 'volume': if self.store_lower_as_negative: stored_amount_on = -abs(self.lower_seconds_on) stored_control_variable = -abs( self.PID_Controller.control_variable) else: stored_amount_on = abs(self.lower_seconds_on) stored_control_variable = abs( self.PID_Controller.control_variable) # Activate lower_output for a volume (ml) self.logger.debug( "Setpoint: {sp} Output: {cv} ml to output {id} CH{ch}" .format(sp=self.PID_Controller.setpoint, cv=self.PID_Controller.control_variable, id=self.lower_output_id, ch=self.lower_output_channel)) self.control.output_on( self.lower_output_id, output_type='vol', amount=stored_amount_on, min_off=self.lower_min_off_duration, output_channel=self.lower_output_channel) self.write_pid_output_influxdb( 'ml', 'volume', 8, stored_control_variable) elif self.lower_output_type == 'pwm' and not self.lower_always_min_pwm: # Turn PWM Off if PWM Output and not instructed to always be at least min self.control.output_on( self.lower_output_id, output_type='pwm', amount=0, output_channel=self.lower_output_channel) else: self.logger.debug( "Last measurement unsuccessful. Turning outputs off.") if self.PID_Controller.direction in ['raise', 'both' ] and self.raise_output_id: self.control.output_off( self.raise_output_id, output_channel=self.raise_output_channel) if self.PID_Controller.direction in ['lower', 'both' ] and self.lower_output_id: self.control.output_off( self.lower_output_id, output_channel=self.lower_output_channel) def pid_parameters_str(self): return "Device ID: {did}, " \ "Measurement ID: {mid}, " \ "Direction: {dir}, " \ "Period: {per}, " \ "Setpoint: {sp}, " \ "Band: {band}, " \ "Kp: {kp}, " \ "Ki: {ki}, " \ "Kd: {kd}, " \ "Integrator Min: {imn}, " \ "Integrator Max {imx}, " \ "Output Raise: {opr}, " \ "Output Raise Channel: {oprc}, " \ "Output Raise Type: {oprt}, " \ "Output Raise Min On: {oprmnon}, " \ "Output Raise Max On: {oprmxon}, " \ "Output Raise Min Off: {oprmnoff}, " \ "Output Raise Always Min: {opramn}, " \ "Output Lower: {opl}, " \ "Output Lower Channel: {oplc}, " \ "Output Lower Type: {oplt}, " \ "Output Lower Min On: {oplmnon}, " \ "Output Lower Max On: {oplmxon}, " \ "Output Lower Min Off: {oplmnoff}, " \ "Output Lower Always Min: {oplamn}, " \ "Setpoint Tracking Type: {sptt}, " \ "Setpoint Tracking ID: {spt}".format( did=self.device_id, mid=self.measurement_id, dir=self.PID_Controller.direction, per=self.period, sp=self.PID_Controller.setpoint, band=self.PID_Controller.band, kp=self.PID_Controller.Kp, ki=self.PID_Controller.Ki, kd=self.PID_Controller.Kd, imn=self.PID_Controller.integrator_min, imx=self.PID_Controller.integrator_max, opr=self.raise_output_id, oprc=self.raise_output_channel, oprt=self.raise_output_type, oprmnon=self.raise_min_duration, oprmxon=self.raise_max_duration, oprmnoff=self.raise_min_off_duration, opramn=self.raise_always_min_pwm, opl=self.lower_output_id, oplc=self.lower_output_channel, oplt=self.lower_output_type, oplmnon=self.lower_min_duration, oplmxon=self.lower_max_duration, oplmnoff=self.lower_min_off_duration, oplamn=self.lower_always_min_pwm, sptt=self.setpoint_tracking_type, spt=self.setpoint_tracking_id) def control_var_to_duty_cycle(self, control_variable): # Convert control variable to duty cycle if control_variable > self.period: return 100.0 else: return float((control_variable / self.period) * 100) @staticmethod def return_output_channel(output_channel_id): output_channel = db_retrieve_table_daemon(OutputChannel, unique_id=output_channel_id) if output_channel and output_channel.channel is not None: return output_channel.channel def write_pid_output_influxdb(self, unit, measurement, channel, value): write_pid_out_db = threading.Thread(target=write_influxdb_value, args=( self.unique_id, unit, value, ), kwargs={ 'measure': measurement, 'channel': channel }) write_pid_out_db.start() def pid_mod(self): if self.initialize_variables(): return "success" else: return "error" def pid_hold(self): self.is_held = True self.logger.info("Hold") return "success" def pid_pause(self): self.is_paused = True self.logger.info("Pause") return "success" def pid_resume(self): self.is_activated = True self.is_held = False self.is_paused = False self.logger.info("Resume") return "success" def set_setpoint(self, setpoint): """ Set the setpoint of PID """ self.PID_Controller.setpoint = float(setpoint) with session_scope(MYCODO_DB_PATH) as db_session: mod_pid = db_session.query(PID).filter( PID.unique_id == self.unique_id).first() mod_pid.setpoint = setpoint db_session.commit() return "Setpoint set to {sp}".format(sp=setpoint) def set_method(self, method_id): """ Set the method of PID """ with session_scope(MYCODO_DB_PATH) as db_session: mod_pid = db_session.query(PID).filter( PID.unique_id == self.unique_id).first() mod_pid.setpoint_tracking_id = method_id if method_id == '': self.setpoint_tracking_id = '' db_session.commit() else: mod_pid.method_start_time = 'Ready' mod_pid.method_end_time = None db_session.commit() self.setup_method(method_id) return "Method set to {me}".format(me=method_id) def set_integrator(self, integrator): """ Set the integrator of the controller """ self.PID_Controller.integrator = float(integrator) return "Integrator set to {i}".format(i=self.PID_Controller.integrator) def set_derivator(self, derivator): """ Set the derivator of the controller """ self.PID_Controller.derivator = float(derivator) return "Derivator set to {d}".format(d=self.PID_Controller.derivator) def set_kp(self, p): """ Set Kp gain of the controller """ self.PID_Controller.Kp = float(p) with session_scope(MYCODO_DB_PATH) as db_session: mod_pid = db_session.query(PID).filter( PID.unique_id == self.unique_id).first() mod_pid.p = p db_session.commit() return "Kp set to {kp}".format(kp=self.PID_Controller.Kp) def set_ki(self, i): """ Set Ki gain of the controller """ self.PID_Controller.Ki = float(i) with session_scope(MYCODO_DB_PATH) as db_session: mod_pid = db_session.query(PID).filter( PID.unique_id == self.unique_id).first() mod_pid.i = i db_session.commit() return "Ki set to {ki}".format(ki=self.PID_Controller.Ki) def set_kd(self, d): """ Set Kd gain of the controller """ self.PID_Controller.Kd = float(d) with session_scope(MYCODO_DB_PATH) as db_session: mod_pid = db_session.query(PID).filter( PID.unique_id == self.unique_id).first() mod_pid.d = d db_session.commit() return "Kd set to {kd}".format(kd=self.PID_Controller.Kd) def get_setpoint(self): return self.PID_Controller.setpoint def get_setpoint_band(self): return self.PID_Controller.setpoint_band def get_error(self): return self.PID_Controller.error def get_integrator(self): return self.PID_Controller.integrator def get_derivator(self): return self.PID_Controller.derivator def get_kp(self): return self.PID_Controller.Kp def get_ki(self): return self.PID_Controller.Ki def get_kd(self): return self.PID_Controller.Kd def stop_controller(self, ended_normally=True, deactivate_pid=False): self.thread_shutdown_timer = timeit.default_timer() self.running = False # Unset method start time if (self.setpoint_tracking_type == 'method' and self.setpoint_tracking_id != '' and ended_normally): with session_scope(MYCODO_DB_PATH) as db_session: mod_pid = db_session.query(PID).filter( PID.unique_id == self.unique_id).first() mod_pid.method_start_time = 'Ended' mod_pid.method_end_time = None db_session.commit() # Deactivate PID and Autotune if deactivate_pid: with session_scope(MYCODO_DB_PATH) as db_session: mod_pid = db_session.query(PID).filter( PID.unique_id == self.unique_id).first() mod_pid.is_activated = False mod_pid.autotune_activated = False db_session.commit()
class InputModule(AbstractInput): """ A sensor support class that measures the DHT22's humidity and temperature and calculates the dew point An adaptation of DHT22 code from https://github.com/joan2937/pigpio The sensor is also known as the AM2302. The sensor can be powered from the Pi 3.3-volt or 5-volt rail. Powering from the 3.3-volt rail is simpler and safer. You may need to power from 5 if the sensor is connected via a long cable. For 3.3-volt operation connect pin 1 to 3.3 volts and pin 4 to ground. Connect pin 2 to a gpio. For 5-volt operation connect pin 1 to the 5 volts and pin 4 to ground. The following pin 2 connection works for me. Use at YOUR OWN RISK. 5V--5K_resistor--+--10K_resistor--Ground | DHT22 pin 2 -----+ | gpio ------------+ """ def __init__(self, input_dev, testing=False): """ Instantiate with the Pi and gpio to which the DHT22 output pin is connected. Optionally a gpio used to power the sensor may be specified. This gpio will be set high to power the sensor. If the sensor locks it will be power cycled to restart the readings. Taking readings more often than about once every two seconds will eventually cause the DHT22 to hang. A 3 second interval seems OK. """ super(InputModule, self).__init__() self.logger = logging.getLogger('mycodo.inputs.dht22') self.temp_temperature = None self.temp_humidity = None self.temp_dew_point = None self.temp_vpd = None self.power_output_id = None self.powered = False self.pi = None if not testing: import pigpio from mycodo.mycodo_client import DaemonControl self.logger = logging.getLogger( 'mycodo.dht22_{id}'.format(id=input_dev.unique_id.split('-')[0])) self.device_measurements = db_retrieve_table_daemon( DeviceMeasurements).filter( DeviceMeasurements.device_id == input_dev.unique_id) self.power_output_id = input_dev.power_output_id self.control = DaemonControl() self.pigpio = pigpio self.pi = self.pigpio.pi() self.gpio = int(input_dev.gpio_location) self.bad_CS = 0 # Bad checksum count self.bad_SM = 0 # Short message count self.bad_MM = 0 # Missing message count self.bad_SR = 0 # Sensor reset count # Power cycle if timeout > MAX_NO_RESPONSE self.MAX_NO_RESPONSE = 3 self.no_response = None self.tov = None self.high_tick = None self.bit = None self.either_edge_cb = None self.start_sensor() def get_measurement(self): """ Gets the humidity and temperature """ return_dict = measurements_dict.copy() if not self.pi.connected: # Check if pigpiod is running self.logger.error('Could not connect to pigpiod. ' 'Ensure it is running and try again.') return None, None, None # Ensure if the power pin turns off, it is turned back on if (self.power_output_id and db_retrieve_table_daemon(Output, unique_id=self.power_output_id) and self.control.output_state(self.power_output_id) == 'off'): self.logger.error( 'Sensor power output {rel} detected as being off. ' 'Turning on.'.format(rel=self.power_output_id)) self.start_sensor() time.sleep(2) # Try twice to get measurement. This prevents an anomaly where # the first measurement fails if the sensor has just been powered # for the first time. for _ in range(4): self.measure_sensor() if self.temp_dew_point is not None: if self.is_enabled(0): return_dict[0]['value'] = self.temp_temperature if self.is_enabled(1): return_dict[1]['value'] = self.temp_humidity if (self.is_enabled(2) and self.is_enabled(0) and self.is_enabled(1)): return_dict[2]['value'] = self.temp_dew_point if (self.is_enabled(3) and self.is_enabled(0) and self.is_enabled(1)): return_dict[3]['value'] = self.temp_vpd return return_dict # success - no errors time.sleep(2) # Measurement failure, power cycle the sensor (if enabled) # Then try two more times to get a measurement if self.power_output_id is not None and self.running: self.stop_sensor() time.sleep(3) self.start_sensor() for _ in range(2): self.measure_sensor() if self.temp_dew_point is not None: if self.is_enabled(0): return_dict[0]['value'] = self.temp_temperature if self.is_enabled(1): return_dict[1]['value'] = self.temp_humidity if (self.is_enabled(2) and self.is_enabled(0) and self.is_enabled(1)): return_dict[2]['value'] = self.temp_dew_point if (self.is_enabled(3) and self.is_enabled(0) and self.is_enabled(1)): return_dict[3]['value'] = self.temp_vpd return return_dict # success - no errors time.sleep(2) self.logger.debug("Could not acquire a measurement") return None def measure_sensor(self): self.temp_temperature = None self.temp_humidity = None self.temp_dew_point = None self.temp_vpd = None initialized = False try: self.close() time.sleep(0.2) self.setup() time.sleep(0.2) initialized = True except Exception as except_msg: self.logger.error( "Could not initialize sensor. Check if it's connected " "properly and pigpiod is running. Error: {msg}".format( msg=except_msg)) if initialized: try: self.pi.write(self.gpio, self.pigpio.LOW) time.sleep(0.017) # 17 ms self.pi.set_mode(self.gpio, self.pigpio.INPUT) self.pi.set_watchdog(self.gpio, 200) time.sleep(0.2) if (self.temp_humidity is not None and self.temp_temperature is not None): self.temp_dew_point = calculate_dewpoint( self.temp_temperature, self.temp_humidity) self.temp_vpd = calculate_vapor_pressure_deficit( self.temp_temperature, self.temp_humidity) except Exception as e: self.logger.exception( "Exception when taking a reading: {err}".format( err=e)) finally: self.close() def setup(self): """ Clears the internal gpio pull-up/down resistor. Kills any watchdogs. Setup callbacks """ self.no_response = 0 self.tov = None self.high_tick = 0 self.bit = 40 self.either_edge_cb = None self.pi.set_pull_up_down(self.gpio, self.pigpio.PUD_OFF) self.pi.set_watchdog(self.gpio, 0) # Kill any watchdogs self.register_callbacks() def register_callbacks(self): """ Monitors RISING_EDGE changes using callback """ self.either_edge_cb = self.pi.callback(self.gpio, self.pigpio.EITHER_EDGE, self.either_edge_callback) def either_edge_callback(self, gpio, level, tick): """ Either Edge callbacks, called each time the gpio edge changes. Accumulate the 40 data bits from the DHT22 sensor. Format into 5 bytes, humidity high, humidity low, temperature high, temperature low, checksum. """ level_handlers = { self.pigpio.FALLING_EDGE: self._edge_fall, self.pigpio.RISING_EDGE: self._edge_rise, self.pigpio.EITHER_EDGE: self._edge_either } handler = level_handlers[level] diff = self.pigpio.tickDiff(self.high_tick, tick) handler(tick, diff) def _edge_rise(self, tick, diff): """ Handle Rise signal """ # Edge length determines if bit is 1 or 0. if diff >= 50: val = 1 if diff >= 200: # Bad bit? self.CS = 256 # Force bad checksum. else: val = 0 if self.bit >= 40: # Message complete. self.bit = 40 elif self.bit >= 32: # In checksum byte. self.CS = (self.CS << 1) + val if self.bit == 39: # 40th bit received. self.pi.set_watchdog(self.gpio, 0) self.no_response = 0 total = self.hH + self.hL + self.tH + self.tL if (total & 255) == self.CS: # Is checksum ok? self.temp_humidity = ((self.hH << 8) + self.hL) * 0.1 if self.tH & 128: # Negative temperature. mult = -0.1 self.tH &= 127 else: mult = 0.1 self.temp_temperature = ((self.tH << 8) + self.tL) * mult self.tov = time.time() else: self.bad_CS += 1 elif self.bit >= 24: # in temp low byte self.tL = (self.tL << 1) + val elif self.bit >= 16: # in temp high byte self.tH = (self.tH << 1) + val elif self.bit >= 8: # in humidity low byte self.hL = (self.hL << 1) + val elif self.bit >= 0: # in humidity high byte self.hH = (self.hH << 1) + val self.bit += 1 def _edge_fall(self, tick, diff): """ Handle Fall signal """ # Edge length determines if bit is 1 or 0. self.high_tick = tick if diff <= 250000: return self.bit = -2 self.hH = 0 self.hL = 0 self.tH = 0 self.tL = 0 self.CS = 0 def _edge_either(self, tick, diff): """ Handle Either signal or Timeout """ self.pi.set_watchdog(self.gpio, 0) if self.bit < 8: # Too few data bits received. self.bad_MM += 1 # Bump missing message count. self.no_response += 1 if self.no_response > self.MAX_NO_RESPONSE: self.no_response = 0 self.bad_SR += 1 # Bump sensor reset count. if self.power_output_id is not None: self.logger.error( "Invalid data, power cycling sensor.") self.stop_sensor() time.sleep(2) self.start_sensor() elif self.bit < 39: # Short message received. self.bad_SM += 1 # Bump short message count. self.no_response = 0 else: # Full message received. self.no_response = 0 def staleness(self): """ Return time since measurement made """ if self.tov is not None: return time.time() - self.tov else: return -999 def bad_checksum(self): """ Return count of messages received with bad checksums """ return self.bad_CS def short_message(self): """ Return count of short messages """ return self.bad_SM def missing_message(self): """ Return count of missing messages """ return self.bad_MM def sensor_resets(self): """ Return count of power cycles because of sensor hangs """ return self.bad_SR def close(self): """ Stop reading sensor, remove callbacks """ self.pi.set_watchdog(self.gpio, 0) if self.either_edge_cb: self.either_edge_cb.cancel() self.either_edge_cb = None def start_sensor(self): """ Turn the sensor on """ if self.power_output_id: self.logger.info("Turning on sensor") self.control.output_on(self.power_output_id, 0) time.sleep(2) self.powered = True def stop_sensor(self): """ Turn the sensor off """ if self.power_output_id: self.logger.info("Turning off sensor") self.control.output_off(self.power_output_id) self.powered = False
class CustomModule(AbstractFunction): """ Class to operate custom controller """ def __init__(self, function, testing=False): super(CustomModule, self).__init__(function, testing=testing, name=__name__) self.control_variable = None self.timestamp = None self.control = DaemonControl() self.outputIsOn = False self.timer_loop = time.time() # Initialize custom options self.measurement_device_id = None self.measurement_measurement_id = None self.output_device_id = None self.output_measurement_id = None self.output_channel_id = None self.setpoint = None self.hysteresis = None self.direction = None self.output_channel = None self.update_period = None # Set custom options custom_function = db_retrieve_table_daemon(CustomController, unique_id=self.unique_id) self.setup_custom_options(FUNCTION_INFORMATION['custom_options'], custom_function) if not testing: self.initialize_variables() def initialize_variables(self): self.timestamp = time.time() self.output_channel = self.get_output_channel_from_channel_id( self.output_channel_id) self.logger.info( "Bang-Bang controller started with options: " "Measurement Device: {}, Measurement: {}, Output: {}, " "Output_Channel: {}, Setpoint: {}, Hysteresis: {}, " "Direction: {}, Period: {}".format( self.measurement_device_id, self.measurement_measurement_id, self.output_device_id, self.output_channel, self.setpoint, self.hysteresis, self.direction, self.update_period)) def loop(self): if self.timer_loop > time.time(): return while self.timer_loop < time.time(): self.timer_loop += self.update_period if self.output_channel is None: self.logger.error( "Cannot run bang-bang controller: Check output channel.") return last_measurement = self.get_last_measurement( self.measurement_device_id, self.measurement_measurement_id)[1] outputState = self.control.output_state(self.output_device_id, self.output_channel) self.logger.info("Input: {}, output: {}, target: {}, hyst: {}".format( last_measurement, outputState, self.setpoint, self.hysteresis)) if self.direction == 'raise': if last_measurement > (self.setpoint + self.hysteresis): if outputState == 'on': self.control.output_off(self.output_device_id, output_channel=self.output_channel) else: if last_measurement < (self.setpoint - self.hysteresis): self.control.output_on(self.output_device_id, output_channel=self.output_channel) elif self.direction == 'lower': if last_measurement < (self.setpoint - self.hysteresis): if outputState == 'on': self.control.output_off(self.output_device_id, output_channel=self.output_channel) else: if last_measurement > (self.setpoint + self.hysteresis): self.control.output_on(self.output_device_id, output_channel=self.output_channel) else: self.logger.info("Unknown controller direction: '{}'".format( self.direction)) def stop_function(self): self.control.output_off(self.output_device_id, self.output_channel)
def camera_record(record_type, unique_id, duration_sec=None, tmp_filename=None): """ Record still image from cameras :param record_type: :param unique_id: :param duration_sec: :param tmp_filename: :return: """ daemon_control = None settings = db_retrieve_table_daemon(Camera, unique_id=unique_id) timestamp = datetime.datetime.now().strftime('%Y-%m-%d_%H-%M-%S') assure_path_exists(PATH_CAMERAS) camera_path = assure_path_exists( os.path.join(PATH_CAMERAS, '{uid}'.format(uid=settings.unique_id))) if record_type == 'photo': if settings.path_still != '': save_path = settings.path_still else: save_path = assure_path_exists(os.path.join(camera_path, 'still')) filename = 'Still-{cam_id}-{cam}-{ts}.jpg'.format( cam_id=settings.id, cam=settings.name, ts=timestamp).replace(" ", "_") elif record_type == 'timelapse': if settings.path_timelapse != '': save_path = settings.path_timelapse else: save_path = assure_path_exists(os.path.join(camera_path, 'timelapse')) start = datetime.datetime.fromtimestamp( settings.timelapse_start_time).strftime("%Y-%m-%d_%H-%M-%S") filename = 'Timelapse-{cam_id}-{cam}-{st}-img-{cn:05d}.jpg'.format( cam_id=settings.id, cam=settings.name, st=start, cn=settings.timelapse_capture_number).replace(" ", "_") elif record_type == 'video': if settings.path_video != '': save_path = settings.path_video else: save_path = assure_path_exists(os.path.join(camera_path, 'video')) filename = 'Video-{cam}-{ts}.h264'.format( cam=settings.name, ts=timestamp).replace(" ", "_") else: return assure_path_exists(save_path) if tmp_filename: filename = tmp_filename path_file = os.path.join(save_path, filename) # Turn on output, if configured if settings.output_id: daemon_control = DaemonControl() daemon_control.output_on(settings.output_id) # Pause while the output remains on for the specified duration. # Used for instance to allow fluorescent lights to fully turn on before # capturing an image. if settings.output_duration: time.sleep(settings.output_duration) if settings.library == 'picamera': # Try 5 times to access the pi camera (in case another process is accessing it) for _ in range(5): try: with picamera.PiCamera() as camera: camera.resolution = (settings.width, settings.height) camera.hflip = settings.hflip camera.vflip = settings.vflip camera.rotation = settings.rotation camera.brightness = int(settings.brightness) camera.contrast = int(settings.contrast) camera.exposure_compensation = int(settings.exposure) camera.saturation = int(settings.saturation) camera.start_preview() time.sleep(2) # Camera warm-up time if record_type in ['photo', 'timelapse']: camera.capture(path_file, use_video_port=False) elif record_type == 'video': camera.start_recording(path_file, format='h264', quality=20) camera.wait_recording(duration_sec) camera.stop_recording() else: return break except picamera.exc.PiCameraMMALError: logger.error("The camera is already open by picamera. Retrying 4 times.") time.sleep(1) elif settings.library == 'fswebcam': cmd = "/usr/bin/fswebcam --device {dev} --resolution {w}x{h} --set brightness={bt}% " \ "--no-banner --save {file}".format(dev=settings.device, w=settings.width, h=settings.height, bt=settings.brightness, file=path_file) if settings.hflip: cmd += " --flip h" if settings.vflip: cmd += " --flip h" if settings.rotation: cmd += " --rotate {angle}".format(angle=settings.rotation) if settings.custom_options: cmd += " " + settings.custom_options out, err, status = cmd_output(cmd, stdout_pipe=False) # logger.error("TEST01: {}; {}; {}; {}".format(cmd, out, err, status)) # Turn off output, if configured if settings.output_id and daemon_control: daemon_control.output_off(settings.output_id) try: set_user_grp(path_file, 'mycodo', 'mycodo') return save_path, filename except Exception as e: logger.exception( "Exception raised in 'camera_record' when setting user grp: " "{err}".format(err=e))
class PIDController(threading.Thread): """ Class to operate discrete PID controller """ def __init__(self, ready, pid_id): threading.Thread.__init__(self) self.logger = logging.getLogger( "mycodo.pid_{id}".format(id=pid_id.split('-')[0])) self.running = False self.thread_startup_timer = timeit.default_timer() self.thread_shutdown_timer = 0 self.ready = ready self.pid_id = pid_id self.control = DaemonControl() self.sample_rate = db_retrieve_table_daemon( Misc, entry='first').sample_rate_controller_pid self.control_variable = 0.0 self.derivator = 0.0 self.integrator = 0.0 self.error = 0.0 self.P_value = None self.I_value = None self.D_value = None self.lower_seconds_on = 0.0 self.raise_seconds_on = 0.0 self.lower_duty_cycle = 0.0 self.raise_duty_cycle = 0.0 self.last_time = None self.last_measurement = None self.last_measurement_success = False self.is_activated = None self.is_held = None self.is_paused = None self.measurement = None self.method_id = None self.direction = None self.raise_output_id = None self.raise_min_duration = None self.raise_max_duration = None self.raise_min_off_duration = None self.lower_output_id = None self.lower_min_duration = None self.lower_max_duration = None self.lower_min_off_duration = None self.Kp = None self.Ki = None self.Kd = None self.integrator_min = None self.integrator_max = None self.period = None self.max_measure_age = None self.default_setpoint = None self.setpoint = None self.store_lower_as_negative = None # Hysteresis options self.band = None self.allow_raising = False self.allow_lowering = False self.dev_unique_id = None self.input_duration = None self.raise_output_type = None self.lower_output_type = None self.first_start = True self.timer = 0 self.initialize_values() # Check if a method is set for this PID self.method_start_act = None if self.method_id != '': self.setup_method(self.method_id) def run(self): try: self.running = True startup_str = "Activated in {:.1f} ms".format( (timeit.default_timer() - self.thread_startup_timer) * 1000) if self.is_paused: startup_str += ", started Paused" elif self.is_held: startup_str += ", started Held" self.logger.info(startup_str) self.ready.set() while self.running: if (self.method_start_act == 'Ended' and self.method_type == 'Duration'): self.stop_controller(ended_normally=False, deactivate_pid=True) self.logger.warning( "Method has ended. " "Activate the PID controller to start it again.") elif time.time() > self.timer: self.check_pid() time.sleep(self.sample_rate) except Exception as except_msg: self.logger.exception("Run Error: {err}".format(err=except_msg)) finally: # Turn off output used in PID when the controller is deactivated if self.raise_output_id and self.direction in ['raise', 'both']: self.control.output_off(self.raise_output_id, trigger_conditionals=True) if self.lower_output_id and self.direction in ['lower', 'both']: self.control.output_off(self.lower_output_id, trigger_conditionals=True) self.running = False self.logger.info("Deactivated in {:.1f} ms".format( (timeit.default_timer() - self.thread_shutdown_timer) * 1000)) def initialize_values(self): """Set PID parameters""" pid = db_retrieve_table_daemon(PID, unique_id=self.pid_id) self.is_activated = pid.is_activated self.is_held = pid.is_held self.is_paused = pid.is_paused self.method_id = pid.method_id self.direction = pid.direction self.raise_output_id = pid.raise_output_id self.raise_min_duration = pid.raise_min_duration self.raise_max_duration = pid.raise_max_duration self.raise_min_off_duration = pid.raise_min_off_duration self.lower_output_id = pid.lower_output_id self.lower_min_duration = pid.lower_min_duration self.lower_max_duration = pid.lower_max_duration self.lower_min_off_duration = pid.lower_min_off_duration self.Kp = pid.p self.Ki = pid.i self.Kd = pid.d self.integrator_min = pid.integrator_min self.integrator_max = pid.integrator_max self.period = pid.period self.max_measure_age = pid.max_measure_age self.default_setpoint = pid.setpoint self.setpoint = pid.setpoint self.band = pid.band self.store_lower_as_negative = pid.store_lower_as_negative dev_unique_id = pid.measurement.split(',')[0] self.measurement = pid.measurement.split(',')[1] input_dev = db_retrieve_table_daemon(Input, unique_id=dev_unique_id) math = db_retrieve_table_daemon(Math, unique_id=dev_unique_id) if input_dev: self.dev_unique_id = input_dev.unique_id self.input_duration = input_dev.period elif math: self.dev_unique_id = math.unique_id self.input_duration = math.period try: self.raise_output_type = db_retrieve_table_daemon( Output, unique_id=self.raise_output_id).output_type except AttributeError: self.raise_output_type = None try: self.lower_output_type = db_retrieve_table_daemon( Output, unique_id=self.lower_output_id).output_type except AttributeError: self.lower_output_type = None return "success" def check_pid(self): """ Get measurement and apply to PID controller """ # Ensure the timer ends in the future while time.time() > self.timer: self.timer = self.timer + self.period # If PID is active, retrieve measurement and update # the control variable. # A PID on hold will sustain the current output and # not update the control variable. if self.is_activated and (not self.is_paused or not self.is_held): self.get_last_measurement() if self.last_measurement_success: if self.method_id != '': # Update setpoint using a method this_pid = db_retrieve_table_daemon(PID, unique_id=self.pid_id) setpoint, ended = calculate_method_setpoint( self.method_id, PID, this_pid, Method, MethodData, self.logger) if ended: self.method_start_act = 'Ended' if setpoint is not None: self.setpoint = setpoint else: self.setpoint = self.default_setpoint self.write_setpoint_band() # Write variables to database # Calculate new control variable self.control_variable = self.update_pid_output( self.last_measurement) self.write_pid_values() # Write variables to database # Is PID in a state that allows manipulation of outputs if self.is_activated and (not self.is_paused or self.is_held): self.manipulate_output() def setup_method(self, method_id): """ Initialize method variables to start running a method """ self.method_id = '' method = db_retrieve_table_daemon(Method, unique_id=method_id) method_data = db_retrieve_table_daemon(MethodData) method_data = method_data.filter(MethodData.method_id == method_id) method_data_repeat = method_data.filter( MethodData.duration_sec == 0).first() pid = db_retrieve_table_daemon(PID, unique_id=self.pid_id) self.method_type = method.method_type self.method_start_act = pid.method_start_time self.method_start_time = None self.method_end_time = None if self.method_type == 'Duration': if self.method_start_act == 'Ended': # Method has ended and hasn't been instructed to begin again pass elif (self.method_start_act == 'Ready' or self.method_start_act is None): # Method has been instructed to begin now = datetime.datetime.now() self.method_start_time = now if method_data_repeat and method_data_repeat.duration_end: self.method_end_time = now + datetime.timedelta( seconds=float(method_data_repeat.duration_end)) with session_scope(MYCODO_DB_PATH) as db_session: mod_pid = db_session.query(PID).filter( PID.unique_id == self.pid_id).first() mod_pid.method_start_time = self.method_start_time mod_pid.method_end_time = self.method_end_time db_session.commit() else: # Method neither instructed to begin or not to # Likely there was a daemon restart ot power failure # Resume method with saved start_time self.method_start_time = datetime.datetime.strptime( str(pid.method_start_time), '%Y-%m-%d %H:%M:%S.%f') if method_data_repeat and method_data_repeat.duration_end: self.method_end_time = datetime.datetime.strptime( str(pid.method_end_time), '%Y-%m-%d %H:%M:%S.%f') if self.method_end_time > datetime.datetime.now(): self.logger.warning( "Resuming method {id}: started {start}, " "ends {end}".format(id=method_id, start=self.method_start_time, end=self.method_end_time)) else: self.method_start_act = 'Ended' else: self.method_start_act = 'Ended' self.method_id = method_id def write_setpoint_band(self): """ Write setpoint and band values to measurement database """ write_setpoint_db = threading.Thread(target=write_influxdb_value, args=( self.pid_id, 'setpoint', self.setpoint, )) write_setpoint_db.start() if self.band: band_min = self.setpoint - self.band write_setpoint_db = threading.Thread(target=write_influxdb_value, args=( self.pid_id, 'setpoint_band_min', band_min, )) write_setpoint_db.start() band_max = self.setpoint + self.band write_setpoint_db = threading.Thread(target=write_influxdb_value, args=( self.pid_id, 'setpoint_band_max', band_max, )) write_setpoint_db.start() def write_pid_values(self): """ Write p, i, and d values to the measurement database """ write_setpoint_db = threading.Thread(target=write_influxdb_value, args=( self.pid_id, 'pid_p_value', self.P_value, )) write_setpoint_db.start() write_setpoint_db = threading.Thread(target=write_influxdb_value, args=( self.pid_id, 'pid_i_value', self.I_value, )) write_setpoint_db.start() write_setpoint_db = threading.Thread(target=write_influxdb_value, args=( self.pid_id, 'pid_d_value', self.D_value, )) write_setpoint_db.start() def update_pid_output(self, current_value): """ Calculate PID output value from reference input and feedback :return: Manipulated, or control, variable. This is the PID output. :rtype: float :param current_value: The input, or process, variable (the actual measured condition by the input) :type current_value: float """ # Determine if hysteresis is enabled and if the PID should be applied setpoint = self.check_hysteresis(current_value) if setpoint is None: # Prevent PID variables form being manipulated and # restrict PID from operating. return 0 self.error = setpoint - current_value # Calculate P-value self.P_value = self.Kp * self.error # Calculate I-value self.integrator += self.error # First method for managing integrator if self.integrator > self.integrator_max: self.integrator = self.integrator_max elif self.integrator < self.integrator_min: self.integrator = self.integrator_min # Second method for regulating integrator # if self.period is not None: # if self.integrator * self.Ki > self.period: # self.integrator = self.period / self.Ki # elif self.integrator * self.Ki < -self.period: # self.integrator = -self.period / self.Ki self.I_value = self.integrator * self.Ki # Prevent large initial D-value if self.first_start: self.derivator = self.error self.first_start = False # Calculate D-value self.D_value = self.Kd * (self.error - self.derivator) self.derivator = self.error # Produce output form P, I, and D values pid_value = self.P_value + self.I_value + self.D_value return pid_value def check_hysteresis(self, measure): """ Determine if hysteresis is enabled and if the PID should be applied :return: float if the setpoint if the PID should be applied, None to restrict the PID :rtype: float or None :param measure: The PID input (or process) variable :type measure: float """ if self.band == 0: # If band is disabled, return setpoint return self.setpoint band_min = self.setpoint - self.band band_max = self.setpoint + self.band if self.direction == 'raise': if (measure < band_min or (band_min < measure < band_max and self.allow_raising)): self.allow_raising = True setpoint = band_max # New setpoint return setpoint # Apply the PID elif measure > band_max: self.allow_raising = False return None # Restrict the PID elif self.direction == 'lower': if (measure > band_max or (band_min < measure < band_max and self.allow_lowering)): self.allow_lowering = True setpoint = band_min # New setpoint return setpoint # Apply the PID elif measure < band_min: self.allow_lowering = False return None # Restrict the PID elif self.direction == 'both': if measure < band_min: setpoint = band_min # New setpoint if not self.allow_raising: # Reset integrator and derivator upon direction switch self.integrator = 0.0 self.derivator = 0.0 self.allow_raising = True self.allow_lowering = False elif measure > band_max: setpoint = band_max # New setpoint if not self.allow_lowering: # Reset integrator and derivator upon direction switch self.integrator = 0.0 self.derivator = 0.0 self.allow_raising = False self.allow_lowering = True else: return None # Restrict the PID return setpoint # Apply the PID def get_last_measurement(self): """ Retrieve the latest input measurement from InfluxDB :rtype: None """ self.last_measurement_success = False # Get latest measurement from influxdb try: self.last_measurement = read_last_influxdb( self.dev_unique_id, self.measurement, int(self.max_measure_age)) if self.last_measurement: self.last_time = self.last_measurement[0] self.last_measurement = self.last_measurement[1] utc_dt = datetime.datetime.strptime( self.last_time.split(".")[0], '%Y-%m-%dT%H:%M:%S') utc_timestamp = calendar.timegm(utc_dt.timetuple()) local_timestamp = str( datetime.datetime.fromtimestamp(utc_timestamp)) self.logger.debug("Latest {meas}: {last} @ {ts}".format( meas=self.measurement, last=self.last_measurement, ts=local_timestamp)) if calendar.timegm( time.gmtime()) - utc_timestamp > self.max_measure_age: self.logger.error( "Last measurement was {last_sec} seconds ago, however" " the maximum measurement age is set to {max_sec}" " seconds.".format( last_sec=calendar.timegm(time.gmtime()) - utc_timestamp, max_sec=self.max_measure_age)) self.last_measurement_success = True else: self.logger.warning("No data returned from influxdb") except requests.ConnectionError: self.logger.error("Failed to read measurement from the " "influxdb database: Could not connect.") except Exception as except_msg: self.logger.exception( "Exception while reading measurement from the influxdb " "database: {err}".format(err=except_msg)) def manipulate_output(self): """ Activate output based on PID control variable and whether the manipulation directive is to raise, lower, or both. :rtype: None """ # If the last measurement was able to be retrieved and was entered within the past minute if self.last_measurement_success: # # PID control variable is positive, indicating a desire to raise # the environmental condition # if self.direction in ['raise', 'both'] and self.raise_output_id: if self.control_variable > 0: # Determine if the output should be PWM or a duration if self.raise_output_type == 'pwm': self.raise_duty_cycle = float("{0:.1f}".format( self.control_var_to_duty_cycle( self.control_variable))) # Ensure the duty cycle doesn't exceed the min/max if (self.raise_max_duration and self.raise_duty_cycle > self.raise_max_duration): self.raise_duty_cycle = self.raise_max_duration elif (self.raise_min_duration and self.raise_duty_cycle < self.raise_min_duration): self.raise_duty_cycle = self.raise_min_duration self.logger.debug( "Setpoint: {sp}, Control Variable: {cv}, Output: PWM output " "{id} to {dc:.1f}%".format( sp=self.setpoint, cv=self.control_variable, id=self.raise_output_id, dc=self.raise_duty_cycle)) # Activate pwm with calculated duty cycle self.control.output_on( self.raise_output_id, duty_cycle=self.raise_duty_cycle) self.write_pid_output_influxdb( 'duty_cycle', self.control_var_to_duty_cycle( self.control_variable)) elif self.raise_output_type in [ 'command', 'wired', 'wireless_433MHz_pi_switch' ]: # Ensure the output on duration doesn't exceed the set maximum if (self.raise_max_duration and self.control_variable > self.raise_max_duration): self.raise_seconds_on = self.raise_max_duration else: self.raise_seconds_on = float("{0:.2f}".format( self.control_variable)) if self.raise_seconds_on > self.raise_min_duration: # Activate raise_output for a duration self.logger.debug( "Setpoint: {sp} Output: {cv} to output " "{id}".format(sp=self.setpoint, cv=self.control_variable, id=self.raise_output_id)) self.control.output_on( self.raise_output_id, duration=self.raise_seconds_on, min_off=self.raise_min_off_duration) self.write_pid_output_influxdb('duration_sec', self.control_variable) else: if self.raise_output_type == 'pwm': self.control.output_on(self.raise_output_id, duty_cycle=0) # # PID control variable is negative, indicating a desire to lower # the environmental condition # if self.direction in ['lower', 'both'] and self.lower_output_id: if self.control_variable < 0: # Determine if the output should be PWM or a duration if self.lower_output_type == 'pwm': self.lower_duty_cycle = float("{0:.1f}".format( self.control_var_to_duty_cycle( abs(self.control_variable)))) # Ensure the duty cycle doesn't exceed the min/max if (self.lower_max_duration and self.lower_duty_cycle > self.lower_max_duration): self.lower_duty_cycle = self.lower_max_duration elif (self.lower_min_duration and self.lower_duty_cycle < self.lower_min_duration): self.lower_duty_cycle = self.lower_min_duration self.logger.debug( "Setpoint: {sp}, Control Variable: {cv}, " "Output: PWM output {id} to {dc:.1f}%".format( sp=self.setpoint, cv=self.control_variable, id=self.lower_output_id, dc=self.lower_duty_cycle)) if self.store_lower_as_negative: stored_duty_cycle = -abs(self.lower_duty_cycle) stored_control_variable = -self.control_var_to_duty_cycle( abs(self.control_variable)) else: stored_duty_cycle = abs(self.lower_duty_cycle) stored_control_variable = self.control_var_to_duty_cycle( abs(self.control_variable)) # Activate pwm with calculated duty cycle self.control.output_on(self.lower_output_id, duty_cycle=stored_duty_cycle) self.write_pid_output_influxdb( 'duty_cycle', stored_control_variable) elif self.lower_output_type in [ 'command', 'wired', 'wireless_433MHz_pi_switch' ]: # Ensure the output on duration doesn't exceed the set maximum if (self.lower_max_duration and abs(self.control_variable) > self.lower_max_duration): self.lower_seconds_on = self.lower_max_duration else: self.lower_seconds_on = float("{0:.2f}".format( abs(self.control_variable))) if self.store_lower_as_negative: stored_seconds_on = -abs(self.lower_seconds_on) stored_control_variable = -abs( self.control_variable) else: stored_seconds_on = abs(self.lower_seconds_on) stored_control_variable = abs( self.control_variable) if self.lower_seconds_on > self.lower_min_duration: # Activate lower_output for a duration self.logger.debug("Setpoint: {sp} Output: {cv} to " "output {id}".format( sp=self.setpoint, cv=self.control_variable, id=self.lower_output_id)) self.control.output_on( self.lower_output_id, duration=stored_seconds_on, min_off=self.lower_min_off_duration) self.write_pid_output_influxdb( 'duration_sec', stored_control_variable) else: if self.lower_output_type == 'pwm': self.control.output_on(self.lower_output_id, duty_cycle=0) else: if self.direction in ['raise', 'both'] and self.raise_output_id: self.control.output_off(self.raise_output_id) if self.direction in ['lower', 'both'] and self.lower_output_id: self.control.output_off(self.lower_output_id) def control_var_to_duty_cycle(self, control_variable): # Convert control variable to duty cycle if control_variable > self.period: return 100.0 else: return float((control_variable / self.period) * 100) def write_pid_output_influxdb(self, pid_entry_type, pid_entry_value): write_pid_out_db = threading.Thread(target=write_influxdb_value, args=( self.pid_id, pid_entry_type, pid_entry_value, )) write_pid_out_db.start() def pid_mod(self): if self.initialize_values(): return "success" else: return "error" def pid_hold(self): self.is_held = True self.logger.info("Hold") return "success" def pid_pause(self): self.is_paused = True self.logger.info("Pause") return "success" def pid_resume(self): self.is_activated = True self.is_held = False self.is_paused = False self.logger.info("Resume") return "success" def set_setpoint(self, setpoint): """ Set the setpoint of PID """ self.setpoint = float(setpoint) with session_scope(MYCODO_DB_PATH) as db_session: mod_pid = db_session.query(PID).filter( PID.unique_id == self.pid_id).first() mod_pid.setpoint = setpoint db_session.commit() return "Setpoint set to {sp}".format(sp=setpoint) def set_method(self, method_id): """ Set the method of PID """ with session_scope(MYCODO_DB_PATH) as db_session: mod_pid = db_session.query(PID).filter( PID.unique_id == self.pid_id).first() mod_pid.method_id = method_id if method_id == '': self.method_id = '' db_session.commit() else: mod_pid.method_start_time = 'Ready' mod_pid.method_end_time = None db_session.commit() self.setup_method(method_id) return "Method set to {me}".format(me=method_id) def set_integrator(self, integrator): """ Set the integrator of the controller """ self.integrator = float(integrator) return "Integrator set to {i}".format(i=self.integrator) def set_derivator(self, derivator): """ Set the derivator of the controller """ self.derivator = float(derivator) return "Derivator set to {d}".format(d=self.derivator) def set_kp(self, p): """ Set Kp gain of the controller """ self.Kp = float(p) with session_scope(MYCODO_DB_PATH) as db_session: mod_pid = db_session.query(PID).filter( PID.unique_id == self.pid_id).first() mod_pid.p = p db_session.commit() return "Kp set to {kp}".format(kp=self.Kp) def set_ki(self, i): """ Set Ki gain of the controller """ self.Ki = float(i) with session_scope(MYCODO_DB_PATH) as db_session: mod_pid = db_session.query(PID).filter( PID.unique_id == self.pid_id).first() mod_pid.i = i db_session.commit() return "Ki set to {ki}".format(ki=self.Ki) def set_kd(self, d): """ Set Kd gain of the controller """ self.Kd = float(d) with session_scope(MYCODO_DB_PATH) as db_session: mod_pid = db_session.query(PID).filter( PID.unique_id == self.pid_id).first() mod_pid.d = d db_session.commit() return "Kd set to {kd}".format(kd=self.Kd) def get_setpoint(self): return self.setpoint def get_error(self): return self.error def get_integrator(self): return self.integrator def get_derivator(self): return self.derivator def get_kp(self): return self.Kp def get_ki(self): return self.Ki def get_kd(self): return self.Kd def is_running(self): return self.running def stop_controller(self, ended_normally=True, deactivate_pid=False): self.thread_shutdown_timer = timeit.default_timer() self.running = False # Unset method start time if self.method_id != '' and ended_normally: with session_scope(MYCODO_DB_PATH) as db_session: mod_pid = db_session.query(PID).filter( PID.unique_id == self.pid_id).first() mod_pid.method_start_time = 'Ended' mod_pid.method_end_time = None db_session.commit() if deactivate_pid: with session_scope(MYCODO_DB_PATH) as db_session: mod_pid = db_session.query(PID).filter( PID.unique_id == self.pid_id).first() mod_pid.is_activated = False db_session.commit()
class InputModule(AbstractInput): """ A sensor support class that measures the DHT11's humidity and temperature and calculates the dew point The DHT11 class is a stripped version of the DHT22 sensor code by joan2937. You can find the initial implementation here: - https://github.com/srounet/pigpio/tree/master/EXAMPLES/Python/DHT22_AM2302_SENSOR """ def __init__(self, input_dev, testing=False): """ :param gpio: gpio pin number :type gpio: int :param power: Power pin number :type power: int Instantiate with the Pi and gpio to which the DHT11 output pin is connected. Optionally a gpio used to power the sensor may be specified. This gpio will be set high to power the sensor. """ super(InputModule, self).__init__() self.logger = logging.getLogger('mycodo.inputs.dht11') self.temp_temperature = 0 self.temp_humidity = 0 self.temp_dew_point = None self.temp_vpd = None self.power_output_id = None self.powered = False if not testing: import pigpio from mycodo.mycodo_client import DaemonControl self.logger = logging.getLogger('mycodo.dht11_{id}'.format( id=input_dev.unique_id.split('-')[0])) self.device_measurements = db_retrieve_table_daemon( DeviceMeasurements).filter( DeviceMeasurements.device_id == input_dev.unique_id) self.gpio = int(input_dev.gpio_location) self.power_output_id = input_dev.power_output_id self.control = DaemonControl() self.pigpio = pigpio self.pi = self.pigpio.pi() self.high_tick = None self.bit = None self.either_edge_cb = None self.start_sensor() def get_measurement(self): """ Gets the humidity and temperature """ return_dict = measurements_dict.copy() if not self.pi.connected: # Check if pigpiod is running self.logger.error("Could not connect to pigpiod." "Ensure it is running and try again.") return None, None, None import pigpio self.pigpio = pigpio # Ensure if the power pin turns off, it is turned back on if (self.power_output_id and db_retrieve_table_daemon( Output, unique_id=self.power_output_id) and self.control.output_state(self.power_output_id) == 'off'): self.logger.error( 'Sensor power output {rel} detected as being off. ' 'Turning on.'.format(rel=self.power_output_id)) self.start_sensor() time.sleep(2) # Try twice to get measurement. This prevents an anomaly where # the first measurement fails if the sensor has just been powered # for the first time. for _ in range(2): self.measure_sensor() if self.temp_dew_point is not None: if self.is_enabled(0): return_dict[0]['value'] = self.temp_temperature if self.is_enabled(1): return_dict[1]['value'] = self.temp_humidity if (self.is_enabled(2) and self.is_enabled(0) and self.is_enabled(1)): return_dict[2]['value'] = self.temp_dew_point if (self.is_enabled(3) and self.is_enabled(0) and self.is_enabled(1)): return_dict[3]['value'] = self.temp_vpd return return_dict # success - no errors time.sleep(2) # Measurement failure, power cycle the sensor (if enabled) # Then try two more times to get a measurement if self.power_output_id is not None and self.running: self.stop_sensor() time.sleep(2) self.start_sensor() for _ in range(2): self.measure_sensor() if self.temp_dew_point is not None: if self.is_enabled(0): return_dict[0]['value'] = self.temp_temperature if self.is_enabled(1): return_dict[1]['value'] = self.temp_humidity if (self.is_enabled(2) and self.is_enabled(0) and self.is_enabled(1)): return_dict[2]['value'] = self.temp_dew_point if (self.is_enabled(3) and self.is_enabled(0) and self.is_enabled(1)): return_dict[3]['value'] = self.temp_vpd return return_dict # success - no errors time.sleep(2) self.logger.error("Could not acquire a measurement") return None def measure_sensor(self): self.temp_temperature = 0 self.temp_humidity = 0 self.temp_dew_point = None self.temp_vpd = None try: try: self.setup() except Exception as except_msg: self.logger.error( 'Could not initialize sensor. Check if gpiod is running. ' 'Error: {msg}'.format(msg=except_msg)) self.pi.write(self.gpio, self.pigpio.LOW) time.sleep(0.017) # 17 ms self.pi.set_mode(self.gpio, self.pigpio.INPUT) self.pi.set_watchdog(self.gpio, 200) time.sleep(0.2) if self.temp_humidity != 0: self.temp_dew_point = calculate_dewpoint( self.temp_temperature, self.temp_humidity) self.temp_vpd = calculate_vapor_pressure_deficit( self.temp_temperature, self.temp_humidity) except Exception as e: self.logger.error( "Exception raised when taking a reading: {err}".format(err=e)) finally: self.close() return (self.temp_dew_point, self.temp_humidity, self.temp_temperature) def setup(self): """ Clears the internal gpio pull-up/down resistor. Kills any watchdogs. Setup callbacks """ self.high_tick = 0 self.bit = 40 self.either_edge_cb = None self.pi.set_pull_up_down(self.gpio, self.pigpio.PUD_OFF) self.pi.set_watchdog(self.gpio, 0) self.register_callbacks() def register_callbacks(self): """ Monitors RISING_EDGE changes using callback """ self.either_edge_cb = self.pi.callback(self.gpio, self.pigpio.EITHER_EDGE, self.either_edge_callback) def either_edge_callback(self, gpio, level, tick): """ Either Edge callbacks, called each time the gpio edge changes. Accumulate the 40 data bits from the DHT11 sensor. """ level_handlers = { self.pigpio.FALLING_EDGE: self._edge_fall, self.pigpio.RISING_EDGE: self._edge_rise, self.pigpio.EITHER_EDGE: self._edge_either } handler = level_handlers[level] diff = self.pigpio.tickDiff(self.high_tick, tick) handler(tick, diff) def _edge_rise(self, tick, diff): """ Handle Rise signal """ val = 0 if diff >= 50: val = 1 if diff >= 200: # Bad bit? self.checksum = 256 # Force bad checksum if self.bit >= 40: # Message complete self.bit = 40 elif self.bit >= 32: # In checksum byte self.checksum = (self.checksum << 1) + val if self.bit == 39: # 40th bit received self.pi.set_watchdog(self.gpio, 0) total = self.temp_humidity + self.temp_temperature # is checksum ok ? if not (total & 255) == self.checksum: # For some reason the port from python 2 to python 3 causes # this bad checksum error to happen during every read # TODO: Investigate how to properly check the checksum in python 3 self.logger.debug( "Exception raised when taking a reading: " "Bad Checksum.") elif 16 <= self.bit < 24: # in temperature byte self.temp_temperature = (self.temp_temperature << 1) + val elif 0 <= self.bit < 8: # in humidity byte self.temp_humidity = (self.temp_humidity << 1) + val self.bit += 1 def _edge_fall(self, tick, diff): """ Handle Fall signal """ self.high_tick = tick if diff <= 250000: return self.bit = -2 self.checksum = 0 self.temp_temperature = 0 self.temp_humidity = 0 def _edge_either(self, tick, diff): """ Handle Either signal """ self.pi.set_watchdog(self.gpio, 0) def close(self): """ Stop reading sensor, remove callbacks """ self.pi.set_watchdog(self.gpio, 0) if self.either_edge_cb: self.either_edge_cb.cancel() self.either_edge_cb = None def start_sensor(self): """ Power the sensor """ if self.power_output_id: self.logger.info("Turning on sensor") self.control.output_on(self.power_output_id, 0) time.sleep(2) self.powered = True def stop_sensor(self): """ Depower the sensor """ if self.power_output_id: self.logger.info("Turning off sensor") self.control.output_off(self.power_output_id) self.powered = False
def output_on_off(form_output): action = '{action} {controller}'.format(action=gettext("Actuate"), controller=gettext("Output")) error = [] try: control = DaemonControl() output = Output.query.filter_by( unique_id=form_output.output_id.data).first() if output.output_type == 'wired' and int( form_output.output_pin.data) == 0: error.append(gettext("Cannot modulate output with a GPIO of 0")) elif form_output.on_submit.data: if output.output_type in [ 'wired', 'wireless_433MHz_pi_switch', 'command' ]: if float(form_output.sec_on.data) <= 0: error.append(gettext("Value must be greater than 0")) else: return_value = control.output_on( form_output.output_id.data, duration=float(form_output.sec_on.data)) flash( gettext( "Output turned on for %(sec)s seconds: %(rvalue)s", sec=form_output.sec_on.data, rvalue=return_value), "success") if output.output_type == 'pwm': if int(form_output.output_pin.data) == 0: error.append(gettext("Invalid pin")) if output.pwm_hertz <= 0: error.append(gettext("PWM Hertz must be a positive value")) if float(form_output.pwm_duty_cycle_on.data) <= 0: error.append( gettext("PWM duty cycle must be a positive value")) if not error: return_value = control.output_on( form_output.output_id.data, duty_cycle=float(form_output.pwm_duty_cycle_on.data)) flash( gettext( "PWM set to %(dc)s%% at %(hertz)s Hz: %(rvalue)s", dc=float(form_output.pwm_duty_cycle_on.data), hertz=output.pwm_hertz, rvalue=return_value), "success") elif form_output.turn_on.data: return_value = control.output_on(form_output.output_id.data, 0) flash(gettext("Output turned on: %(rvalue)s", rvalue=return_value), "success") elif form_output.turn_off.data: return_value = control.output_off(form_output.output_id.data) flash( gettext("Output turned off: %(rvalue)s", rvalue=return_value), "success") except ValueError as except_msg: error.append('{err}: {msg}'.format(err=gettext("Invalid value"), msg=except_msg)) except Exception as except_msg: error.append(except_msg) flash_success_errors(error, action, url_for('routes_page.page_output'))
class PIDController(threading.Thread): """ Class to operate discrete PID controller in Mycodo """ def __init__(self, ready, pid_id): threading.Thread.__init__(self) self.logger = logging.getLogger("mycodo.pid_{id}".format( id=pid_id.split('-')[0])) self.running = False self.thread_startup_timer = timeit.default_timer() self.thread_shutdown_timer = 0 self.ready = ready self.pid_id = pid_id self.control = DaemonControl() self.sample_rate = db_retrieve_table_daemon( Misc, entry='first').sample_rate_controller_pid self.device_measurements = db_retrieve_table_daemon(DeviceMeasurements) self.PID_Controller = None self.control_variable = 0.0 self.derivator = 0.0 self.integrator = 0.0 self.error = 0.0 self.P_value = None self.I_value = None self.D_value = None self.lower_seconds_on = 0.0 self.raise_seconds_on = 0.0 self.lower_duty_cycle = 0.0 self.raise_duty_cycle = 0.0 self.last_time = None self.last_measurement = None self.last_measurement_success = False self.is_activated = None self.is_held = None self.is_paused = None self.measurement = None self.method_id = None self.direction = None self.raise_output_id = None self.raise_min_duration = None self.raise_max_duration = None self.raise_min_off_duration = None self.lower_output_id = None self.lower_min_duration = None self.lower_max_duration = None self.lower_min_off_duration = None self.Kp = None self.Ki = None self.Kd = None self.integrator_min = None self.integrator_max = None self.period = None self.start_offset = None self.max_measure_age = None self.default_setpoint = None self.setpoint = None self.store_lower_as_negative = None # Hysteresis options self.band = None self.allow_raising = False self.allow_lowering = False # PID Autotune self.autotune = None self.autotune_activated = False self.autotune_debug = False self.autotune_noiseband = None self.autotune_outstep = None self.autotune_timestamp = None self.device_id = None self.measurement_id = None self.input_duration = None self.raise_output_type = None self.lower_output_type = None self.first_start = True self.initialize_values() self.timer = time.time() + self.start_offset # Check if a method is set for this PID self.method_type = None self.method_start_act = None self.method_start_time = None self.method_end_time = None if self.method_id != '': self.setup_method(self.method_id) def run(self): try: self.running = True startup_str = "Activated in {time:.1f} ms".format( time=(timeit.default_timer() - self.thread_startup_timer) * 1000) if self.is_paused: startup_str += ", started Paused" elif self.is_held: startup_str += ", started Held" self.logger.info(startup_str) # Initialize PID Controller self.PID_Controller = PIDControl( self.period, self.Kp, self.Ki, self.Kd, integrator_min=self.integrator_min, integrator_max=self.integrator_max) # If activated, initialize PID Autotune if self.autotune_activated: self.autotune_timestamp = time.time() try: self.autotune = PIDAutotune( self.setpoint, out_step=self.autotune_outstep, sampletime=self.period, out_min=0, out_max=self.period, noiseband=self.autotune_noiseband) except Exception as msg: self.logger.error(msg) self.stop_controller(deactivate_pid=True) self.ready.set() while self.running: if (self.method_start_act == 'Ended' and self.method_type == 'Duration'): self.stop_controller(ended_normally=False, deactivate_pid=True) self.logger.warning( "Method has ended. " "Activate the PID controller to start it again.") elif time.time() > self.timer: self.check_pid() time.sleep(self.sample_rate) except Exception as except_msg: self.logger.exception("Run Error: {err}".format( err=except_msg)) finally: # Turn off output used in PID when the controller is deactivated if self.raise_output_id and self.direction in ['raise', 'both']: self.control.output_off(self.raise_output_id, trigger_conditionals=True) if self.lower_output_id and self.direction in ['lower', 'both']: self.control.output_off(self.lower_output_id, trigger_conditionals=True) self.running = False self.logger.info("Deactivated in {:.1f} ms".format( (timeit.default_timer() - self.thread_shutdown_timer) * 1000)) def initialize_values(self): """Set PID parameters""" pid = db_retrieve_table_daemon(PID, unique_id=self.pid_id) self.is_activated = pid.is_activated self.is_held = pid.is_held self.is_paused = pid.is_paused self.method_id = pid.method_id self.direction = pid.direction self.raise_output_id = pid.raise_output_id self.raise_min_duration = pid.raise_min_duration self.raise_max_duration = pid.raise_max_duration self.raise_min_off_duration = pid.raise_min_off_duration self.lower_output_id = pid.lower_output_id self.lower_min_duration = pid.lower_min_duration self.lower_max_duration = pid.lower_max_duration self.lower_min_off_duration = pid.lower_min_off_duration self.Kp = pid.p self.Ki = pid.i self.Kd = pid.d self.integrator_min = pid.integrator_min self.integrator_max = pid.integrator_max self.period = pid.period self.start_offset = pid.start_offset self.max_measure_age = pid.max_measure_age self.default_setpoint = pid.setpoint self.setpoint = pid.setpoint self.band = pid.band self.store_lower_as_negative = pid.store_lower_as_negative # Autotune self.autotune_activated = pid.autotune_activated self.autotune_noiseband = pid.autotune_noiseband self.autotune_outstep = pid.autotune_outstep self.device_id = pid.measurement.split(',')[0] self.measurement_id = pid.measurement.split(',')[1] input_dev = db_retrieve_table_daemon(Input, unique_id=self.device_id) math = db_retrieve_table_daemon(Math, unique_id=self.device_id) if input_dev: self.input_duration = input_dev.period elif math: self.input_duration = math.period try: self.raise_output_type = db_retrieve_table_daemon( Output, unique_id=self.raise_output_id).output_type except AttributeError: self.raise_output_type = None try: self.lower_output_type = db_retrieve_table_daemon( Output, unique_id=self.lower_output_id).output_type except AttributeError: self.lower_output_type = None self.logger.info("PID Settings: {}".format(self.pid_parameters_str())) return "success" def check_pid(self): """ Get measurement and apply to PID controller """ # Ensure the timer ends in the future while time.time() > self.timer: self.timer = self.timer + self.period # If PID is active, retrieve measurement and update # the control variable. # A PID on hold will sustain the current output and # not update the control variable. if self.is_activated and (not self.is_paused or not self.is_held): self.get_last_measurement() if self.last_measurement_success: if self.method_id != '': # Update setpoint using a method this_pid = db_retrieve_table_daemon( PID, unique_id=self.pid_id) setpoint, ended = calculate_method_setpoint( self.method_id, PID, this_pid, Method, MethodData, self.logger) if ended: self.method_start_act = 'Ended' if setpoint is not None: self.setpoint = setpoint else: self.setpoint = self.default_setpoint # If autotune activated, determine control variable (output) from autotune if self.autotune_activated: if not self.autotune.run(self.last_measurement): self.control_variable = self.autotune.output if self.autotune_debug: self.logger.info('') self.logger.info("state: {}".format(self.autotune.state)) self.logger.info("output: {}".format(self.autotune.output)) else: # Autotune has finished timestamp = time.time() - self.autotune_timestamp self.logger.info('') self.logger.info('time: {0} min'.format(round(timestamp / 60))) self.logger.info('state: {0}'.format(self.autotune.state)) if self.autotune.state == PIDAutotune.STATE_SUCCEEDED: for rule in self.autotune.tuning_rules: params = self.autotune.get_pid_parameters(rule) self.logger.info('') self.logger.info('rule: {0}'.format(rule)) self.logger.info('Kp: {0}'.format(params.Kp)) self.logger.info('Ki: {0}'.format(params.Ki)) self.logger.info('Kd: {0}'.format(params.Kd)) self.stop_controller(deactivate_pid=True) else: # Calculate new control variable (output) from PID Controller # Original PID method self.control_variable = self.update_pid_output( self.last_measurement) # New PID method (untested) # self.control_variable = self.PID_Controller.calc( # self.last_measurement, self.setpoint) self.write_pid_values() # Write variables to database # Is PID in a state that allows manipulation of outputs if self.is_activated and (not self.is_paused or self.is_held): self.manipulate_output() def setup_method(self, method_id): """ Initialize method variables to start running a method """ self.method_id = '' method = db_retrieve_table_daemon(Method, unique_id=method_id) method_data = db_retrieve_table_daemon(MethodData) method_data = method_data.filter(MethodData.method_id == method_id) method_data_repeat = method_data.filter(MethodData.duration_sec == 0).first() pid = db_retrieve_table_daemon(PID, unique_id=self.pid_id) self.method_type = method.method_type self.method_start_act = pid.method_start_time self.method_start_time = None self.method_end_time = None if self.method_type == 'Duration': if self.method_start_act == 'Ended': # Method has ended and hasn't been instructed to begin again pass elif (self.method_start_act == 'Ready' or self.method_start_act is None): # Method has been instructed to begin now = datetime.datetime.now() self.method_start_time = now if method_data_repeat and method_data_repeat.duration_end: self.method_end_time = now + datetime.timedelta( seconds=float(method_data_repeat.duration_end)) with session_scope(MYCODO_DB_PATH) as db_session: mod_pid = db_session.query(PID).filter( PID.unique_id == self.pid_id).first() mod_pid.method_start_time = self.method_start_time mod_pid.method_end_time = self.method_end_time db_session.commit() else: # Method neither instructed to begin or not to # Likely there was a daemon restart ot power failure # Resume method with saved start_time self.method_start_time = datetime.datetime.strptime( str(pid.method_start_time), '%Y-%m-%d %H:%M:%S.%f') if method_data_repeat and method_data_repeat.duration_end: self.method_end_time = datetime.datetime.strptime( str(pid.method_end_time), '%Y-%m-%d %H:%M:%S.%f') if self.method_end_time > datetime.datetime.now(): self.logger.warning( "Resuming method {id}: started {start}, " "ends {end}".format( id=method_id, start=self.method_start_time, end=self.method_end_time)) else: self.method_start_act = 'Ended' else: self.method_start_act = 'Ended' self.method_id = method_id def write_pid_values(self): """ Write PID values to the measurement database """ if self.band: setpoint_band_lower = self.setpoint - self.band setpoint_band_upper = self.setpoint + self.band else: setpoint_band_lower = None setpoint_band_upper = None list_measurements = [ self.setpoint, setpoint_band_lower, setpoint_band_upper, self.P_value, self.I_value, self.D_value ] measurement_dict = {} measurements = self.device_measurements.filter( DeviceMeasurements.device_id == self.pid_id).all() for each_channel, each_measurement in enumerate(measurements): if (each_measurement.channel not in measurement_dict and each_measurement.channel < len(list_measurements)): # If setpoint, get unit from PID measurement if each_measurement.measurement_type == 'setpoint': setpoint_pid = db_retrieve_table_daemon( PID, unique_id=each_measurement.device_id) if setpoint_pid and ',' in setpoint_pid.measurement: pid_measurement = setpoint_pid.measurement.split(',')[1] setpoint_measurement = db_retrieve_table_daemon( DeviceMeasurements, unique_id=pid_measurement) if setpoint_measurement: conversion = db_retrieve_table_daemon( Conversion, unique_id=setpoint_measurement.conversion_id) _, unit, _ = return_measurement_info( setpoint_measurement, conversion) measurement_dict[each_channel] = { 'measurement': each_measurement.measurement, 'unit': unit, 'value': list_measurements[each_channel] } else: measurement_dict[each_channel] = { 'measurement': each_measurement.measurement, 'unit': each_measurement.unit, 'value': list_measurements[each_channel] } add_measurements_influxdb(self.pid_id, measurement_dict) def update_pid_output(self, current_value): """ Calculate PID output value from reference input and feedback :return: Manipulated, or control, variable. This is the PID output. :rtype: float :param current_value: The input, or process, variable (the actual measured condition by the input) :type current_value: float """ # Determine if hysteresis is enabled and if the PID should be applied setpoint = self.check_hysteresis(current_value) if setpoint is None: # Prevent PID variables form being manipulated and # restrict PID from operating. return 0 self.error = setpoint - current_value # Calculate P-value self.P_value = self.Kp * self.error # Calculate I-value self.integrator += self.error # First method for managing integrator if self.integrator > self.integrator_max: self.integrator = self.integrator_max elif self.integrator < self.integrator_min: self.integrator = self.integrator_min # Second method for regulating integrator # if self.period is not None: # if self.integrator * self.Ki > self.period: # self.integrator = self.period / self.Ki # elif self.integrator * self.Ki < -self.period: # self.integrator = -self.period / self.Ki self.I_value = self.integrator * self.Ki # Prevent large initial D-value if self.first_start: self.derivator = self.error self.first_start = False # Calculate D-value self.D_value = self.Kd * (self.error - self.derivator) self.derivator = self.error # Produce output form P, I, and D values pid_value = self.P_value + self.I_value + self.D_value return pid_value def check_hysteresis(self, measure): """ Determine if hysteresis is enabled and if the PID should be applied :return: float if the setpoint if the PID should be applied, None to restrict the PID :rtype: float or None :param measure: The PID input (or process) variable :type measure: float """ if self.band == 0: # If band is disabled, return setpoint return self.setpoint band_min = self.setpoint - self.band band_max = self.setpoint + self.band if self.direction == 'raise': if (measure < band_min or (band_min < measure < band_max and self.allow_raising)): self.allow_raising = True setpoint = band_max # New setpoint return setpoint # Apply the PID elif measure > band_max: self.allow_raising = False return None # Restrict the PID elif self.direction == 'lower': if (measure > band_max or (band_min < measure < band_max and self.allow_lowering)): self.allow_lowering = True setpoint = band_min # New setpoint return setpoint # Apply the PID elif measure < band_min: self.allow_lowering = False return None # Restrict the PID elif self.direction == 'both': if measure < band_min: setpoint = band_min # New setpoint if not self.allow_raising: # Reset integrator and derivator upon direction switch self.integrator = 0.0 self.derivator = 0.0 self.allow_raising = True self.allow_lowering = False elif measure > band_max: setpoint = band_max # New setpoint if not self.allow_lowering: # Reset integrator and derivator upon direction switch self.integrator = 0.0 self.derivator = 0.0 self.allow_raising = False self.allow_lowering = True else: return None # Restrict the PID return setpoint # Apply the PID def get_last_measurement(self): """ Retrieve the latest input measurement from InfluxDB :rtype: None """ self.last_measurement_success = False # Get latest measurement from influxdb try: device_measurement = get_measurement(self.measurement_id) if device_measurement: conversion = db_retrieve_table_daemon( Conversion, unique_id=device_measurement.conversion_id) else: conversion = None channel, unit, measurement = return_measurement_info( device_measurement, conversion) self.last_measurement = read_last_influxdb( self.device_id, unit, measurement, channel, int(self.max_measure_age)) if self.last_measurement: self.last_time = self.last_measurement[0] self.last_measurement = self.last_measurement[1] utc_dt = datetime.datetime.strptime( self.last_time.split(".")[0], '%Y-%m-%dT%H:%M:%S') utc_timestamp = calendar.timegm(utc_dt.timetuple()) local_timestamp = str(datetime.datetime.fromtimestamp(utc_timestamp)) self.logger.debug("Latest (CH{ch}, Unit: {unit}): {last} @ {ts}".format( ch=channel, unit=unit, last=self.last_measurement, ts=local_timestamp)) if calendar.timegm(time.gmtime()) - utc_timestamp > self.max_measure_age: self.logger.error( "Last measurement was {last_sec} seconds ago, however" " the maximum measurement age is set to {max_sec}" " seconds.".format( last_sec=calendar.timegm(time.gmtime()) - utc_timestamp, max_sec=self.max_measure_age )) self.last_measurement_success = True else: self.logger.warning("No data returned from influxdb") except requests.ConnectionError: self.logger.error("Failed to read measurement from the " "influxdb database: Could not connect.") except Exception as except_msg: self.logger.exception( "Exception while reading measurement from the influxdb " "database: {err}".format(err=except_msg)) def manipulate_output(self): """ Activate output based on PID control variable and whether the manipulation directive is to raise, lower, or both. :rtype: None """ # If the last measurement was able to be retrieved and was entered within the past minute if self.last_measurement_success: # # PID control variable is positive, indicating a desire to raise # the environmental condition # if self.direction in ['raise', 'both'] and self.raise_output_id: if self.control_variable > 0: # Determine if the output should be PWM or a duration if self.raise_output_type in ['pwm', 'command_pwm', 'python_pwm']: self.raise_duty_cycle = float("{0:.1f}".format( self.control_var_to_duty_cycle(self.control_variable))) # Ensure the duty cycle doesn't exceed the min/max if (self.raise_max_duration and self.raise_duty_cycle > self.raise_max_duration): self.raise_duty_cycle = self.raise_max_duration elif (self.raise_min_duration and self.raise_duty_cycle < self.raise_min_duration): self.raise_duty_cycle = self.raise_min_duration self.logger.debug( "Setpoint: {sp}, Control Variable: {cv}, Output: PWM output " "{id} to {dc:.1f}%".format( sp=self.setpoint, cv=self.control_variable, id=self.raise_output_id, dc=self.raise_duty_cycle)) # Activate pwm with calculated duty cycle self.control.output_on(self.raise_output_id, duty_cycle=self.raise_duty_cycle) self.write_pid_output_influxdb( 'percent', 'duty_cycle', 7, self.control_var_to_duty_cycle(self.control_variable)) elif self.raise_output_type in ['command', 'python', 'wired', 'wireless_rpi_rf']: # Ensure the output on duration doesn't exceed the set maximum if (self.raise_max_duration and self.control_variable > self.raise_max_duration): self.raise_seconds_on = self.raise_max_duration else: self.raise_seconds_on = float("{0:.2f}".format( self.control_variable)) if self.raise_seconds_on > self.raise_min_duration: # Activate raise_output for a duration self.logger.debug( "Setpoint: {sp} Output: {cv} to output " "{id}".format( sp=self.setpoint, cv=self.control_variable, id=self.raise_output_id)) self.control.output_on( self.raise_output_id, duration=self.raise_seconds_on, min_off=self.raise_min_off_duration) self.write_pid_output_influxdb( 's', 'duration_time', 6, self.control_variable) else: if self.raise_output_type in ['pwm', 'command_pwm', 'python_pwm']: self.control.output_on(self.raise_output_id, duty_cycle=0) # # PID control variable is negative, indicating a desire to lower # the environmental condition # if self.direction in ['lower', 'both'] and self.lower_output_id: if self.control_variable < 0: # Determine if the output should be PWM or a duration if self.lower_output_type in ['pwm', 'command_pwm', 'python_pwm']: self.lower_duty_cycle = float("{0:.1f}".format( self.control_var_to_duty_cycle(abs(self.control_variable)))) # Ensure the duty cycle doesn't exceed the min/max if (self.lower_max_duration and self.lower_duty_cycle > self.lower_max_duration): self.lower_duty_cycle = self.lower_max_duration elif (self.lower_min_duration and self.lower_duty_cycle < self.lower_min_duration): self.lower_duty_cycle = self.lower_min_duration self.logger.debug( "Setpoint: {sp}, Control Variable: {cv}, " "Output: PWM output {id} to {dc:.1f}%".format( sp=self.setpoint, cv=self.control_variable, id=self.lower_output_id, dc=self.lower_duty_cycle)) if self.store_lower_as_negative: stored_duty_cycle = -abs(self.lower_duty_cycle) stored_control_variable = -self.control_var_to_duty_cycle(abs(self.control_variable)) else: stored_duty_cycle = abs(self.lower_duty_cycle) stored_control_variable = self.control_var_to_duty_cycle(abs(self.control_variable)) # Activate pwm with calculated duty cycle self.control.output_on( self.lower_output_id, duty_cycle=stored_duty_cycle) self.write_pid_output_influxdb( 'percent', 'duty_cycle', 7, stored_control_variable) elif self.lower_output_type in ['command', 'python', 'wired', 'wireless_rpi_rf']: # Ensure the output on duration doesn't exceed the set maximum if (self.lower_max_duration and abs(self.control_variable) > self.lower_max_duration): self.lower_seconds_on = self.lower_max_duration else: self.lower_seconds_on = float("{0:.2f}".format( abs(self.control_variable))) if self.store_lower_as_negative: stored_seconds_on = -abs(self.lower_seconds_on) stored_control_variable = -abs(self.control_variable) else: stored_seconds_on = abs(self.lower_seconds_on) stored_control_variable = abs(self.control_variable) if self.lower_seconds_on > self.lower_min_duration: # Activate lower_output for a duration self.logger.debug("Setpoint: {sp} Output: {cv} to " "output {id}".format( sp=self.setpoint, cv=self.control_variable, id=self.lower_output_id)) self.control.output_on( self.lower_output_id, duration=stored_seconds_on, min_off=self.lower_min_off_duration) self.write_pid_output_influxdb( 's', 'duration_time', 6, stored_control_variable) else: if self.lower_output_type in ['pwm', 'command_pwm', 'python_pwm']: self.control.output_on(self.lower_output_id, duty_cycle=0) else: if self.direction in ['raise', 'both'] and self.raise_output_id: self.control.output_off(self.raise_output_id) if self.direction in ['lower', 'both'] and self.lower_output_id: self.control.output_off(self.lower_output_id) def pid_parameters_str(self): return "Device ID: {did}, " \ "Measurement ID: {mid}, " \ "Direction: {dir}, " \ "Period: {per}, " \ "Setpoint: {sp}, " \ "Band: {band}, " \ "Kp: {kp}, " \ "Ki: {ki}, " \ "Kd: {kd}, " \ "Integrator Min: {imn}, " \ "Integrator Max {imx}, " \ "Output Raise: {opr}, " \ "Output Raise Min On: {oprmnon}, " \ "Output Raise Max On: {oprmxon}, " \ "Output Raise Min Off: {oprmnoff}, " \ "Output Lower: {opl}, " \ "Output Lower Min On: {oplmnon}, " \ "Output Lower Max On: {oplmxon}, " \ "Output Lower Min Off: {oplmnoff}, " \ "Setpoint Tracking: {spt}".format( did=self.device_id, mid=self.measurement_id, dir=self.direction, per=self.period, sp=self.setpoint, band=self.band, kp=self.Kp, ki=self.Ki, kd=self.Kd, imn=self.integrator_min, imx=self.integrator_max, opr=self.raise_output_id, oprmnon=self.raise_min_duration, oprmxon=self.raise_max_duration, oprmnoff=self.raise_min_off_duration, opl=self.lower_output_id, oplmnon=self.lower_min_duration, oplmxon=self.lower_max_duration, oplmnoff=self.lower_min_off_duration, spt=self.method_id) def control_var_to_duty_cycle(self, control_variable): # Convert control variable to duty cycle if control_variable > self.period: return 100.0 else: return float((control_variable / self.period) * 100) def write_pid_output_influxdb(self, unit, measurement, channel, value): write_pid_out_db = threading.Thread( target=write_influxdb_value, args=(self.pid_id, unit, value,), kwargs={'measure': measurement, 'channel': channel}) write_pid_out_db.start() def pid_mod(self): if self.initialize_values(): return "success" else: return "error" def pid_hold(self): self.is_held = True self.logger.info("Hold") return "success" def pid_pause(self): self.is_paused = True self.logger.info("Pause") return "success" def pid_resume(self): self.is_activated = True self.is_held = False self.is_paused = False self.logger.info("Resume") return "success" def set_setpoint(self, setpoint): """ Set the setpoint of PID """ self.setpoint = float(setpoint) with session_scope(MYCODO_DB_PATH) as db_session: mod_pid = db_session.query(PID).filter( PID.unique_id == self.pid_id).first() mod_pid.setpoint = setpoint db_session.commit() return "Setpoint set to {sp}".format(sp=setpoint) def set_method(self, method_id): """ Set the method of PID """ with session_scope(MYCODO_DB_PATH) as db_session: mod_pid = db_session.query(PID).filter( PID.unique_id == self.pid_id).first() mod_pid.method_id = method_id if method_id == '': self.method_id = '' db_session.commit() else: mod_pid.method_start_time = 'Ready' mod_pid.method_end_time = None db_session.commit() self.setup_method(method_id) return "Method set to {me}".format(me=method_id) def set_integrator(self, integrator): """ Set the integrator of the controller """ self.integrator = float(integrator) return "Integrator set to {i}".format(i=self.integrator) def set_derivator(self, derivator): """ Set the derivator of the controller """ self.derivator = float(derivator) return "Derivator set to {d}".format(d=self.derivator) def set_kp(self, p): """ Set Kp gain of the controller """ self.Kp = float(p) with session_scope(MYCODO_DB_PATH) as db_session: mod_pid = db_session.query(PID).filter( PID.unique_id == self.pid_id).first() mod_pid.p = p db_session.commit() return "Kp set to {kp}".format(kp=self.Kp) def set_ki(self, i): """ Set Ki gain of the controller """ self.Ki = float(i) with session_scope(MYCODO_DB_PATH) as db_session: mod_pid = db_session.query(PID).filter( PID.unique_id == self.pid_id).first() mod_pid.i = i db_session.commit() return "Ki set to {ki}".format(ki=self.Ki) def set_kd(self, d): """ Set Kd gain of the controller """ self.Kd = float(d) with session_scope(MYCODO_DB_PATH) as db_session: mod_pid = db_session.query(PID).filter( PID.unique_id == self.pid_id).first() mod_pid.d = d db_session.commit() return "Kd set to {kd}".format(kd=self.Kd) def get_setpoint(self): return self.setpoint def get_error(self): return self.error def get_integrator(self): return self.integrator def get_derivator(self): return self.derivator def get_kp(self): return self.Kp def get_ki(self): return self.Ki def get_kd(self): return self.Kd def is_running(self): return self.running def stop_controller(self, ended_normally=True, deactivate_pid=False): self.thread_shutdown_timer = timeit.default_timer() self.running = False # Unset method start time if self.method_id != '' and ended_normally: with session_scope(MYCODO_DB_PATH) as db_session: mod_pid = db_session.query(PID).filter( PID.unique_id == self.pid_id).first() mod_pid.method_start_time = 'Ended' mod_pid.method_end_time = None db_session.commit() # Deactivate PID and Autotune if deactivate_pid: with session_scope(MYCODO_DB_PATH) as db_session: mod_pid = db_session.query(PID).filter( PID.unique_id == self.pid_id).first() mod_pid.is_activated = False mod_pid.autotune_activated = False db_session.commit()
class PIDController(AbstractController, threading.Thread): """ Class to operate discrete PID controller in Mycodo """ def __init__(self, ready, unique_id): threading.Thread.__init__(self) super().__init__(ready, unique_id=unique_id, name=__name__) self.unique_id = unique_id self.sample_rate = None self.dict_outputs = None self.control = DaemonControl() self.PID_Controller = None self.setpoint = None self.device_measurements = None self.device_id = None self.measurement_id = None self.log_level_debug = None self.last_time = None self.last_measurement = None self.last_measurement_success = False self.is_activated = None self.is_held = None self.is_paused = None self.measurement = None self.setpoint_tracking_type = None self.setpoint_tracking_id = None self.setpoint_tracking_max_age = None self.raise_output_id = None self.raise_output_channel_id = None self.raise_output_channel = None self.raise_output_type = None self.raise_min_duration = None self.raise_max_duration = None self.raise_min_off_duration = None self.raise_always_min_pwm = None self.lower_output_id = None self.lower_output_channel_id = None self.lower_output_channel = None self.lower_output_type = None self.lower_min_duration = None self.lower_max_duration = None self.lower_min_off_duration = None self.lower_always_min_pwm = None self.period = 0 self.start_offset = 0 self.max_measure_age = None self.send_lower_as_negative = None self.store_lower_as_negative = None self.timer = 0 # Check if a method is set for this PID self.method_type = None self.method_start_time = None self.method_end_time = None def loop(self): if time.time() > self.timer: while time.time() > self.timer: self.timer = self.timer + self.period self.attempt_execute(self.check_pid) def run_finally(self): # Turn off output used in PID when the controller is deactivated if self.raise_output_id and self.PID_Controller.direction in [ 'raise', 'both' ]: self.control.output_off(self.raise_output_id, output_channel=self.raise_output_channel, trigger_conditionals=True) if self.lower_output_id and self.PID_Controller.direction in [ 'lower', 'both' ]: self.control.output_off(self.lower_output_id, output_channel=self.lower_output_channel, trigger_conditionals=True) def initialize_variables(self): """Set PID parameters.""" self.dict_outputs = parse_output_information() self.sample_rate = db_retrieve_table_daemon( Misc, entry='first').sample_rate_controller_pid self.device_measurements = db_retrieve_table_daemon(DeviceMeasurements) pid = db_retrieve_table_daemon(PID, unique_id=self.unique_id) self.log_level_debug = pid.log_level_debug self.set_log_level_debug(self.log_level_debug) self.device_id = pid.measurement.split(',')[0] self.measurement_id = pid.measurement.split(',')[1] self.is_activated = pid.is_activated self.is_held = pid.is_held self.is_paused = pid.is_paused self.setpoint_tracking_type = pid.setpoint_tracking_type self.setpoint_tracking_id = pid.setpoint_tracking_id self.setpoint_tracking_max_age = pid.setpoint_tracking_max_age if pid.raise_output_id and "," in pid.raise_output_id: self.raise_output_id = pid.raise_output_id.split(",")[0] self.raise_output_channel_id = pid.raise_output_id.split(",")[1] output_channel = db_retrieve_table_daemon( OutputChannel, unique_id=self.raise_output_channel_id) self.raise_output_channel = output_channel.channel self.raise_output_type = pid.raise_output_type self.raise_min_duration = pid.raise_min_duration self.raise_max_duration = pid.raise_max_duration self.raise_min_off_duration = pid.raise_min_off_duration self.raise_always_min_pwm = pid.raise_always_min_pwm if pid.lower_output_id and "," in pid.lower_output_id: self.lower_output_id = pid.lower_output_id.split(",")[0] self.lower_output_channel_id = pid.lower_output_id.split(",")[1] output_channel = db_retrieve_table_daemon( OutputChannel, unique_id=self.lower_output_channel_id) self.lower_output_channel = output_channel.channel self.lower_output_type = pid.lower_output_type self.lower_min_duration = pid.lower_min_duration self.lower_max_duration = pid.lower_max_duration self.lower_min_off_duration = pid.lower_min_off_duration self.lower_always_min_pwm = pid.lower_always_min_pwm self.period = pid.period self.start_offset = pid.start_offset self.max_measure_age = pid.max_measure_age self.send_lower_as_negative = pid.send_lower_as_negative self.store_lower_as_negative = pid.store_lower_as_negative self.timer = time.time() + self.start_offset self.setpoint = pid.setpoint # Initialize PID Controller if self.PID_Controller is None: self.PID_Controller = PIDControl(self.logger, pid.setpoint, pid.p, pid.i, pid.d, pid.direction, pid.band, pid.integrator_min, pid.integrator_max) else: # Set PID options self.PID_Controller.setpoint = pid.setpoint self.PID_Controller.Kp = pid.p self.PID_Controller.Ki = pid.i self.PID_Controller.Kd = pid.d self.PID_Controller.direction = pid.direction self.PID_Controller.band = pid.band self.PID_Controller.integrator_min = pid.integrator_min self.PID_Controller.integrator_max = pid.integrator_max self.PID_Controller.first_start = True if self.setpoint_tracking_type == 'method' and self.setpoint_tracking_id != '': self.setup_method(self.setpoint_tracking_id) if self.is_paused: self.logger.info("Starting Paused") elif self.is_held: self.logger.info("Starting Held") self.logger.info(f"PID Settings: {self.pid_parameters_str()}") self.ready.set() self.running = True return "success" def check_pid(self): """Get measurement and apply to PID controller.""" # If PID is active, retrieve measurement and update # the control variable. # A PID on hold will sustain the current output and # not update the control variable. if self.is_activated and (not self.is_paused or not self.is_held): self.get_last_measurement_pid() if self.last_measurement_success: if self.setpoint_tracking_type == 'method' and self.setpoint_tracking_id != '': # Update setpoint using a method this_pid = db_retrieve_table_daemon( PID, unique_id=self.unique_id) now = datetime.datetime.now() method = load_method_handler(self.setpoint_tracking_id, self.logger) new_setpoint, ended = method.calculate_setpoint( now, this_pid.method_start_time) self.logger.debug( f"Method {self.setpoint_tracking_id} {method} {now} {this_pid.method_start_time}" ) if ended: # point in time is out of method range with session_scope(MYCODO_DB_PATH) as db_session: # Overwrite this_controller with committable connection this_pid = db_session.query(PID).filter( PID.unique_id == self.unique_id).first() self.logger.debug("Ended") # Duration method has ended, reset method_start_time locally and in DB this_pid.method_start_time = None this_pid.method_end_time = None this_pid.is_activated = False db_session.commit() self.is_activated = False self.stop_controller() db_session.commit() if new_setpoint is not None: self.logger.debug( f"New setpoint = {new_setpoint} {ended}") self.PID_Controller.setpoint = new_setpoint else: self.logger.debug( f"New setpoint = default {self.setpoint} {ended}") self.PID_Controller.setpoint = self.setpoint if self.setpoint_tracking_type == 'input-math' and self.setpoint_tracking_id != '': # Update setpoint using an Input device_id = self.setpoint_tracking_id.split(',')[0] measurement_id = self.setpoint_tracking_id.split(',')[1] measurement = get_measurement(measurement_id) if not measurement: return False, None conversion = db_retrieve_table_daemon( Conversion, unique_id=measurement.conversion_id) channel, unit, measurement = return_measurement_info( measurement, conversion) last_measurement = read_influxdb_single( device_id, unit, channel, measure=measurement, duration_sec=self.setpoint_tracking_max_age, value='LAST') if last_measurement[1] is not None: self.PID_Controller.setpoint = last_measurement[1] else: self.logger.debug( "Could not find measurement for Setpoint " f"Tracking. Max Age of {self.setpoint_tracking_max_age} exceeded for measuring " f"device ID {device_id} (measurement {measurement_id})" ) self.PID_Controller.setpoint = None # Calculate new control variable (output) from PID Controller self.PID_Controller.update_pid_output(self.last_measurement) self.write_pid_values() # Write variables to database # Is PID in a state that allows manipulation of outputs if (self.is_activated and self.PID_Controller.setpoint is not None and (not self.is_paused or self.is_held)): self.manipulate_output() def setup_method(self, method_id): """Initialize method variables to start running a method.""" self.setpoint_tracking_id = '' method = load_method_handler(method_id, self.logger) this_controller = db_retrieve_table_daemon(PID, unique_id=self.unique_id) self.method_type = method.method_type if parse_db_time(this_controller.method_start_time) is None: self.method_start_time = datetime.datetime.now() self.method_end_time = method.determine_end_time( self.method_start_time) self.logger.info( f"Starting method {self.method_start_time} {self.method_end_time}" ) with session_scope(MYCODO_DB_PATH) as db_session: this_controller = db_session.query(PID) this_controller = this_controller.filter( PID.unique_id == self.unique_id).first() this_controller.method_start_time = self.method_start_time this_controller.method_end_time = self.method_end_time db_session.commit() else: # already running, potentially the daemon has been restarted self.method_start_time = this_controller.method_start_time self.method_end_time = this_controller.method_end_time self.setpoint_tracking_id = method_id self.logger.debug(f"Method enabled: {self.setpoint_tracking_id}") def stop_method(self): self.method_start_time = None self.method_end_time = None with session_scope(MYCODO_DB_PATH) as db_session: this_controller = db_session.query(PID) this_controller = this_controller.filter( PID.unique_id == self.unique_id).first() this_controller.is_activated = False this_controller.method_start_time = None this_controller.method_end_time = None db_session.commit() self.stop_controller() self.is_activated = False self.logger.warning( "Method has ended. " "Activate the Trigger controller to start it again.") def write_pid_values(self): """Write PID values to the measurement database""" if self.PID_Controller.band: setpoint_band_lower = self.PID_Controller.setpoint - self.PID_Controller.band setpoint_band_upper = self.PID_Controller.setpoint + self.PID_Controller.band else: setpoint_band_lower = None setpoint_band_upper = None list_measurements = [ self.PID_Controller.setpoint, setpoint_band_lower, setpoint_band_upper, self.PID_Controller.P_value, self.PID_Controller.I_value, self.PID_Controller.D_value ] measurement_dict = {} measurements = self.device_measurements.filter( DeviceMeasurements.device_id == self.unique_id).all() for each_channel, each_measurement in enumerate(measurements): if (each_measurement.channel not in measurement_dict and each_measurement.channel < len(list_measurements)): # If setpoint, get unit from PID measurement if each_measurement.measurement_type == 'setpoint': setpoint_pid = db_retrieve_table_daemon( PID, unique_id=each_measurement.device_id) if setpoint_pid and ',' in setpoint_pid.measurement: pid_measurement = setpoint_pid.measurement.split( ',')[1] setpoint_measurement = db_retrieve_table_daemon( DeviceMeasurements, unique_id=pid_measurement) if setpoint_measurement: conversion = db_retrieve_table_daemon( Conversion, unique_id=setpoint_measurement.conversion_id) _, unit, _ = return_measurement_info( setpoint_measurement, conversion) measurement_dict[each_channel] = { 'measurement': each_measurement.measurement, 'unit': unit, 'value': list_measurements[each_channel] } else: measurement_dict[each_channel] = { 'measurement': each_measurement.measurement, 'unit': each_measurement.unit, 'value': list_measurements[each_channel] } add_measurements_influxdb(self.unique_id, measurement_dict) def get_last_measurement_pid(self): """ Retrieve the latest input measurement from InfluxDB :rtype: None """ self.last_measurement_success = False # Get latest measurement from influxdb try: device_measurement = get_measurement(self.measurement_id) if device_measurement: conversion = db_retrieve_table_daemon( Conversion, unique_id=device_measurement.conversion_id) else: conversion = None channel, unit, measurement = return_measurement_info( device_measurement, conversion) self.last_measurement = read_influxdb_single( self.device_id, unit, channel, measure=measurement, duration_sec=int(self.max_measure_age), value='LAST') if self.last_measurement: self.last_time = self.last_measurement[0] self.last_measurement = self.last_measurement[1] utc_dt = datetime.datetime.strptime( self.last_time.split(".")[0], '%Y-%m-%dT%H:%M:%S') utc_timestamp = calendar.timegm(utc_dt.timetuple()) local_timestamp = str( datetime.datetime.fromtimestamp(utc_timestamp)) self.logger.debug( f"Latest (CH{channel}, Unit: {unit}): {self.last_measurement} @ {local_timestamp}" ) if calendar.timegm( time.gmtime()) - utc_timestamp > self.max_measure_age: sec = calendar.timegm(time.gmtime()) - utc_timestamp self.logger.error( f"Last measurement was {sec} seconds ago, however the maximum " f"measurement age is set to {self.max_measure_age} seconds." ) self.last_measurement_success = True else: self.logger.warning("No data returned from influxdb") except requests.ConnectionError: self.logger.error( "Failed to read measurement from the influxdb database: Could not connect." ) except Exception: self.logger.exception( "Exception while reading measurement from the influxdb database" ) def manipulate_output(self): """ Activate output based on PID control variable and whether the manipulation directive is to raise, lower, or both. :rtype: None """ # If the last measurement was able to be retrieved and was entered within the past minute if self.last_measurement_success: # # PID control variable is positive, indicating a desire to raise # the environmental condition # if self.PID_Controller.direction in ['raise', 'both' ] and self.raise_output_id: if self.PID_Controller.control_variable > 0: if self.raise_output_type == 'pwm': raise_duty_cycle = self.control_var_to_duty_cycle( self.PID_Controller.control_variable) # Ensure the duty cycle doesn't exceed the min/max if (self.raise_max_duration and raise_duty_cycle > self.raise_max_duration): raise_duty_cycle = self.raise_max_duration elif (self.raise_min_duration and raise_duty_cycle < self.raise_min_duration): raise_duty_cycle = self.raise_min_duration self.logger.debug( f"Setpoint: {self.PID_Controller.setpoint}, " f"Control Variable: {self.PID_Controller.control_variable}, " f"Output: PWM output {self.raise_output_id} " f"CH{self.raise_output_channel} to {raise_duty_cycle:.1f}%" ) # Activate pwm with calculated duty cycle self.control.output_on( self.raise_output_id, output_type='pwm', amount=raise_duty_cycle, output_channel=self.raise_output_channel) self.write_pid_output_influxdb( 'percent', 'duty_cycle', 7, self.control_var_to_duty_cycle( self.PID_Controller.control_variable)) elif self.raise_output_type == 'on_off': raise_seconds_on = self.PID_Controller.control_variable # Ensure the output on duration doesn't exceed the set maximum if (self.raise_max_duration and raise_seconds_on > self.raise_max_duration): raise_seconds_on = self.raise_max_duration if raise_seconds_on >= self.raise_min_duration: # Activate raise_output for a duration self.logger.debug( f"Setpoint: {self.PID_Controller.setpoint} " f"Output: {self.PID_Controller.control_variable} sec to " f"output {self.raise_output_id} CH{self.raise_output_channel}" ) self.control.output_on( self.raise_output_id, output_type='sec', amount=raise_seconds_on, min_off=self.raise_min_off_duration, output_channel=self.raise_output_channel) self.write_pid_output_influxdb( 's', 'duration_time', 6, self.PID_Controller.control_variable) elif self.raise_output_type == 'value': raise_value = self.PID_Controller.control_variable # Ensure the duty cycle doesn't exceed the min/max if (self.raise_max_duration and raise_value > self.raise_max_duration): raise_value = self.raise_max_duration if raise_value >= self.raise_min_duration: # Activate raise_output for a value self.logger.debug( f"Setpoint: {self.PID_Controller.setpoint} " f"Output: {self.PID_Controller.control_variable} to " f"output {self.raise_output_id} CH{self.raise_output_channel}" ) self.control.output_on( self.raise_output_id, output_type='value', amount=raise_value, min_off=self.raise_min_off_duration, output_channel=self.raise_output_channel) self.write_pid_output_influxdb('none', 'unitless', 9, raise_value) elif self.raise_output_type == 'volume': raise_volume = self.PID_Controller.control_variable # Ensure the duty cycle doesn't exceed the min/max if (self.raise_max_duration and raise_volume > self.raise_max_duration): raise_volume = self.raise_max_duration if raise_volume >= self.raise_min_duration: # Activate raise_output for a volume (ml) self.logger.debug( f"Setpoint: {self.PID_Controller.setpoint} " f"Output: {self.PID_Controller.control_variable} ml to " f"output {self.raise_output_id} CH{self.raise_output_channel}" ) self.control.output_on( self.raise_output_id, output_type='vol', amount=raise_volume, min_off=self.raise_min_off_duration, output_channel=self.raise_output_channel) self.write_pid_output_influxdb( 'ml', 'volume', 8, self.PID_Controller.control_variable) elif self.raise_output_type == 'pwm' and not self.raise_always_min_pwm: # Turn PWM Off if PWM Output and not instructed to always be at least min self.control.output_on( self.raise_output_id, output_type='pwm', amount=0, output_channel=self.raise_output_channel) # # PID control variable is negative, indicating a desire to lower # the environmental condition # if self.PID_Controller.direction in ['lower', 'both' ] and self.lower_output_id: if self.PID_Controller.control_variable < 0: if self.lower_output_type == 'pwm': lower_duty_cycle = self.control_var_to_duty_cycle( abs(self.PID_Controller.control_variable)) # Ensure the duty cycle doesn't exceed the min/max if (self.lower_max_duration and lower_duty_cycle > self.lower_max_duration): lower_duty_cycle = self.lower_max_duration elif (self.lower_min_duration and lower_duty_cycle < self.lower_min_duration): lower_duty_cycle = self.lower_min_duration self.logger.debug( f"Setpoint: {self.PID_Controller.setpoint}, " f"Control Variable: {self.PID_Controller.control_variable}, " f"Output: PWM output {self.lower_output_id} " f"CH{self.lower_output_channel} to {lower_duty_cycle:.1f}%" ) if self.store_lower_as_negative: store_duty_cycle = -self.control_var_to_duty_cycle( abs(self.PID_Controller.control_variable)) else: store_duty_cycle = self.control_var_to_duty_cycle( abs(self.PID_Controller.control_variable)) if self.send_lower_as_negative: send_duty_cycle = -abs(lower_duty_cycle) else: send_duty_cycle = abs(lower_duty_cycle) # Activate pwm with calculated duty cycle self.control.output_on( self.lower_output_id, output_type='pwm', amount=send_duty_cycle, output_channel=self.lower_output_channel) self.write_pid_output_influxdb('percent', 'duty_cycle', 7, store_duty_cycle) elif self.lower_output_type == 'on_off': lower_seconds_on = abs( self.PID_Controller.control_variable) # Ensure the output on duration doesn't exceed the set maximum if (self.lower_max_duration and lower_seconds_on > self.lower_max_duration): lower_seconds_on = self.lower_max_duration if self.store_lower_as_negative: store_amount_on = -abs( self.PID_Controller.control_variable) else: store_amount_on = abs( self.PID_Controller.control_variable) if self.send_lower_as_negative: send_amount_on = -lower_seconds_on else: send_amount_on = lower_seconds_on if lower_seconds_on >= self.lower_min_duration: # Activate lower_output for a duration self.logger.debug( f"Setpoint: {self.PID_Controller.setpoint} " f"Output: {self.PID_Controller.control_variable} sec to " f"output {self.lower_output_id} CH{self.lower_output_channel}" ) self.control.output_on( self.lower_output_id, output_type='sec', amount=send_amount_on, min_off=self.lower_min_off_duration, output_channel=self.lower_output_channel) self.write_pid_output_influxdb('s', 'duration_time', 6, store_amount_on) elif self.lower_output_type == 'value': lower_value = abs(self.PID_Controller.control_variable) # Ensure the output value doesn't exceed the set maximum if (self.lower_max_duration and lower_value > self.lower_max_duration): lower_value = self.lower_max_duration if self.store_lower_as_negative: store_value = -abs( self.PID_Controller.control_variable) else: store_value = abs( self.PID_Controller.control_variable) if self.send_lower_as_negative: send_value = -lower_value else: send_value = lower_value if lower_value >= self.lower_min_duration: # Activate lower_output for a value self.logger.debug( f"Setpoint: {self.PID_Controller.setpoint} " f"Output: {self.PID_Controller.control_variable} to " f"output {self.lower_output_id} CH{self.lower_output_channel}" ) self.control.output_on( self.lower_output_id, output_type='value', amount=send_value, min_off=self.lower_min_off_duration, output_channel=self.lower_output_channel) self.write_pid_output_influxdb('none', 'unitless', 9, store_value) elif self.lower_output_type == 'volume': lower_volume = abs( self.PID_Controller.control_variable) # Ensure the output volume doesn't exceed the set maximum if (self.lower_max_duration and lower_volume > self.lower_max_duration): lower_volume = self.lower_max_duration if self.store_lower_as_negative: store_volume = -abs( self.PID_Controller.control_variable) else: store_volume = abs( self.PID_Controller.control_variable) if self.send_lower_as_negative: send_volume = -lower_volume else: send_volume = lower_volume if lower_volume >= self.lower_min_duration: # Activate lower_output for a volume (ml) self.logger.debug( f"Setpoint: {self.PID_Controller.setpoint} " f"Output: {self.PID_Controller.control_variable} ml to " f"output {self.lower_output_id} CH{self.lower_output_channel}" ) self.control.output_on( self.lower_output_id, output_type='vol', amount=send_volume, min_off=self.lower_min_off_duration, output_channel=self.lower_output_channel) self.write_pid_output_influxdb('ml', 'volume', 8, store_volume) elif self.lower_output_type == 'pwm' and not self.lower_always_min_pwm: # Turn PWM Off if PWM Output and not instructed to always be at least min self.control.output_on( self.lower_output_id, output_type='pwm', amount=0, output_channel=self.lower_output_channel) else: self.logger.debug( "Last measurement unsuccessful. Turning outputs off.") if self.PID_Controller.direction in ['raise', 'both' ] and self.raise_output_id: self.control.output_off( self.raise_output_id, output_channel=self.raise_output_channel) if self.PID_Controller.direction in ['lower', 'both' ] and self.lower_output_id: self.control.output_off( self.lower_output_id, output_channel=self.lower_output_channel) def pid_parameters_str(self): return f"Device ID: {self.device_id}, " \ f"Measurement ID: {self.measurement_id}, " \ f"Direction: {self.PID_Controller.direction}, " \ f"Period: {self.period}, " \ f"Setpoint: {self.PID_Controller.setpoint}, " \ f"Band: {self.PID_Controller.band}, " \ f"Kp: {self.PID_Controller.Kp}, " \ f"Ki: {self.PID_Controller.Ki}, " \ f"Kd: {self.PID_Controller.Kd}, " \ f"Integrator Min: {self.PID_Controller.integrator_min}, " \ f"Integrator Max {self.PID_Controller.integrator_max}, " \ f"Output Raise: {self.raise_output_id}, " \ f"Output Raise Channel: {self.raise_output_channel}, " \ f"Output Raise Type: {self.raise_output_type}, " \ f"Output Raise Min On: {self.raise_min_duration}, " \ f"Output Raise Max On: {self.raise_max_duration}, " \ f"Output Raise Min Off: {self.raise_min_off_duration}, " \ f"Output Raise Always Min: {self.raise_always_min_pwm}, " \ f"Output Lower: {self.lower_output_id}, " \ f"Output Lower Channel: {self.lower_output_channel}, " \ f"Output Lower Type: {self.lower_output_type}, " \ f"Output Lower Min On: {self.lower_min_duration}, " \ f"Output Lower Max On: {self.lower_max_duration}, " \ f"Output Lower Min Off: {self.lower_min_off_duration}, " \ f"Output Lower Always Min: {self.lower_always_min_pwm}, " \ f"Setpoint Tracking Type: {self.setpoint_tracking_type}, " \ f"Setpoint Tracking ID: {self.setpoint_tracking_id}" def control_var_to_duty_cycle(self, control_variable): # Convert control variable to duty cycle if control_variable > self.period: return 100.0 else: return float((control_variable / self.period) * 100) @staticmethod def return_output_channel(output_channel_id): output_channel = db_retrieve_table_daemon(OutputChannel, unique_id=output_channel_id) if output_channel and output_channel.channel is not None: return output_channel.channel def write_pid_output_influxdb(self, unit, measurement, channel, value): write_pid_out_db = threading.Thread(target=write_influxdb_value, args=( self.unique_id, unit, value, ), kwargs={ 'measure': measurement, 'channel': channel }) write_pid_out_db.start() def pid_mod(self): if self.initialize_variables(): return "success" else: return "error" def pid_hold(self): self.is_held = True with session_scope(MYCODO_DB_PATH) as db_session: mod_pid = db_session.query(PID).filter( PID.unique_id == self.unique_id).first() mod_pid.is_held = True db_session.commit() self.logger.info("PID Held") return "success" def pid_pause(self): self.is_paused = True with session_scope(MYCODO_DB_PATH) as db_session: mod_pid = db_session.query(PID).filter( PID.unique_id == self.unique_id).first() mod_pid.is_paused = True db_session.commit() self.logger.info("PID Paused") return "success" def pid_resume(self): self.is_activated = True self.is_held = False self.is_paused = False with session_scope(MYCODO_DB_PATH) as db_session: mod_pid = db_session.query(PID).filter( PID.unique_id == self.unique_id).first() mod_pid.is_activated = True mod_pid.is_held = False mod_pid.is_paused = False db_session.commit() self.logger.info("PID Resumed") return "success" def set_setpoint(self, setpoint): """Set the setpoint of PID.""" self.PID_Controller.setpoint = float(setpoint) with session_scope(MYCODO_DB_PATH) as db_session: mod_pid = db_session.query(PID).filter( PID.unique_id == self.unique_id).first() mod_pid.setpoint = setpoint db_session.commit() return f"Setpoint set to {setpoint}" def set_method(self, method_id): """Set the method of PID.""" with session_scope(MYCODO_DB_PATH) as db_session: mod_pid = db_session.query(PID).filter( PID.unique_id == self.unique_id).first() mod_pid.setpoint_tracking_id = method_id if method_id == '': self.setpoint_tracking_id = '' db_session.commit() else: mod_pid.method_start_time = None mod_pid.method_end_time = None db_session.commit() self.setup_method(method_id) return f"Method set to {method_id}" def set_integrator(self, integrator): """Set the integrator of the controller.""" self.PID_Controller.integrator = float(integrator) return f"Integrator set to {self.PID_Controller.integrator}" def set_derivator(self, derivator): """Set the derivator of the controller.""" self.PID_Controller.derivator = float(derivator) return f"Derivator set to {self.PID_Controller.derivator}" def set_kp(self, p): """Set Kp gain of the controller.""" self.PID_Controller.Kp = float(p) with session_scope(MYCODO_DB_PATH) as db_session: mod_pid = db_session.query(PID).filter( PID.unique_id == self.unique_id).first() mod_pid.p = p db_session.commit() return f"Kp set to {self.PID_Controller.Kp}" def set_ki(self, i): """Set Ki gain of the controller.""" self.PID_Controller.Ki = float(i) with session_scope(MYCODO_DB_PATH) as db_session: mod_pid = db_session.query(PID).filter( PID.unique_id == self.unique_id).first() mod_pid.i = i db_session.commit() return f"Ki set to {self.PID_Controller.Ki}" def set_kd(self, d): """Set Kd gain of the controller.""" self.PID_Controller.Kd = float(d) with session_scope(MYCODO_DB_PATH) as db_session: mod_pid = db_session.query(PID).filter( PID.unique_id == self.unique_id).first() mod_pid.d = d db_session.commit() return f"Kd set to {self.PID_Controller.Kd}" def get_setpoint(self): return self.PID_Controller.setpoint def get_setpoint_band(self): return self.PID_Controller.setpoint_band def get_error(self): return self.PID_Controller.error def get_integrator(self): return self.PID_Controller.integrator def get_derivator(self): return self.PID_Controller.derivator def get_kp(self): return self.PID_Controller.Kp def get_ki(self): return self.PID_Controller.Ki def get_kd(self): return self.PID_Controller.Kd def function_status(self): total = self.PID_Controller.P_value + self.PID_Controller.I_value + self.PID_Controller.D_value return_dict = { 'string_status': "This info is being returned from the PID Controller." f"\nCurrent time: {datetime.datetime.now()}" f"\nControl Variable: {total:.4f} = " f"{self.PID_Controller.P_value:.4f} (P), " f"{self.PID_Controller.I_value:.4f} (I), " f"{self.PID_Controller.D_value:.4f} (D)", 'error': [] } return return_dict def stop_controller(self, ended_normally=True, deactivate_pid=False): self.thread_shutdown_timer = timeit.default_timer() self.running = False # Unset method start time if (self.setpoint_tracking_type == 'method' and self.setpoint_tracking_id != '' and ended_normally): with session_scope(MYCODO_DB_PATH) as db_session: mod_pid = db_session.query(PID).filter( PID.unique_id == self.unique_id).first() mod_pid.method_start_time = None mod_pid.method_end_time = None db_session.commit() # Deactivate PID and Autotune if deactivate_pid: with session_scope(MYCODO_DB_PATH) as db_session: mod_pid = db_session.query(PID).filter( PID.unique_id == self.unique_id).first() mod_pid.is_activated = False mod_pid.autotune_activated = False db_session.commit()
def camera_record(record_type, unique_id, duration_sec=None, tmp_filename=None): """ Record still image from cameras :param record_type: :param unique_id: :param duration_sec: :param tmp_filename: :return: """ daemon_control = None settings = db_retrieve_table_daemon(Camera, unique_id=unique_id) timestamp = datetime.datetime.now().strftime('%Y-%m-%d_%H-%M-%S') assure_path_exists(PATH_CAMERAS) camera_path = assure_path_exists( os.path.join(PATH_CAMERAS, '{uid}'.format(uid=settings.unique_id))) if record_type == 'photo': if settings.path_still != '': save_path = settings.path_still else: save_path = assure_path_exists(os.path.join(camera_path, 'still')) filename = 'Still-{cam_id}-{cam}-{ts}.jpg'.format( cam_id=settings.id, cam=settings.name, ts=timestamp).replace(" ", "_") elif record_type == 'timelapse': if settings.path_timelapse != '': save_path = settings.path_timelapse else: save_path = assure_path_exists( os.path.join(camera_path, 'timelapse')) start = datetime.datetime.fromtimestamp( settings.timelapse_start_time).strftime("%Y-%m-%d_%H-%M-%S") filename = 'Timelapse-{cam_id}-{cam}-{st}-img-{cn:05d}.jpg'.format( cam_id=settings.id, cam=settings.name, st=start, cn=settings.timelapse_capture_number).replace(" ", "_") elif record_type == 'video': if settings.path_video != '': save_path = settings.path_video else: save_path = assure_path_exists(os.path.join(camera_path, 'video')) filename = 'Video-{cam}-{ts}.h264'.format(cam=settings.name, ts=timestamp).replace( " ", "_") else: return assure_path_exists(save_path) if tmp_filename: filename = tmp_filename path_file = os.path.join(save_path, filename) # Turn on output, if configured output_already_on = False if settings.output_id: daemon_control = DaemonControl() if daemon_control.output_state(settings.output_id) == "on": output_already_on = True else: daemon_control.output_on(settings.output_id) # Pause while the output remains on for the specified duration. # Used for instance to allow fluorescent lights to fully turn on before # capturing an image. if settings.output_duration: time.sleep(settings.output_duration) if settings.library == 'picamera': import picamera # Try 5 times to access the pi camera (in case another process is accessing it) for _ in range(5): try: with picamera.PiCamera() as camera: camera.resolution = (settings.width, settings.height) camera.hflip = settings.hflip camera.vflip = settings.vflip camera.rotation = settings.rotation camera.brightness = int(settings.brightness) camera.contrast = int(settings.contrast) camera.exposure_compensation = int(settings.exposure) camera.saturation = int(settings.saturation) camera.shutter_speed = settings.picamera_shutter_speed camera.sharpness = settings.picamera_sharpness camera.iso = settings.picamera_iso camera.awb_mode = settings.picamera_awb if settings.picamera_awb == 'off': camera.awb_gains = (settings.picamera_awb_gain_red, settings.picamera_awb_gain_blue) camera.exposure_mode = settings.picamera_exposure_mode camera.meter_mode = settings.picamera_meter_mode camera.image_effect = settings.picamera_image_effect camera.start_preview() time.sleep(2) # Camera warm-up time if record_type in ['photo', 'timelapse']: camera.capture(path_file, use_video_port=False) elif record_type == 'video': camera.start_recording(path_file, format='h264', quality=20) camera.wait_recording(duration_sec) camera.stop_recording() else: return break except picamera.exc.PiCameraMMALError: logger.error( "The camera is already open by picamera. Retrying 4 times." ) time.sleep(1) elif settings.library == 'fswebcam': cmd = "/usr/bin/fswebcam --device {dev} --resolution {w}x{h} --set brightness={bt}% " \ "--no-banner --save {file}".format(dev=settings.device, w=settings.width, h=settings.height, bt=settings.brightness, file=path_file) if settings.hflip: cmd += " --flip h" if settings.vflip: cmd += " --flip h" if settings.rotation: cmd += " --rotate {angle}".format(angle=settings.rotation) if settings.custom_options: cmd += " {}".format(settings.custom_options) out, err, status = cmd_output(cmd, stdout_pipe=False, user='******') logger.debug("Camera debug message: " "cmd: {}; out: {}; error: {}; status: {}".format( cmd, out, err, status)) elif settings.library == 'opencv': import cv2 import imutils cap = cv2.VideoCapture(settings.opencv_device) cap.set(cv2.CAP_PROP_FRAME_WIDTH, settings.width) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, settings.height) cap.set(cv2.CAP_PROP_EXPOSURE, settings.exposure) cap.set(cv2.CAP_PROP_GAIN, settings.gain) cap.set(cv2.CAP_PROP_BRIGHTNESS, settings.brightness) cap.set(cv2.CAP_PROP_CONTRAST, settings.contrast) cap.set(cv2.CAP_PROP_HUE, settings.hue) cap.set(cv2.CAP_PROP_SATURATION, settings.saturation) # Check if image can be read if not cap.read(): logger.error("Cannot detect USB camera with device '{dev}'".format( dev=settings.opencv_device)) return # Discard a few frames to allow camera to adjust to settings for _ in range(2): cap.read() if record_type in ['photo', 'timelapse']: edited = False _, img_orig = cap.read() cap.release() img_edited = img_orig.copy() if any((settings.hflip, settings.vflip, settings.rotation)): edited = True if settings.hflip and settings.vflip: img_edited = cv2.flip(img_orig, -1) elif settings.hflip: img_edited = cv2.flip(img_orig, 1) elif settings.vflip: img_edited = cv2.flip(img_orig, 0) if settings.rotation: img_edited = imutils.rotate_bound(img_orig, settings.rotation) if edited: cv2.imwrite(path_file, img_edited) else: cv2.imwrite(path_file, img_orig) elif record_type == 'video': # TODO: opencv video recording is currently not working. No idea why. Try to fix later. try: cap = cv2.VideoCapture(settings.opencv_device) fourcc = cv2.CV_FOURCC('X', 'V', 'I', 'D') resolution = (settings.width, settings.height) out = cv2.VideoWriter(path_file, fourcc, 20.0, resolution) time_end = time.time() + duration_sec while cap.isOpened() and time.time() < time_end: ret, frame = cap.read() if ret: # write the frame out.write(frame) if cv2.waitKey(1) & 0xFF == ord('q'): break else: break cap.release() out.release() cv2.destroyAllWindows() except Exception as e: logger.exception("Exception raised while recording video: " "{err}".format(err=e)) else: return try: set_user_grp(path_file, 'mycodo', 'mycodo') except Exception as e: logger.exception( "Exception raised in 'camera_record' when setting user grp: " "{err}".format(err=e)) # Turn off output, if configured if settings.output_id and daemon_control: if not output_already_on: daemon_control.output_off(settings.output_id) try: set_user_grp(path_file, 'mycodo', 'mycodo') return save_path, filename except Exception as e: logger.exception( "Exception raised in 'camera_record' when setting user grp: " "{err}".format(err=e))
class CustomModule(AbstractController, threading.Thread): """ Class to operate custom controller """ def __init__(self, ready, unique_id, testing=False): threading.Thread.__init__(self) super(CustomModule, self).__init__(ready, unique_id=unique_id, name=__name__) self.unique_id = unique_id self.log_level_debug = None self.control = DaemonControl() # Initialize custom options self.start_offset = None self.period = None self.input_temperature_condenser_device_id = None self.input_temperature_condenser_measurement_id = None self.input_temperature_condenser_max_age = None self.input_temperature_room_device_id = None self.input_temperature_room_measurement_id = None self.input_temperature_room_max_age = None self.output_ac_id = None self.output_ac_sensor_heater_id = None self.setpoint_temperature = None # Set custom options custom_controller = db_retrieve_table_daemon(CustomController, unique_id=unique_id) self.setup_custom_options(CONTROLLER_INFORMATION['custom_options'], custom_controller) if not testing: pass # import controller-specific modules here def get_ac_condenser_temperature(self): """Get condenser temperature""" last_measurement = self.get_last_measurement( self.input_temperature_condenser_device_id, self.input_temperature_condenser_measurement_id, max_age=self.input_temperature_condenser_max_age) if last_measurement: self.logger.debug( "Most recent timestamp and measurement for " "input_temperature_condenser: {timestamp}, {meas}".format( timestamp=last_measurement[0], meas=last_measurement[1])) return last_measurement else: self.logger.debug( "Could not find a measurement in the database for " "input_temperature_condenser device ID {} and measurement " "ID {}".format( self.input_temperature_condenser_device_id, self.input_temperature_condenser_measurement_id)) def get_room_temperature(self): """Get condenser temperature""" last_measurement = self.get_last_measurement( self.input_temperature_room_device_id, self.input_temperature_room_measurement_id, max_age=self.input_temperature_room_max_age) if last_measurement: self.logger.debug( "Most recent timestamp and measurement for " "input_temperature_room: {timestamp}, {meas}".format( timestamp=last_measurement[0], meas=last_measurement[1])) return last_measurement else: self.logger.debug( "Could not find a measurement in the database for " "input_temperature_room device ID {} and measurement " "ID {}".format(self.input_temperature_room_device_id, self.input_temperature_room_measurement_id)) def run(self): try: self.logger.info("Activated in {:.1f} ms".format( (timeit.default_timer() - self.thread_startup_timer) * 1000)) self.ready.set() self.running = True start_offset_timer = time.time() + self.start_offset while self.running and time.time() < start_offset_timer: time.sleep(1) if not self.running: return # try to get measurement from sensor temperature_condenser = self.get_ac_condenser_temperature() temperature_room = self.get_room_temperature() if not temperature_condenser or not temperature_room: return # Turn Output output_ac on self.logger.debug("Turning output_ac with ID {} on".format( self.output_ac_id)) self.control.output_on(self.output_ac_id) # Start a loop while self.running: temperature_condenser = self.get_ac_condenser_temperature() temperature_room = self.get_room_temperature() if temperature_room > self.setpoint_temperature and temperature_condenser > 0: # Turn Output output_ac_sensor_heater on self.logger.debug( "Turning output_ac_sensor_heater with ID {} on".format( self.output_ac_sensor_heater_id)) self.control.output_on(self.output_ac_sensor_heater_id) else: # Turn Output output_ac_sensor_heater off self.logger.debug( "Turning output_ac_sensor_heater with ID {} off". format(self.output_ac_sensor_heater_id)) self.control.output_off(self.output_ac_sensor_heater_id) time.sleep(self.period) except: self.logger.exception("Run Error") finally: self.run_finally() self.running = False if self.thread_shutdown_timer: self.logger.info("Deactivated in {:.1f} ms".format( (timeit.default_timer() - self.thread_shutdown_timer) * 1000)) else: self.logger.error("Deactivated unexpectedly") def loop(self): pass def initialize_variables(self): controller = db_retrieve_table_daemon(CustomController, unique_id=self.unique_id) self.log_level_debug = controller.log_level_debug self.set_log_level_debug(self.log_level_debug) def run_finally(self): # Turn Output output_ac off self.logger.debug("Turning output_ac with ID {} off".format( self.output_ac_id)) self.control.output_off(self.output_ac_id) # Turn Output output_ac_sensor_heater off self.logger.debug( "Turning output_ac_sensor_heater with ID {} off".format( self.output_ac_sensor_heater_id)) self.control.output_off(self.output_ac_sensor_heater_id)
def camera_record(record_type, unique_id, duration_sec=None, tmp_filename=None): """ Record still image from cameras :param record_type: :param unique_id: :param duration_sec: :param tmp_filename: :return: """ daemon_control = None settings = db_retrieve_table_daemon(Camera, unique_id=unique_id) timestamp = datetime.datetime.now().strftime('%Y-%m-%d_%H-%M-%S') assure_path_exists(PATH_CAMERAS) camera_path = assure_path_exists( os.path.join(PATH_CAMERAS, '{uid}'.format(uid=settings.unique_id))) if record_type == 'photo': if settings.path_still: save_path = settings.path_still else: save_path = assure_path_exists(os.path.join(camera_path, 'still')) # TODO: next major version, remove cam id (unique_id is already in path) filename = 'Still-{cam_id}-{cam}-{ts}.jpg'.format( cam_id=settings.id, cam=settings.name, ts=timestamp).replace(" ", "_") elif record_type == 'timelapse': if settings.path_timelapse: save_path = settings.path_timelapse else: save_path = assure_path_exists(os.path.join(camera_path, 'timelapse')) start = datetime.datetime.fromtimestamp( settings.timelapse_start_time).strftime("%Y-%m-%d_%H-%M-%S") # TODO: next major version, remove cam id (unique_id is already in path) filename = 'Timelapse-{cam_id}-{cam}-{st}-img-{cn:05d}.jpg'.format( cam_id=settings.id, cam=settings.name, st=start, cn=settings.timelapse_capture_number).replace(" ", "_") elif record_type == 'video': if settings.path_video: save_path = settings.path_video else: save_path = assure_path_exists(os.path.join(camera_path, 'video')) filename = 'Video-{cam}-{ts}.h264'.format( cam=settings.name, ts=timestamp).replace(" ", "_") else: return assure_path_exists(save_path) if tmp_filename: filename = tmp_filename path_file = os.path.join(save_path, filename) # Turn on output, if configured output_already_on = False output_id = None output_channel_id = None output_channel = None if settings.output_id and ',' in settings.output_id: output_id = settings.output_id.split(",")[0] output_channel_id = settings.output_id.split(",")[1] output_channel = db_retrieve_table_daemon(OutputChannel, unique_id=output_channel_id) if output_id and output_channel: daemon_control = DaemonControl() if daemon_control.output_state(output_id, output_channel=output_channel.channel) == "on": output_already_on = True else: daemon_control.output_on(output_id, output_channel=output_channel.channel) # Pause while the output remains on for the specified duration. # Used for instance to allow fluorescent lights to fully turn on before # capturing an image. if settings.output_duration: time.sleep(settings.output_duration) if settings.library == 'picamera': try: import picamera # Try 5 times to access the pi camera (in case another process is accessing it) for _ in range(5): try: with picamera.PiCamera() as camera: camera.resolution = (settings.width, settings.height) camera.hflip = settings.hflip camera.vflip = settings.vflip camera.rotation = settings.rotation camera.brightness = int(settings.brightness) camera.contrast = int(settings.contrast) camera.exposure_compensation = int(settings.exposure) camera.saturation = int(settings.saturation) camera.shutter_speed = settings.picamera_shutter_speed camera.sharpness = settings.picamera_sharpness camera.iso = settings.picamera_iso camera.awb_mode = settings.picamera_awb if settings.picamera_awb == 'off': camera.awb_gains = (settings.picamera_awb_gain_red, settings.picamera_awb_gain_blue) camera.exposure_mode = settings.picamera_exposure_mode camera.meter_mode = settings.picamera_meter_mode camera.image_effect = settings.picamera_image_effect camera.start_preview() time.sleep(2) # Camera warm-up time if record_type in ['photo', 'timelapse']: camera.capture(path_file, use_video_port=False) elif record_type == 'video': camera.start_recording(path_file, format='h264', quality=20) camera.wait_recording(duration_sec) camera.stop_recording() else: return break except picamera.exc.PiCameraMMALError: logger.error("The camera is already open by picamera. Retrying 4 times.") time.sleep(1) except: logger.exception("picamera") elif settings.library == 'fswebcam': try: cmd = "/usr/bin/fswebcam --device {dev} --resolution {w}x{h} --set brightness={bt}% " \ "--no-banner --save {file}".format(dev=settings.device, w=settings.width, h=settings.height, bt=settings.brightness, file=path_file) if settings.hflip: cmd += " --flip h" if settings.vflip: cmd += " --flip h" if settings.rotation: cmd += " --rotat {angle}".format(angle=settings.rotation) if settings.custom_options: cmd += " {}".format(settings.custom_options) out, err, status = cmd_output(cmd, stdout_pipe=False, user='******') logger.debug( "Camera debug message: " "cmd: {}; out: {}; error: {}; status: {}".format( cmd, out, err, status)) except: logger.exception("fswebcam") elif settings.library == 'raspistill': try: cmd = "/usr/bin/raspistill -w {w} -h {h} --brightness {bt} " \ "-o {file}".format(w=settings.width, h=settings.height, bt=settings.brightness, file=path_file) if settings.contrast is not None: cmd += " --contrast {}".format(int(settings.contrast)) if settings.saturation is not None: cmd += " --saturation {}".format(int(settings.saturation)) if settings.picamera_sharpness is not None: cmd += " --sharpness {}".format(int(settings.picamera_sharpness)) if settings.picamera_iso not in [0, None]: cmd += " --ISO {}".format(int(settings.picamera_iso)) if settings.picamera_awb is not None: cmd += " --awb {}".format(settings.picamera_awb) if settings.hflip: cmd += " --hflip" if settings.vflip: cmd += " --vflip" if settings.rotation: cmd += " --rotation {angle}".format(angle=settings.rotation) if settings.custom_options: cmd += " {}".format(settings.custom_options) out, err, status = cmd_output(cmd, stdout_pipe=False, user='******') logger.debug( "Camera debug message: " "cmd: {}; out: {}; error: {}; status: {}".format( cmd, out, err, status)) except: logger.exception("raspistill") elif settings.library == 'opencv': try: import cv2 import imutils cap = cv2.VideoCapture(settings.opencv_device) cap.set(cv2.CAP_PROP_FRAME_WIDTH, settings.width) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, settings.height) cap.set(cv2.CAP_PROP_EXPOSURE, settings.exposure) cap.set(cv2.CAP_PROP_GAIN, settings.gain) cap.set(cv2.CAP_PROP_BRIGHTNESS, settings.brightness) cap.set(cv2.CAP_PROP_CONTRAST, settings.contrast) cap.set(cv2.CAP_PROP_HUE, settings.hue) cap.set(cv2.CAP_PROP_SATURATION, settings.saturation) # Check if image can be read status, _ = cap.read() if not status: logger.error( "Cannot detect USB camera with device '{dev}'".format( dev=settings.opencv_device)) return # Discard a few frames to allow camera to adjust to settings for _ in range(2): cap.read() if record_type in ['photo', 'timelapse']: edited = False status, img_orig = cap.read() cap.release() if not status: logger.error("Could not acquire image") return img_edited = img_orig.copy() if any((settings.hflip, settings.vflip, settings.rotation)): edited = True if settings.hflip and settings.vflip: img_edited = cv2.flip(img_orig, -1) elif settings.hflip: img_edited = cv2.flip(img_orig, 1) elif settings.vflip: img_edited = cv2.flip(img_orig, 0) if settings.rotation: img_edited = imutils.rotate_bound(img_orig, settings.rotation) if edited: cv2.imwrite(path_file, img_edited) else: cv2.imwrite(path_file, img_orig) elif record_type == 'video': # TODO: opencv video recording is currently not working. No idea why. Try to fix later. try: cap = cv2.VideoCapture(settings.opencv_device) fourcc = cv2.CV_FOURCC('X', 'V', 'I', 'D') resolution = (settings.width, settings.height) out = cv2.VideoWriter(path_file, fourcc, 20.0, resolution) time_end = time.time() + duration_sec while cap.isOpened() and time.time() < time_end: ret, frame = cap.read() if ret: # write the frame out.write(frame) if cv2.waitKey(1) & 0xFF == ord('q'): break else: break cap.release() out.release() cv2.destroyAllWindows() except Exception as e: logger.exception( "Exception raised while recording video: " "{err}".format(err=e)) else: return except: logger.exception("opencv") elif settings.library == 'http_address': try: import cv2 import imutils from urllib.error import HTTPError from urllib.parse import urlparse from urllib.request import urlretrieve if record_type in ['photo', 'timelapse']: path_tmp = "/tmp/tmpimg.jpg" # Get filename and extension, if available a = urlparse(settings.url_still) filename = os.path.basename(a.path) if filename: path_tmp = "/tmp/{}".format(filename) try: os.remove(path_tmp) except FileNotFoundError: pass try: urlretrieve(settings.url_still, path_tmp) except HTTPError as err: logger.error(err) except Exception as err: logger.exception(err) try: img_orig = cv2.imread(path_tmp) if img_orig is not None and img_orig.shape is not None: if any((settings.hflip, settings.vflip, settings.rotation)): img_edited = None if settings.hflip and settings.vflip: img_edited = cv2.flip(img_orig, -1) elif settings.hflip: img_edited = cv2.flip(img_orig, 1) elif settings.vflip: img_edited = cv2.flip(img_orig, 0) if settings.rotation: img_edited = imutils.rotate_bound(img_orig, settings.rotation) if img_edited: cv2.imwrite(path_file, img_edited) else: cv2.imwrite(path_file, img_orig) else: os.rename(path_tmp, path_file) except Exception as err: logger.error("Could not convert, rotate, or invert image: {}".format(err)) try: os.rename(path_tmp, path_file) except FileNotFoundError: logger.error("Camera image not found") elif record_type == 'video': pass # No video (yet) except: logger.exception("http:address") elif settings.library == 'http_address_requests': try: import cv2 import imutils import requests if record_type in ['photo', 'timelapse']: path_tmp = "/tmp/tmpimg.jpg" try: os.remove(path_tmp) except FileNotFoundError: pass try: r = requests.get(settings.url_still) if r.status_code == 200: open(path_tmp, 'wb').write(r.content) else: logger.error("Could not download image. Status code: {}".format(r.status_code)) except requests.HTTPError as err: logger.error("HTTPError: {}".format(err)) except Exception as err: logger.exception(err) try: img_orig = cv2.imread(path_tmp) if img_orig is not None and img_orig.shape is not None: if any((settings.hflip, settings.vflip, settings.rotation)): if settings.hflip and settings.vflip: img_edited = cv2.flip(img_orig, -1) elif settings.hflip: img_edited = cv2.flip(img_orig, 1) elif settings.vflip: img_edited = cv2.flip(img_orig, 0) if settings.rotation: img_edited = imutils.rotate_bound(img_orig, settings.rotation) cv2.imwrite(path_file, img_edited) else: cv2.imwrite(path_file, img_orig) else: os.rename(path_tmp, path_file) except Exception as err: logger.error("Could not convert, rotate, or invert image: {}".format(err)) try: os.rename(path_tmp, path_file) except FileNotFoundError: logger.error("Camera image not found") elif record_type == 'video': pass # No video (yet) except: logger.exception("http_address_requests") try: set_user_grp(path_file, 'mycodo', 'mycodo') except Exception as e: logger.exception( "Exception raised in 'camera_record' when setting user grp: " "{err}".format(err=e)) # Turn off output, if configured if output_id and output_channel and daemon_control and not output_already_on: daemon_control.output_off(output_id, output_channel=output_channel.channel) try: set_user_grp(path_file, 'mycodo', 'mycodo') return save_path, filename except Exception as e: logger.exception( "Exception raised in 'camera_record' when setting user grp: " "{err}".format(err=e))
class AM2315Sensor(AbstractInput): """ A sensor support class that measures the AM2315's humidity and temperature and calculates the dew point """ def __init__(self, input_dev, testing=False): super(AM2315Sensor, self).__init__() self.logger = logging.getLogger('mycodo.inputs.am2315') self._dew_point = None self._humidity = None self._temperature = None self.powered = False self.am = None if not testing: from mycodo.mycodo_client import DaemonControl self.logger = logging.getLogger( 'mycodo.inputs.am2315_{id}'.format(id=input_dev.id)) self.i2c_bus = input_dev.i2c_bus self.power_output_id = input_dev.power_output_id self.control = DaemonControl() self.start_sensor() self.am = AM2315(self.i2c_bus) def __repr__(self): """ Representation of object """ return "<{cls}(dewpoint={dpt})(humidity={hum})(temperature={temp})>".format( cls=type(self).__name__, dpt="{0:.2f}".format(self._dew_point), hum="{0:.2f}".format(self._humidity), temp="{0:.2f}".format(self._temperature)) def __str__(self): """ Return measurement information """ return "Dew Point: {dpt}, Humidity: {hum}, Temperature: {temp}".format( dpt="{0:.2f}".format(self._dew_point), hum="{0:.2f}".format(self._humidity), temp="{0:.2f}".format(self._temperature)) def __iter__(self): # must return an iterator """ AM2315Sensor iterates through live measurement readings """ return self def next(self): """ Get next measurement reading """ if self.read(): # raised an error raise StopIteration # required return dict(dewpoint=float('{0:.2f}'.format(self._dew_point)), humidity=float('{0:.2f}'.format(self._humidity)), temperature=float('{0:.2f}'.format(self._temperature))) @property def dew_point(self): """ AM2315 dew point in Celsius """ if self._dew_point is None: # update if needed self.read() return self._dew_point @property def humidity(self): """ AM2315 relative humidity in percent """ if self._humidity is None: # update if needed self.read() return self._humidity @property def temperature(self): """ AM2315 temperature in Celsius """ if self._temperature is None: # update if needed self.read() return self._temperature def get_measurement(self): """ Gets the humidity and temperature """ self._dew_point = None self._humidity = None self._temperature = None # Ensure if the power pin turns off, it is turned back on if (self.power_output_id and db_retrieve_table_daemon(Output, unique_id=self.power_output_id) and self.control.output_state(self.power_output_id) == 'off'): self.logger.error( 'Sensor power output {rel} detected as being off. ' 'Turning on.'.format(rel=self.power_output_id)) self.start_sensor() time.sleep(2) # Try twice to get measurement. This prevents an anomaly where # the first measurement fails if the sensor has just been powered # for the first time. for _ in range(2): dew_point, humidity, temperature = self.return_measurements() if dew_point is not None: return dew_point, humidity, temperature # success - no errors time.sleep(2) # Measurement failure, power cycle the sensor (if enabled) # Then try two more times to get a measurement if self.power_output_id: self.stop_sensor() time.sleep(2) self.start_sensor() for _ in range(2): dew_point, humidity, temperature = self.return_measurements() if dew_point is not None: return dew_point, humidity, temperature # success time.sleep(2) self.logger.debug("Could not acquire a measurement") return None, None, None def return_measurements(self): # Retry measurement if CRC fails for num_measure in range(3): humidity, temperature = self.am.data() if humidity is None: self.logger.debug( "Measurement {num} returned failed CRC".format( num=num_measure)) pass else: dew_pt = dewpoint(temperature, humidity) return dew_pt, humidity, temperature time.sleep(2) self.logger.error("All measurements returned failed CRC") return None, None, None def read(self): """ Takes a reading from the AM2315 and updates the self.dew_point, self._humidity, and self._temperature values :returns: None on success or 1 on error """ try: (self._dew_point, self._humidity, self._temperature) = self.get_measurement() if self._dew_point is not None: return # success - no errors except Exception as e: self.logger.exception( "{cls} raised an exception when taking a reading: " "{err}".format(cls=type(self).__name__, err=e)) return 1 def start_sensor(self): """ Turn the sensor on """ if self.power_output_id: self.logger.info("Turning on sensor") self.control.output_on(self.power_output_id, 0) time.sleep(2) self.powered = True def stop_sensor(self): """ Turn the sensor off """ if self.power_output_id: self.logger.info("Turning off sensor") self.control.output_off(self.power_output_id) self.powered = False