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_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_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 = None # 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.PID_Controller.direction in ['raise', 'both']: self.control.output_off( self.raise_output_id, trigger_conditionals=True) if self.lower_output_id and self.PID_Controller.direction in ['lower', 'both']: self.control.output_off( self.lower_output_id, trigger_conditionals=True) def initialize_variables(self): """Set PID parameters""" self.set_log_level_debug(self.log_level_debug) 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.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.raise_output_id = pid.raise_output_id 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 self.lower_output_id = pid.lower_output_id 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 # Autotune options self.autotune_activated = pid.autotune_activated self.autotune_noiseband = pid.autotune_noiseband self.autotune_outstep = pid.autotune_outstep # If activated, initialize PID Autotune if self.autotune_activated: self.autotune_timestamp = time.time() try: self.autotune = PIDAutotune( self.PID_Controller.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_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 last_measurement = read_last_influxdb( device_id, measurement.unit, measurement.channel, measure=measurement.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 # If autotune activated, determine control variable (output) from autotune if self.autotune_activated: if not self.autotune.run(self.last_measurement): self.PID_Controller.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 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} to {dc:.1f}%".format( sp=self.PID_Controller.setpoint, cv=self.PID_Controller.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, output_type='pwm', duty_cycle=self.raise_duty_cycle) 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} to output " "{id}".format( sp=self.PID_Controller.setpoint, cv=self.PID_Controller.control_variable, id=self.raise_output_id)) self.control.output_on( self.raise_output_id, output_type='sec', amount=self.raise_seconds_on, min_off=self.raise_min_off_duration) self.write_pid_output_influxdb( 's', 'duration_time', 6, 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}".format( sp=self.PID_Controller.setpoint, cv=self.PID_Controller.control_variable, id=self.raise_output_id)) self.control.output_on( self.raise_output_id, output_type='vol', amount=self.PID_Controller.control_variable, min_off=self.raise_min_off_duration) 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, duty_cycle=0) # # 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} to {dc:.1f}%".format( sp=self.PID_Controller.setpoint, cv=self.PID_Controller.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.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', duty_cycle=stored_duty_cycle) 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} to " "output {id}".format( sp=self.PID_Controller.setpoint, cv=self.PID_Controller.control_variable, id=self.lower_output_id)) self.control.output_on( self.lower_output_id, output_type='sec', 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 == '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} to output {id}".format( sp=self.PID_Controller.setpoint, cv=self.PID_Controller.control_variable, id=self.lower_output_id)) self.control.output_on( self.lower_output_id, output_type='vol', amount=stored_amount_on, min_off=self.lower_min_off_duration) 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, duty_cycle=0) 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) if self.PID_Controller.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 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 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, 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, 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) 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()
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("PID Settings: {}".format(self.pid_parameters_str())) return "success"
def initialize_variables(self): """Set PID parameters""" self.set_log_level_debug(self.log_level_debug) 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.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.raise_output_id = pid.raise_output_id 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 self.lower_output_id = pid.lower_output_id 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 # Autotune options self.autotune_activated = pid.autotune_activated self.autotune_noiseband = pid.autotune_noiseband self.autotune_outstep = pid.autotune_outstep # If activated, initialize PID Autotune if self.autotune_activated: self.autotune_timestamp = time.time() try: self.autotune = PIDAutotune( self.PID_Controller.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"
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()