예제 #1
0
class PIDController(threading.Thread):
    """
    Class to operate discrete PID controller

    """

    def __init__(self, ready, logger, pid_id):
        threading.Thread.__init__(self)

        self.thread_startup_timer = timeit.default_timer()
        self.thread_shutdown_timer = 0
        self.ready = ready
        self.logger = logger
        self.pid_id = pid_id
        self.control = DaemonControl()

        with session_scope(MYCODO_DB_PATH) as new_session:
            pid = new_session.query(PID).filter(PID.id == self.pid_id).first()
            self.sensor_id = pid.sensor_id
            self.measure_type = pid.measure_type
            self.method_id = pid.method_id
            self.direction = pid.direction
            self.raise_relay_id = pid.raise_relay_id
            self.raise_min_duration = pid.raise_min_duration
            self.raise_max_duration = pid.raise_max_duration
            self.lower_relay_id = pid.lower_relay_id
            self.lower_min_duration = pid.lower_min_duration
            self.lower_max_duration = pid.lower_max_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.measure_interval = pid.period
            self.default_set_point = pid.setpoint
            self.set_point = pid.setpoint
            sensor = new_session.query(Sensor).filter(Sensor.id == self.sensor_id).first()
            self.sensor_duration = sensor.period

        self.Derivator = 0
        self.Integrator = 0
        self.error = 0.0
        self.P_value = None
        self.I_value = None
        self.D_value = None
        self.raise_seconds_on = 0
        self.timer = t.time()+self.measure_interval

        # Check if a method is set for this PID
        if self.method_id:
            with session_scope(MYCODO_DB_PATH) as new_session:
                method = new_session.query(Method)
                method = method.filter(Method.method_id == self.method_id)
                method = method.filter(Method.method_order == 0).first()
                self.method_type = method.method_type
                self.method_start_time = method.start_time

            if self.method_type == 'Duration':
                if self.method_start_time == 'Ended':
                    # Method has ended and hasn't been instructed to begin again
                    pass
                elif self.method_start_time == 'Ready' or self.method_start_time == None:
                    # Method has been instructed to begin
                    with session_scope(MYCODO_DB_PATH) as db_session:
                        mod_method = db_session.query(Method)
                        mod_method = mod_method.filter(Method.method_id == self.method_id)
                        mod_method = mod_method.filter(Method.method_order == 0).first()
                        mod_method.start_time = datetime.datetime.now()
                        self.method_start_time = mod_method.start_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(
                        self.method_start_time, '%Y-%m-%d %H:%M:%S.%f')
                    self.logger.warning("[PID {}] Resuming method {} started at {}".format(
                        self.pid_id, self.method_id, self.method_start_time))


    def run(self):
        try:
            self.running = True
            self.logger.info("[PID {}] Activated in {:.1f} ms".format(
                self.pid_id,
                (timeit.default_timer()-self.thread_startup_timer)*1000))
            self.ready.set()

            while (self.running):
                if t.time() > self.timer:
                    self.timer = self.timer+self.measure_interval
                    self.get_last_measurement()
                    self.manipulate_relays()
                t.sleep(0.1)

            if self.raise_relay_id:
                self.control.relay_off(self.raise_relay_id)
            if self.lower_relay_id:
                self.control.relay_off(self.lower_relay_id)

            self.running = False
            self.logger.info("[PID {}] Deactivated in {:.1f} ms".format(
                self.pid_id,
                (timeit.default_timer()-self.thread_shutdown_timer)*1000))
        except Exception as except_msg:
                self.logger.exception("[PID {}] Run Error: Up ID {}, Down ID "
                                      "{}: {}".format(self.pid_id,
                                                      self.raise_relay_id,
                                                      self.lower_relay_id,
                                                      except_msg))


    def update(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 sensor)
        :type current_value: float
        """
        self.error = self.set_point - 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.measure_interval is not None:  
        #     if self.Integrator * self.Ki > self.measure_interval:
        #         self.Integrator = self.measure_interval / self.Ki
        #     elif self.Integrator * self.Ki < -self.measure_interval:
        #         self.Integrator = -self.measure_interval / self.Ki

        self.I_value = self.Integrator * self.Ki

        # 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 = self.P_value + self.I_value + self.D_value

        return PID


    def get_last_measurement(self):
        """
        Retrieve the latest sensor measurement from InfluxDB

        :rtype: None
        """
        self.last_measurement_success = False
        # Get latest measurement (from within the past minute) from influxdb
        try:
            if self.sensor_duration < 60:
                duration = 60
            else:
                duration = int(self.sensor_duration*1.5)
            self.last_measurement = read_last_influxdb(
                INFLUXDB_HOST,
                INFLUXDB_PORT,
                INFLUXDB_USER,
                INFLUXDB_PASSWORD,
                INFLUXDB_DATABASE,
                self.sensor_id,
                self.measure_type,
                duration)
            if self.last_measurement:
                measurement_list = list(self.last_measurement.get_points(
                    measurement=self.measure_type))
                self.last_time = measurement_list[0]['time']
                self.last_measurement = measurement_list[0]['value']
                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("[PID {}] Latest {}: {} @ {}".format(
                    self.pid_id, self.measure_type,
                    self.last_measurement, local_timestamp))
                self.last_measurement_success = True
            else:
                self.logger.warning("[PID {}] No data returned "
                                    "from influxdb".format(self.pid_id))
        except Exception as except_msg:
            self.logger.exception("[PID {}] Failed to read "
                                "measurement from the influxdb "
                                "database: {}".format(self.pid_id,
                                                      except_msg))


    def manipulate_relays(self):
        """
        Activate a relay based on PID output (control variable) and whether
        the manipulation directive is to raise, lower, or both.

        :rtype: None
        """
        # If there was a measurement able to be retrieved from
        # influxdb database that was entered within the past minute
        if self.last_measurement_success:

            # Update setpoint if a method is selected
            if self.method_id != '':
                self.calculate_method_setpoint(self.method_id)

            self.addSetpointInfluxdb(self.pid_id, self.set_point)

            # Update PID and get control variable
            self.control_variable = self.update(self.last_measurement)

            #
            # PID control variable positive to raise environmental condition
            #
            if self.direction in ['raise', 'both'] and self.raise_relay_id:
                if self.control_variable > 0:
                    # Ensure the relay 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))

                    # Turn off lower_relay if active, because we're now raising
                    if self.lower_relay_id:
                        with session_scope(MYCODO_DB_PATH) as new_session:
                            relay = new_session.query(Relay).filter(
                                Relay.id == self.lower_relay_id).first()
                            if relay.is_on():
                                self.control.relay_off(self.lower_relay_id)

                    if self.raise_seconds_on > self.raise_min_duration:
                        # Activate raise_relay for a duration
                        self.logger.debug("[PID {}] Setpoint: {} "
                            "Output: {} to relay {}".format(
                                self.pid_id,
                                self.set_point,
                                self.control_variable,
                                self.raise_relay_id))
                        self.control.relay_on(self.raise_relay_id,
                                         self.raise_seconds_on)
                else:
                    self.control.relay_off(self.raise_relay_id)

            #
            # PID control variable negative to lower environmental condition
            #
            if self.direction in ['lower', 'both'] and self.lower_relay_id:
                if self.control_variable < 0:
                    # Ensure the relay 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 = abs(float("{0:.2f}".format(self.control_variable)))

                    # Turn off raise_relay if active, because we're now lowering
                    if self.raise_relay_id:
                        with session_scope(MYCODO_DB_PATH) as new_session:
                            relay = new_session.query(Relay).filter(
                                Relay.id == self.raise_relay_id).first()
                            if relay.is_on():
                                self.control.relay_off(self.raise_relay_id)

                    if self.lower_seconds_on > self.lower_min_duration:
                        # Activate lower_relay for a duration
                        self.logger.debug("[PID {}] Setpoint: {} "
                            "Output: {} to relay {}".format(
                                self.pid_id,
                                self.set_point,
                                self.control_variable,
                                self.lower_relay_id))
                        self.control.relay_on(self.lower_relay_id,
                                         self.lower_seconds_on)
                else:
                    self.control.relay_off(self.lower_relay_id)

        else:
            if self.direction in ['raise', 'both'] and self.raise_relay_id:
                self.control.relay_off(self.raise_relay_id)
            if self.direction in ['lower', 'both'] and self.lower_relay_id:
                self.control.relay_off(self.lower_relay_id)


    def calculate_method_setpoint(self, method_id):
        with session_scope(MYCODO_DB_PATH) as new_session:
            method = new_session.query(Method)
            new_session.expunge_all()
            new_session.close()

        method_key = method.filter(Method.method_id == method_id)
        method_key = method_key.filter(Method.method_order == 0).first()

        method = method.filter(Method.method_id == method_id)
        method = method.filter(Method.relay_id == None)
        method = method.filter(Method.method_order > 0)
        method = method.order_by(Method.method_order.asc()).all()

        now = datetime.datetime.now()

        # Calculate where the current time/date is within the time/date method
        if method_key.method_type == 'Date':
            for each_method in method:
                start_time = datetime.datetime.strptime(each_method.start_time, '%Y-%m-%d %H:%M:%S')
                end_time = end_time = datetime.datetime.strptime(each_method.end_time, '%Y-%m-%d %H:%M:%S')
                if start_time < now < end_time:
                    start_setpoint = each_method.start_setpoint
                    if each_method.end_setpoint:
                        end_setpoint = each_method.end_setpoint
                    else:
                        end_setpoint = each_method.start_setpoint

                    setpoint_diff = abs(end_setpoint-start_setpoint)
                    total_seconds = (end_time-start_time).total_seconds()
                    part_seconds = (now-start_time).total_seconds()
                    percent_total = part_seconds/total_seconds

                    if start_setpoint < end_setpoint:
                        new_setpoint = start_setpoint+(setpoint_diff*percent_total)
                    else:
                        new_setpoint = start_setpoint-(setpoint_diff*percent_total)

                    self.logger.debug("[Method] Start: {} End: {}".format(
                        start_time, end_time))
                    self.logger.debug("[Method] Start: {} End: {}".format(
                        start_setpoint, end_setpoint))
                    self.logger.debug("[Method] Total: {} Part total: {} ({}%)".format(
                        total_seconds, part_seconds, percent_total))
                    self.logger.debug("[Method] New Setpoint: {}".format(
                        new_setpoint))
                    self.set_point = new_setpoint
                    return 0

        # Calculate where the current Hour:Minute:Seconds is within the Daily method
        elif method_key.method_type == 'Daily':
            daily_now = datetime.datetime.now().strftime('%H:%M:%S')
            daily_now = datetime.datetime.strptime(str(daily_now), '%H:%M:%S')
            for each_method in method:
                start_time = datetime.datetime.strptime(each_method.start_time, '%H:%M:%S')
                end_time = end_time = datetime.datetime.strptime(each_method.end_time, '%H:%M:%S')
                if start_time < daily_now < end_time:
                    start_setpoint = each_method.start_setpoint
                    if each_method.end_setpoint:
                        end_setpoint = each_method.end_setpoint
                    else:
                        end_setpoint = each_method.start_setpoint

                    setpoint_diff = abs(end_setpoint-start_setpoint)
                    total_seconds = (end_time-start_time).total_seconds()
                    part_seconds = (daily_now-start_time).total_seconds()
                    percent_total = part_seconds/total_seconds

                    if start_setpoint < end_setpoint:
                        new_setpoint = start_setpoint+(setpoint_diff*percent_total)
                    else:
                        new_setpoint = start_setpoint-(setpoint_diff*percent_total)

                    self.logger.debug("[Method] Start: {} End: {}".format(
                        start_time.strftime('%H:%M:%S'), end_time.strftime('%H:%M:%S')))
                    self.logger.debug("[Method] Start: {} End: {}".format(
                        start_setpoint, end_setpoint))
                    self.logger.debug("[Method] Total: {} Part total: {} ({}%)".format(
                        total_seconds, part_seconds, percent_total))
                    self.logger.debug("[Method] New Setpoint: {}".format(
                        new_setpoint))
                    self.set_point = new_setpoint
                    return 0

        # Calculate sine y-axis value from the x-axis (seconds of the day)
        elif method_key.method_type == 'DailySine':
            new_setpoint = sine_wave_y_out(method_key.amplitude,
                                           method_key.frequency,
                                           method_key.shift_angle,
                                           method_key.shift_y)
            self.set_point = new_setpoint
            return 0

        # Calculate Bezier curve y-axis value from the x-axis (seconds of the day)
        elif method_key.method_type == 'DailyBezier':
            new_setpoint = bezier_curve_y_out(method_key.shift_angle,
                                              (method_key.x0, method_key.y0),
                                              (method_key.x1, method_key.y1),
                                              (method_key.x2, method_key.y2),
                                              (method_key.x3, method_key.y3))
            self.set_point = new_setpoint
            return 0

        # Calculate the duration in the method based on self.method_start_time
        elif method_key.method_type == 'Duration' and self.method_start_time != 'Ended':
            seconds_from_start = (now-self.method_start_time).total_seconds()
            total_sec = 0
            previous_total_sec = 0
            for each_method in method:
                total_sec += each_method.duration_sec
                if previous_total_sec <= seconds_from_start < total_sec:
                    row_start_time = float(self.method_start_time.strftime('%s'))+previous_total_sec
                    row_since_start_sec = (now-(self.method_start_time+datetime.timedelta(0, previous_total_sec))).total_seconds()
                    percent_row = row_since_start_sec/each_method.duration_sec

                    start_setpoint = each_method.start_setpoint
                    if each_method.end_setpoint:
                        end_setpoint = each_method.end_setpoint
                    else:
                        end_setpoint = each_method.start_setpoint
                    setpoint_diff = abs(end_setpoint-start_setpoint)
                    if start_setpoint < end_setpoint:
                        new_setpoint = start_setpoint+(setpoint_diff*percent_row)
                    else:
                        new_setpoint = start_setpoint-(setpoint_diff*percent_row)
                    
                    self.logger.debug("[Method] Start: {} Seconds Since: {}".format(
                        self.method_start_time, seconds_from_start))
                    self.logger.debug("[Method] Start time of row: {}".format(
                        datetime.datetime.fromtimestamp(row_start_time)))
                    self.logger.debug("[Method] Sec since start of row: {}".format(
                        row_since_start_sec))
                    self.logger.debug("[Method] Percent of row: {}".format(
                        percent_row))
                    self.logger.debug("[Method] New Setpoint: {}".format(
                        new_setpoint))
                    self.set_point = new_setpoint
                    return 0
                previous_total_sec = total_sec

            # Duration method has ended, reset start_time locally and in DB
            if self.method_start_time:
                with session_scope(MYCODO_DB_PATH) as db_session:
                    mod_method = db_session.query(Method).filter(
                        Method.method_id == self.method_id)
                    mod_method = mod_method.filter(Method.method_order == 0).first()
                    mod_method.start_time = 'Ended'
                    db_session.commit()
                self.method_start_time = 'Ended'

        # Setpoint not needing to be calculated, use default setpoint
        self.set_point = self.default_set_point


    def addSetpointInfluxdb(self, pid_id, setpoint):
        """
        Add a setpoint entry to InfluxDB

        :rtype: None
        """
        write_db = threading.Thread(
            target=write_influxdb_value,
            args=(self.logger, INFLUXDB_HOST,
                  INFLUXDB_PORT, INFLUXDB_USER,
                  INFLUXDB_PASSWORD, INFLUXDB_DATABASE,
                  'pid', pid_id, 'setpoint', setpoint,))
        write_db.start()


    def setPoint(self, set_point):
        """Initilize the setpoint of PID"""
        self.set_point = set_point
        self.Integrator = 0
        self.Derivator = 0


    def setIntegrator(self, Integrator):
        """Set the Integrator of the controller"""
        self.Integrator = Integrator


    def setDerivator(self, Derivator):
        """Set the Derivator of the controller"""
        self.Derivator = Derivator


    def setKp(self, P):
        """Set Kp gain of the controller"""
        self.Kp = P


    def setKi(self, I):
        """Set Ki gain of the controller"""
        self.Ki = I


    def setKd(self, D):
        """Set Kd gain of the controller"""
        self.Kd = D


    def getPoint(self):
        return self.set_point


    def getError(self):
        return self.error


    def getIntegrator(self):
        return self.Integrator


    def getDerivator(self):
        return self.Derivator


    def isRunning(self):
        return self.running


    def stopController(self):
        self.thread_shutdown_timer = timeit.default_timer()
        self.running = False
        # Unset method start time
        if self.method_id:
            with session_scope(MYCODO_DB_PATH) as db_session:
                mod_method = db_session.query(Method)
                mod_method = mod_method.filter(Method.method_id == self.method_id)
                mod_method = mod_method.filter(Method.method_order == 0).first()
                mod_method.start_time = 'Ended'
                db_session.commit()
예제 #2
0
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))

        self.running = False
        self.thread_startup_timer = timeit.default_timer()
        self.thread_shutdown_timer = 0
        self.ready = ready
        self.pid_id = pid_id
        self.pid_unique_id = db_retrieve_table_daemon(
            PID, device_id=self.pid_id).unique_id
        self.control = DaemonControl()

        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.set_point = 0.0
        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_set_point = None
        self.set_point = None

        self.input_unique_id = None
        self.input_duration = None

        self.raise_output_type = None
        self.lower_output_type = None

        self.initialize_values()

        self.timer = t.time() + self.period

        # Check if a method is set for this PID
        self.method_start_act = None
        if self.method_id:
            method = db_retrieve_table_daemon(Method, device_id=self.method_id)
            method_data = db_retrieve_table_daemon(MethodData)
            method_data = method_data.filter(
                MethodData.method_id == self.method_id)
            method_data_repeat = method_data.filter(
                MethodData.duration_sec == 0).first()
            pid = db_retrieve_table_daemon(PID, device_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)
                        mod_pid = mod_pid.filter(PID.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=self.method_id,
                                    start=self.method_start_time,
                                    end=self.method_end_time))
                        else:
                            self.method_start_act = 'Ended'
                    else:
                        self.method_start_act = 'Ended'

    def run(self):
        try:
            self.running = True
            self.logger.info("Activated in {:.1f} ms".format(
                (timeit.default_timer() - self.thread_startup_timer) * 1000))
            if self.is_paused:
                self.logger.info("Paused")
            elif self.is_held:
                self.logger.info("Held")
            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 t.time() > self.timer:
                    # Ensure the timer ends in the future
                    while t.time() > self.timer:
                        self.timer = self.timer + self.period

                    # If PID is active, retrieve input measurement and update PID output
                    if self.is_activated and not self.is_paused:
                        self.get_last_measurement()

                        if self.last_measurement_success:
                            # Update setpoint using a method if one is selected
                            if self.method_id:
                                this_controller = db_retrieve_table_daemon(
                                    PID, device_id=self.pid_id)
                                setpoint, ended = calculate_method_setpoint(
                                    self.method_id, PID, this_controller,
                                    Method, MethodData, self.logger)
                                if ended:
                                    self.method_start_act = 'Ended'
                                if setpoint is not None:
                                    self.set_point = setpoint
                                else:
                                    self.set_point = self.default_set_point

                            write_setpoint_db = threading.Thread(
                                target=write_influxdb_value,
                                args=(
                                    self.pid_unique_id,
                                    'setpoint',
                                    self.set_point,
                                ))
                            write_setpoint_db.start()

                            # Update PID and get control variable
                            self.control_variable = self.update_pid_output(
                                self.last_measurement)

                    # If PID is active or on hold, activate outputs
                    if ((self.is_activated and not self.is_paused)
                            or (self.is_activated and self.is_held)):
                        self.manipulate_output()
                t.sleep(0.1)

            # Turn off output used in PID when the controller is deactivated
            if self.raise_output_id and self.direction in ['raise', 'both']:
                self.control.relay_off(self.raise_output_id,
                                       trigger_conditionals=True)
            if self.lower_output_id and self.direction in ['lower', 'both']:
                self.control.relay_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))
        except Exception as except_msg:
            self.logger.exception("Run Error: {err}".format(err=except_msg))

    def initialize_values(self):
        """Set PID parameters"""
        pid = db_retrieve_table_daemon(PID, device_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_relay_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_relay_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_set_point = pid.setpoint
        self.set_point = pid.setpoint

        input_unique_id = pid.measurement.split(',')[0]
        self.measurement = pid.measurement.split(',')[1]

        input_dev = db_retrieve_table_daemon(Input, unique_id=input_unique_id)
        self.input_unique_id = input_dev.unique_id
        self.input_duration = input_dev.period

        try:
            self.raise_output_type = db_retrieve_table_daemon(
                Output, device_id=self.raise_output_id).relay_type
        except AttributeError:
            self.raise_output_type = None
        try:
            self.lower_output_type = db_retrieve_table_daemon(
                Output, device_id=self.lower_output_id).relay_type
        except AttributeError:
            self.lower_output_type = None

        return "success"

    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
        """
        self.error = self.set_point - 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

        # 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 get_last_measurement(self):
        """
        Retrieve the latest input measurement from InfluxDB

        :rtype: None
        """
        self.last_measurement_success = False
        # Get latest measurement (from within the past minute) from influxdb
        try:
            if self.input_duration < 60:
                duration = 60
            else:
                duration = int(self.input_duration * 1.5)
            self.last_measurement = read_last_influxdb(self.input_unique_id,
                                                       self.measurement,
                                                       duration)
            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(
                        t.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(t.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:
                    # Turn off lower_output if active, because we're now raising
                    if (self.direction == 'both' and self.lower_output_id
                            and self.control.relay_state(
                                self.lower_output_id) != 'off'):
                        self.control.relay_off(self.lower_output_id)

                    # 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.set_point,
                                cv=self.control_variable,
                                id=self.raise_output_id,
                                dc=self.raise_duty_cycle))

                        # Activate pwm with calculated duty cycle
                        self.control.relay_on(self.raise_output_id,
                                              duty_cycle=self.raise_duty_cycle)

                        pid_entry_value = self.control_var_to_duty_cycle(
                            abs(self.control_variable))
                        if self.control_variable < 0:
                            pid_entry_value = -pid_entry_value
                        self.write_pid_output_influxdb('duty_cycle',
                                                       pid_entry_value)

                    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.set_point,
                                              cv=self.control_variable,
                                              id=self.raise_output_id))
                            self.control.relay_on(
                                self.raise_output_id,
                                duration=self.raise_seconds_on,
                                min_off=self.raise_min_off_duration)

                        self.write_pid_output_influxdb('pid_output',
                                                       self.control_variable)

                else:
                    if self.raise_output_type == 'pwm':
                        self.control.relay_on(self.raise_output_id,
                                              duty_cycle=0)
                    else:
                        self.control.relay_off(self.raise_output_id)

            #
            # 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:
                    # Turn off raise_output if active, because we're now raising
                    if (self.direction == 'both' and self.raise_output_id
                            and self.control.relay_state(
                                self.raise_output_id) != 'off'):
                        self.control.relay_off(self.raise_output_id)

                    # 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.set_point,
                                cv=self.control_variable,
                                id=self.lower_output_id,
                                dc=self.lower_duty_cycle))

                        # Turn back negative for proper logging
                        self.lower_duty_cycle = -self.lower_duty_cycle

                        # Activate pwm with calculated duty cycle
                        self.control.relay_on(self.lower_output_id,
                                              duty_cycle=self.lower_duty_cycle)

                        pid_entry_value = self.control_var_to_duty_cycle(
                            abs(self.control_variable))
                        pid_entry_value = -pid_entry_value
                        self.write_pid_output_influxdb('duty_cycle',
                                                       pid_entry_value)

                    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(
                                self.control_variable))

                        if abs(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.set_point,
                                                  cv=self.control_variable,
                                                  id=self.lower_output_id))
                            self.control.relay_on(
                                self.lower_output_id,
                                duration=self.lower_seconds_on,
                                min_off=self.lower_min_off_duration)

                        self.write_pid_output_influxdb('pid_output',
                                                       self.control_variable)

                else:
                    if self.lower_output_type == 'pwm':
                        self.control.relay_on(self.lower_output_id,
                                              duty_cycle=0)
                    else:
                        self.control.relay_off(self.lower_output_id)

        else:
            if self.direction in ['raise', 'both'] and self.raise_output_id:
                self.control.relay_off(self.raise_output_id)
            if self.direction in ['lower', 'both'] and self.lower_output_id:
                self.control.relay_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_unique_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, set_point):
        """ Initilize the setpoint of PID """
        self.set_point = set_point
        self.integrator = 0
        self.derivator = 0

    def set_integrator(self, integrator):
        """ Set the integrator of the controller """
        self.integrator = integrator

    def set_derivator(self, derivator):
        """ Set the derivator of the controller """
        self.derivator = derivator

    def set_kp(self, p):
        """ Set Kp gain of the controller """
        self.Kp = p

    def set_ki(self, i):
        """ Set Ki gain of the controller """
        self.Ki = i

    def set_kd(self, d):
        """ Set Kd gain of the controller """
        self.Kd = d

    def get_setpoint(self):
        return self.set_point

    def get_error(self):
        return self.error

    def get_integrator(self):
        return self.integrator

    def get_derivator(self):
        return self.derivator

    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.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.id == self.pid_id).first()
                mod_pid.is_activated = False
                db_session.commit()
예제 #3
0
class PIDController(threading.Thread):
    """
    Class to operate discrete PID controller

    """

    def __init__(self, ready, logger, pid_id):
        threading.Thread.__init__(self)

        self.thread_startup_timer = timeit.default_timer()
        self.thread_shutdown_timer = 0
        self.ready = ready
        self.logger = logger
        self.pid_id = pid_id
        self.control = DaemonControl()

        with session_scope(MYCODO_DB_PATH) as new_session:
            pid = new_session.query(PID).filter(PID.id == self.pid_id).first()
            self.sensor_id = pid.sensor_id
            self.measure_type = pid.measure_type
            self.direction = pid.direction
            self.raise_relay_id = pid.raise_relay_id
            self.raise_min_duration = pid.raise_min_duration
            self.raise_max_duration = pid.raise_max_duration
            self.lower_relay_id = pid.lower_relay_id
            self.lower_min_duration = pid.lower_min_duration
            self.lower_max_duration = pid.lower_max_duration
            self.Kp = pid.p
            self.Ki = pid.i
            self.Kd = pid.d
            self.measure_interval = pid.period
            self.default_set_point = pid.setpoint
            self.set_point = pid.setpoint

        with session_scope(MYCODO_DB_PATH) as new_session:
            self.pidsetpoints = new_session.query(PIDSetpoints)
            self.pidsetpoints = self.pidsetpoints.filter(PIDSetpoints.pid_id == self.pid_id)
            self.pidsetpoints = self.pidsetpoints.order_by(PIDSetpoints.start_time.asc())
            new_session.expunge_all()
            new_session.close()

        self.Derivator = 0
        self.Integrator = 0
        self.Integrator_max = 500
        self.Integrator_min = -500
        self.error = 0.0
        self.P_value = None
        self.I_value = None
        self.D_value = None
        self.raise_seconds_on = 0
        self.timer = t.time() + self.measure_interval


    def run(self):
        try:
            self.running = True
            self.logger.info("[PID {}] Activated in {}ms".format(
                self.pid_id,
                (timeit.default_timer()-self.thread_startup_timer)*1000))
            self.ready.set()

            while (self.running):
                if t.time() > self.timer:
                    self.timer = t.time() + self.measure_interval
                    self.get_last_measurement()
                    self.manipulate_relays()
                t.sleep(0.1)

            if self.raise_relay_id:
                self.control.relay_off(self.raise_relay_id)
            if self.lower_relay_id:
                self.control.relay_off(self.lower_relay_id)

            self.running = False
            self.logger.info("[PID {}] Deactivated in {}ms".format(
                self.pid_id,
                (timeit.default_timer()-self.thread_shutdown_timer)*1000))
        except Exception as except_msg:
                self.logger.exception("[PID {}] Error: {}".format(self.pid_id,
                                                                except_msg))


    def update(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 sensor)
        :type current_value: float
        """
        self.error = self.set_point - current_value

        # Calculate P-value
        self.P_value = self.Kp * self.error

        # Calculate I-value
        self.Integrator += self.error
        # Old 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
        # New method for regulating Integrator
        if self.measure_interval is not None:  
            if self.Integrator * self.Ki > self.measure_interval:
                self.Integrator = self.measure_interval / self.Ki
            elif self.Integrator * self.Ki < -self.measure_interval:
                self.Integrator = -self.measure_interval / self.Ki
        self.I_value = self.Integrator * self.Ki

        # 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 = self.P_value + self.I_value + self.D_value
        return PID


    def get_last_measurement(self):
        """
        Retrieve the latest sensor measurement from InfluxDB

        :rtype: None
        """
        self.last_measurement_success = False
        # Get latest measurement (from within the past minute) from influxdb
        try:
            self.last_measurement = read_last_influxdb(
                INFLUXDB_HOST,
                INFLUXDB_PORT,
                INFLUXDB_USER,
                INFLUXDB_PASSWORD,
                INFLUXDB_DATABASE,
                self.sensor_id,
                self.measure_type)
            if self.last_measurement:
                measurement_list = list(self.last_measurement.get_points(
                    measurement=self.measure_type))
                self.last_time = measurement_list[0]['time']
                self.last_measurement = measurement_list[0]['value']
                utc_dt = datetime.strptime(self.last_time.split(".")[0], '%Y-%m-%dT%H:%M:%S')
                utc_timestamp = calendar.timegm(utc_dt.timetuple())
                local_timestamp = str(datetime.fromtimestamp(utc_timestamp))
                self.logger.debug("[PID {}] Latest {}: {} @ {}".format(
                    self.pid_id, self.measure_type,
                    self.last_measurement, local_timestamp))
                self.last_measurement_success = True
            else:
                self.logger.warning("[PID {}] No data returned "
                                    "from influxdb".format(self.pid_id))
        except Exception as except_msg:
            self.logger.exception("[PID {}] Failed to read "
                                "measurement from the influxdb "
                                "database: {}".format(self.pid_id,
                                                      except_msg))


    def manipulate_relays(self):
        """
        Activate a relay based on PID output (control variable) and whether
        the manipulation directive is to raise, lower, or both.

        :rtype: None
        """
        # If there was a measurement able to be retrieved from
        # influxdb database that was entered within the past minute
        if self.last_measurement_success:

            # Update setpoint if dynamic setpoints are enabled for this PID
            # and the current time is within one of the set time spans
            use_default_setpoint = True
            for each_setpt in self.pidsetpoints:
                if self.now_in_range(each_setpt.start_time,
                                     each_setpt.end_time):
                    use_default_setpoint = False
                    self.calculate_new_setpoint(each_setpt.start_time,
                                                each_setpt.end_time,
                                                each_setpt.start_setpoint,
                                                each_setpt.end_setpoint)
                    self.logger.debug("[PID {}] New setpoint: {}".format(self.pid_id, self.set_point))
            if use_default_setpoint:
                self.set_point = self.default_set_point

            self.addSetpointInfluxdb(self.pid_id, self.set_point)

            # Update PID and get control variable
            self.control_variable = self.update(self.last_measurement)

            #
            # PID control variable positive to raise environmental condition
            #
            if self.direction in ['raise', 'both'] and self.raise_relay_id:
                if self.control_variable > 0:
                    # Ensure the relay 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))

                    # Turn off lower_relay if active, because we're now raising
                    if self.lower_relay_id:
                        with session_scope(MYCODO_DB_PATH) as new_session:
                            relay = new_session.query(Relay).filter(
                                Relay.id == self.lower_relay_id).first()
                            if relay.is_on():
                                self.control.relay_off(self.lower_relay_id)

                    if self.raise_seconds_on > self.raise_min_duration:
                        # Activate raise_relay for a duration
                        self.logger.debug("[PID {}] Setpoint: {} "
                            "Output: {} to relay {}".format(
                                self.pid_id,
                                self.set_point,
                                self.control_variable,
                                self.raise_relay_id))
                        self.control.relay_on(self.raise_relay_id,
                                         self.raise_seconds_on)
                else:
                    self.control.relay_off(self.raise_relay_id)

            #
            # PID control variable negative to lower environmental condition
            #
            if self.direction in ['lower', 'both'] and self.lower_relay_id:
                if self.control_variable < 0:
                    # Ensure the relay 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 = abs(float("{0:.2f}".format(self.control_variable)))

                    # Turn off raise_relay if active, because we're now lowering
                    if self.raise_relay_id:
                        with session_scope(MYCODO_DB_PATH) as new_session:
                            relay = new_session.query(Relay).filter(
                                Relay.id == self.raise_relay_id).first()
                            if relay.is_on():
                                self.control.relay_off(self.raise_relay_id)

                    if self.lower_seconds_on > self.lower_min_duration:
                        # Activate lower_relay for a duration
                        self.logger.debug("[PID {}] Setpoint: {} "
                            "Output: {} to relay {}".format(
                                self.pid_id,
                                self.set_point,
                                self.control_variable,
                                self.lower_relay_id))
                        self.control.relay_on(self.lower_relay_id,
                                         self.lower_seconds_on)
                else:
                    self.control.relay_off(self.lower_relay_id)

        else:
            if self.direction in ['raise', 'both'] and self.raise_relay_id:
                self.control.relay_off(self.raise_relay_id)
            if self.direction in ['lower', 'both'] and self.lower_relay_id:
                self.control.relay_off(self.lower_relay_id)


    def now_in_range(self, start_time, end_time):
        """
        Check if the current time is between start_time and end_time

        :return: 1 is within range, 0 if not within range
        :rtype: int
        """
        start_hour = int(start_time.split(":")[0])
        start_min = int(start_time.split(":")[1])
        end_hour = int(end_time.split(":")[0])
        end_min = int(end_time.split(":")[1])
        now_time = datetime.now().time()
        now_time = now_time.replace(second=0, microsecond=0)
        if ((start_hour < end_hour) or
                (start_hour == end_hour and start_min < end_min)):
            if now_time >= time(start_hour,start_min) and now_time <= time(end_hour,end_min):
                return 1  # Yes now within range
        else:
            if now_time >= time(start_hour,start_min) or now_time <= time(end_hour,end_min):
                return 1  # Yes now within range
        return 0 # No now not within range


    def calculate_new_setpoint(self, start_time, end_time, start_setpoint, end_setpoint):
        """
        Calculate a dynamic setpoint that changes over time

        The current time must fall between the start_time and end_time.
        If there is only a start_setpoint, that is the only setpoint that can
        be returned.

        Based on where the current time falls between the start_time
        and the end_time, a setpoint between the start_setpoint and
        end_setpoint will be calculated.

        For example, if the time range is 12:00 to 1:00, and the setPoint
        range is 0 to 60, and the current time is 12:30, the calculated
        setpoint will be 30.

        :return: 0 if only a start setpoint is set, 1 if both start and end
            setpoints are set and the value between has been calculated.
        :rtype: int

        :param start_time: The start hour and minute of the time range
        :type start_time: str
        :param end_time: The end hour and minute of the time range
        :type end_time: str
        :param start_setpoint: The start setpoint
        :type start_setpoint: float
        :param end_setpoint: The end setpoint
        :type end_setpoint: float or None
        """
        # Only a start_setpoint set for this time period
        if end_setpoint is None:
            self.set_point = start_setpoint
            return 0

        # Split hour and minute into separate integers
        start_hour = int(start_time.split(":")[0])
        start_min = int(start_time.split(":")[1])
        end_hour = int(end_time.split(":")[0])
        end_min = int(end_time.split(":")[1])

        # Set the date and time format
        date_format = "%d %H:%M"  # Add day in case end time is the next day

        # Convert string of 'day hour:minute' to actual date and time
        start_time_formatted  = datetime.strptime("1 "+start_time, date_format)
        end_day_modifier = "1 "  # End time is the same day
        if (start_hour > end_hour or
                (start_hour == end_hour and start_min > end_min)):
            end_day_modifier = "2 "  # End time is the next day
        end_time_formatted  = datetime.strptime(end_day_modifier+end_time, date_format)

        # Total number of minute between start time and end time
        diff = end_time_formatted-start_time_formatted
        diff_min = diff.seconds/60

        # Find the difference between setpoints
        diff_setpoints = abs(end_setpoint-start_setpoint) 

        # Total number of minute between start time and now
        now = datetime.now()
        if now.hour > start_hour:
            hours = now.hour-start_hour
        elif now.hour < start_hour:
            hours = now.hour+(24-start_hour)
        elif now.hour == start_hour:
            hours = 0
        minutes = now.minute-start_min  # May be negative
        total_minutes = (hours*60)+minutes

        # Based on the number of minutes between the start and end time and the
        # minutes passed since the start time, calculate the new setpoint
        mod_setpoint = total_minutes/float(diff_min/diff_setpoints)

        if end_setpoint < start_setpoint:
            # Setpoint decreases over duration
            new_setpoint = float("{0:.2f}".format(start_setpoint-mod_setpoint))
        else:
            # Setpoint increases over duration
            new_setpoint = float("{0:.2f}".format(start_setpoint+mod_setpoint))

        self.set_point = new_setpoint
        return 1


    def addSetpointInfluxdb(self, pid_id, setpoint):
        """
        Add a setpoint entry to InfluxDB

        :rtype: None
        """
        write_db = threading.Thread(
            target=write_influxdb,
            args=(self.logger, INFLUXDB_HOST,
                  INFLUXDB_PORT, INFLUXDB_USER,
                  INFLUXDB_PASSWORD, INFLUXDB_DATABASE,
                  'pid', pid_id, 'setpoint', setpoint,))
        write_db.start()


    def setPoint(self, set_point):
        """Initilize the setpoint of PID"""
        self.set_point = set_point
        self.Integrator = 0
        self.Derivator = 0


    def setIntegrator(self, Integrator):
        """Set the Integrator of the controller"""
        self.Integrator = Integrator


    def setDerivator(self, Derivator):
        """Set the Derivator of the controller"""
        self.Derivator = Derivator


    def setKp(self, P):
        """Set Kp gain of the controller"""
        self.Kp = P


    def setKi(self, I):
        """Set Ki gain of the controller"""
        self.Ki = I


    def setKd(self, D):
        """Set Kd gain of the controller"""
        self.Kd = D


    def getPoint(self):
        return self.set_point


    def getError(self):
        return self.error


    def getIntegrator(self):
        return self.Integrator


    def getDerivator(self):
        return self.Derivator


    def isRunning(self):
        return self.running


    def stopController(self):
        self.thread_shutdown_timer = timeit.default_timer()
        self.running = False
예제 #4
0
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))

        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.initialize_values()

        self.control_variable = 0
        self.Derivator = 0
        self.Integrator = 0
        self.error = 0.0
        self.P_value = None
        self.I_value = None
        self.D_value = None
        self.set_point = 0
        self.lower_seconds_on = 0
        self.raise_seconds_on = 0
        self.last_measurement_success = False
        self.timer = t.time() + self.measure_interval

        # Check if a method is set for this PID
        if self.method_id:
            method = db_retrieve_table(MYCODO_DB_PATH, Method)
            method = method.filter(Method.method_id == self.method_id)
            method = method.filter(Method.method_order == 0).first()
            self.method_type = method.method_type
            self.method_start_time = method.start_time

            if self.method_type == 'Duration':
                if self.method_start_time == 'Ended':
                    # Method has ended and hasn't been instructed to begin again
                    pass
                elif self.method_start_time == 'Ready' or self.method_start_time is None:
                    # Method has been instructed to begin
                    with session_scope(MYCODO_DB_PATH) as db_session:
                        mod_method = db_session.query(Method)
                        mod_method = mod_method.filter(
                            Method.method_id == self.method_id)
                        mod_method = mod_method.filter(
                            Method.method_order == 0).first()
                        mod_method.start_time = datetime.datetime.now()
                        self.method_start_time = mod_method.start_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(self.method_start_time), '%Y-%m-%d %H:%M:%S.%f')
                    self.logger.warning(
                        "Resuming method {id} started at {time}".format(
                            id=self.method_id, time=self.method_start_time))

    def run(self):
        try:
            self.running = True
            self.logger.info("Activated in {:.1f} ms".format(
                (timeit.default_timer() - self.thread_startup_timer) * 1000))
            if self.activated == 2:
                self.logger.info("Paused")
            elif self.activated == 3:
                self.logger.info("Held")
            self.ready.set()

            while self.running:
                if t.time() > self.timer:
                    self.timer = self.timer + self.measure_interval
                    # self.activated: 0=inactive, 1=active, 2=pause, 3=hold

                    # If active, retrieve sensor measurement and update PID output
                    if self.activated == 1:
                        self.get_last_measurement()

                        if self.last_measurement_success:
                            # Update setpoint if a method is selected
                            if self.method_id != '':
                                self.calculate_method_setpoint(self.method_id)
                            write_influxdb_setpoint(self.pid_id,
                                                    self.set_point)
                            # Update PID and get control variable
                            self.control_variable = self.update(
                                self.last_measurement)

                    # If active or on hold, activate relays
                    if self.activated in [1, 3]:
                        self.manipulate_relays()
                t.sleep(0.1)

            if self.raise_relay_id:
                self.control.relay_off(self.raise_relay_id)
            if self.lower_relay_id:
                self.control.relay_off(self.lower_relay_id)

            self.running = False
            self.logger.info("Deactivated in {:.1f} ms".format(
                (timeit.default_timer() - self.thread_shutdown_timer) * 1000))
        except Exception as except_msg:
            self.logger.exception("Run Error: {err}".format(err=except_msg))

    def initialize_values(self):
        """Set PID parameters"""
        pid = db_retrieve_table(MYCODO_DB_PATH, PID, device_id=self.pid_id)
        sensor = db_retrieve_table(MYCODO_DB_PATH,
                                   Sensor,
                                   device_id=pid.sensor_id)
        self.activated = pid.activated  # 0=inactive, 1=active, 2=paused
        self.sensor_id = pid.sensor_id
        self.measure_type = pid.measure_type
        self.method_id = pid.method_id
        self.direction = pid.direction
        self.raise_relay_id = pid.raise_relay_id
        self.raise_min_duration = pid.raise_min_duration
        self.raise_max_duration = pid.raise_max_duration
        self.lower_relay_id = pid.lower_relay_id
        self.lower_min_duration = pid.lower_min_duration
        self.lower_max_duration = pid.lower_max_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.measure_interval = pid.period
        self.default_set_point = pid.setpoint
        self.set_point = pid.setpoint
        self.sensor_duration = sensor.period
        return "success"

    def update(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 sensor)
        :type current_value: float
        """
        self.error = self.set_point - 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.measure_interval is not None:
        #     if self.Integrator * self.Ki > self.measure_interval:
        #         self.Integrator = self.measure_interval / self.Ki
        #     elif self.Integrator * self.Ki < -self.measure_interval:
        #         self.Integrator = -self.measure_interval / self.Ki

        self.I_value = self.Integrator * self.Ki

        # 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 get_last_measurement(self):
        """
        Retrieve the latest sensor measurement from InfluxDB

        :rtype: None
        """
        self.last_measurement_success = False
        # Get latest measurement (from within the past minute) from influxdb
        try:
            if self.sensor_duration < 60:
                duration = 60
            else:
                duration = int(self.sensor_duration * 1.5)
            self.last_measurement = read_last_influxdb(self.sensor_id,
                                                       self.measure_type,
                                                       duration)
            if self.last_measurement:
                measurement_list = list(
                    self.last_measurement.get_points(
                        measurement=self.measure_type))
                self.last_time = measurement_list[0]['time']
                self.last_measurement = measurement_list[0]['value']
                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 {}: {} @ {}".format(
                    self.measure_type, self.last_measurement, local_timestamp))
                self.last_measurement_success = True
            else:
                self.logger.warning("No data returned from influxdb")
        except Exception as except_msg:
            self.logger.exception(
                "Failed to read measurement from the influxdb database: "
                "{err}".format(err=except_msg))

    def manipulate_relays(self):
        """
        Activate a relay based on PID output (control variable) and whether
        the manipulation directive is to raise, lower, or both.

        :rtype: None
        """
        # If there was a measurement able to be retrieved from
        # influxdb database that was entered within the past minute
        if self.last_measurement_success:
            #
            # PID control variable positive to raise environmental condition
            #
            if self.direction in ['raise', 'both'] and self.raise_relay_id:
                if self.control_variable > 0:
                    # Ensure the relay 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))

                    # Turn off lower_relay if active, because we're now raising
                    if self.lower_relay_id:
                        relay = db_retrieve_table(
                            MYCODO_DB_PATH,
                            Relay,
                            device_id=self.lower_relay_id)
                        if relay.is_on():
                            self.control.relay_off(self.lower_relay_id)

                    if self.raise_seconds_on > self.raise_min_duration:
                        # Activate raise_relay for a duration
                        self.logger.debug(
                            "Setpoint: {sp} Output: {op} to relay "
                            "{relay}".format(sp=self.set_point,
                                             op=self.control_variable,
                                             relay=self.raise_relay_id))
                        self.control.relay_on(self.raise_relay_id,
                                              self.raise_seconds_on)
                else:
                    self.control.relay_off(self.raise_relay_id)

            #
            # PID control variable negative to lower environmental condition
            #
            if self.direction in ['lower', 'both'] and self.lower_relay_id:
                if self.control_variable < 0:
                    # Ensure the relay 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 = abs(
                            float("{0:.2f}".format(self.control_variable)))

                    # Turn off raise_relay if active, because we're now lowering
                    if self.raise_relay_id:
                        relay = db_retrieve_table(
                            MYCODO_DB_PATH,
                            Relay,
                            device_id=self.raise_relay_id)
                        if relay.is_on():
                            self.control.relay_off(self.raise_relay_id)

                    if self.lower_seconds_on > self.lower_min_duration:
                        # Activate lower_relay for a duration
                        self.logger.debug("Setpoint: {sp} Output: {op} to "
                                          "relay {relay}".format(
                                              sp=self.set_point,
                                              op=self.control_variable,
                                              relay=self.lower_relay_id))
                        self.control.relay_on(self.lower_relay_id,
                                              self.lower_seconds_on)
                else:
                    self.control.relay_off(self.lower_relay_id)

        else:
            if self.direction in ['raise', 'both'] and self.raise_relay_id:
                self.control.relay_off(self.raise_relay_id)
            if self.direction in ['lower', 'both'] and self.lower_relay_id:
                self.control.relay_off(self.lower_relay_id)

    def calculate_method_setpoint(self, method_id):
        method = db_retrieve_table(MYCODO_DB_PATH, Method)

        method_key = method.filter(Method.method_id == method_id)
        method_key = method_key.filter(Method.method_order == 0).first()

        method = method.filter(Method.method_id == method_id)
        method = method.filter(Method.relay_id == None)
        method = method.filter(Method.method_order > 0)
        method = method.order_by(Method.method_order.asc()).all()

        now = datetime.datetime.now()

        # Calculate where the current time/date is within the time/date method
        if method_key.method_type == 'Date':
            for each_method in method:
                start_time = datetime.datetime.strptime(
                    each_method.start_time, '%Y-%m-%d %H:%M:%S')
                end_time = datetime.datetime.strptime(each_method.end_time,
                                                      '%Y-%m-%d %H:%M:%S')
                if start_time < now < end_time:
                    start_setpoint = each_method.start_setpoint
                    if each_method.end_setpoint:
                        end_setpoint = each_method.end_setpoint
                    else:
                        end_setpoint = each_method.start_setpoint

                    setpoint_diff = abs(end_setpoint - start_setpoint)
                    total_seconds = (end_time - start_time).total_seconds()
                    part_seconds = (now - start_time).total_seconds()
                    percent_total = part_seconds / total_seconds

                    if start_setpoint < end_setpoint:
                        new_setpoint = start_setpoint + (setpoint_diff *
                                                         percent_total)
                    else:
                        new_setpoint = start_setpoint - (setpoint_diff *
                                                         percent_total)

                    self.logger.debug("[Method] Start: {} End: {}".format(
                        start_time, end_time))
                    self.logger.debug("[Method] Start: {} End: {}".format(
                        start_setpoint, end_setpoint))
                    self.logger.debug(
                        "[Method] Total: {} Part total: {} ({}%)".format(
                            total_seconds, part_seconds, percent_total))
                    self.logger.debug(
                        "[Method] New Setpoint: {}".format(new_setpoint))
                    self.set_point = new_setpoint
                    return 0

        # Calculate where the current Hour:Minute:Seconds is within the Daily method
        elif method_key.method_type == 'Daily':
            daily_now = datetime.datetime.now().strftime('%H:%M:%S')
            daily_now = datetime.datetime.strptime(str(daily_now), '%H:%M:%S')
            for each_method in method:
                start_time = datetime.datetime.strptime(
                    each_method.start_time, '%H:%M:%S')
                end_time = datetime.datetime.strptime(each_method.end_time,
                                                      '%H:%M:%S')
                if start_time < daily_now < end_time:
                    start_setpoint = each_method.start_setpoint
                    if each_method.end_setpoint:
                        end_setpoint = each_method.end_setpoint
                    else:
                        end_setpoint = each_method.start_setpoint

                    setpoint_diff = abs(end_setpoint - start_setpoint)
                    total_seconds = (end_time - start_time).total_seconds()
                    part_seconds = (daily_now - start_time).total_seconds()
                    percent_total = part_seconds / total_seconds

                    if start_setpoint < end_setpoint:
                        new_setpoint = start_setpoint + (setpoint_diff *
                                                         percent_total)
                    else:
                        new_setpoint = start_setpoint - (setpoint_diff *
                                                         percent_total)

                    self.logger.debug("[Method] Start: {} End: {}".format(
                        start_time.strftime('%H:%M:%S'),
                        end_time.strftime('%H:%M:%S')))
                    self.logger.debug("[Method] Start: {} End: {}".format(
                        start_setpoint, end_setpoint))
                    self.logger.debug(
                        "[Method] Total: {} Part total: {} ({}%)".format(
                            total_seconds, part_seconds, percent_total))
                    self.logger.debug(
                        "[Method] New Setpoint: {}".format(new_setpoint))
                    self.set_point = new_setpoint
                    return 0

        # Calculate sine y-axis value from the x-axis (seconds of the day)
        elif method_key.method_type == 'DailySine':
            new_setpoint = sine_wave_y_out(method_key.amplitude,
                                           method_key.frequency,
                                           method_key.shift_angle,
                                           method_key.shift_y)
            self.set_point = new_setpoint
            return 0

        # Calculate Bezier curve y-axis value from the x-axis (seconds of the day)
        elif method_key.method_type == 'DailyBezier':
            new_setpoint = bezier_curve_y_out(method_key.shift_angle,
                                              (method_key.x0, method_key.y0),
                                              (method_key.x1, method_key.y1),
                                              (method_key.x2, method_key.y2),
                                              (method_key.x3, method_key.y3))
            self.set_point = new_setpoint
            return 0

        # Calculate the duration in the method based on self.method_start_time
        elif method_key.method_type == 'Duration' and self.method_start_time != 'Ended':
            seconds_from_start = (now - self.method_start_time).total_seconds()
            total_sec = 0
            previous_total_sec = 0
            for each_method in method:
                total_sec += each_method.duration_sec
                if previous_total_sec <= seconds_from_start < total_sec:
                    row_start_time = float(
                        self.method_start_time.strftime(
                            '%s')) + previous_total_sec
                    row_since_start_sec = (
                        now - (self.method_start_time + datetime.timedelta(
                            0, previous_total_sec))).total_seconds()
                    percent_row = row_since_start_sec / each_method.duration_sec

                    start_setpoint = each_method.start_setpoint
                    if each_method.end_setpoint:
                        end_setpoint = each_method.end_setpoint
                    else:
                        end_setpoint = each_method.start_setpoint
                    setpoint_diff = abs(end_setpoint - start_setpoint)
                    if start_setpoint < end_setpoint:
                        new_setpoint = start_setpoint + (setpoint_diff *
                                                         percent_row)
                    else:
                        new_setpoint = start_setpoint - (setpoint_diff *
                                                         percent_row)

                    self.logger.debug(
                        "[Method] Start: {} Seconds Since: {}".format(
                            self.method_start_time, seconds_from_start))
                    self.logger.debug("[Method] Start time of row: {}".format(
                        datetime.datetime.fromtimestamp(row_start_time)))
                    self.logger.debug(
                        "[Method] Sec since start of row: {}".format(
                            row_since_start_sec))
                    self.logger.debug(
                        "[Method] Percent of row: {}".format(percent_row))
                    self.logger.debug(
                        "[Method] New Setpoint: {}".format(new_setpoint))
                    self.set_point = new_setpoint
                    return 0
                previous_total_sec = total_sec

            # Duration method has ended, reset start_time locally and in DB
            if self.method_start_time:
                with session_scope(MYCODO_DB_PATH) as db_session:
                    mod_method = db_session.query(Method).filter(
                        Method.method_id == self.method_id)
                    mod_method = mod_method.filter(
                        Method.method_order == 0).first()
                    mod_method.start_time = 'Ended'
                    db_session.commit()
                self.method_start_time = 'Ended'

        # Setpoint not needing to be calculated, use default setpoint
        self.set_point = self.default_set_point

    def pid_mod(self):
        if self.initialize_values():
            return "success"
        else:
            return "error"

    def pid_hold(self):
        self.activated = 3
        self.logger.info("Hold")
        return "success"

    def pid_pause(self):
        self.activated = 2
        self.logger.info("Pause")
        return "success"

    def pid_resume(self):
        self.activated = 1
        self.logger.info("Resume")
        return "success"

    def set_setpoint(self, set_point):
        """ Initilize the setpoint of PID """
        self.set_point = set_point
        self.Integrator = 0
        self.Derivator = 0

    def set_integrator(self, Integrator):
        """ Set the Integrator of the controller """
        self.Integrator = Integrator

    def set_derivator(self, Derivator):
        """ Set the Derivator of the controller """
        self.Derivator = Derivator

    def set_kp(self, P):
        """ Set Kp gain of the controller """
        self.Kp = P

    def set_ki(self, I):
        """ Set Ki gain of the controller """
        self.Ki = I

    def set_kd(self, D):
        """ Set Kd gain of the controller """
        self.Kd = D

    def get_setpoint(self):
        return self.set_point

    def get_error(self):
        return self.error

    def get_integrator(self):
        return self.Integrator

    def get_derivator(self):
        return self.Derivator

    def is_running(self):
        return self.running

    def stop_controller(self):
        self.thread_shutdown_timer = timeit.default_timer()
        self.running = False
        # Unset method start time
        if self.method_id:
            with session_scope(MYCODO_DB_PATH) as db_session:
                mod_method = db_session.query(Method)
                mod_method = mod_method.filter(
                    Method.method_id == self.method_id)
                mod_method = mod_method.filter(
                    Method.method_order == 0).first()
                mod_method.start_time = 'Ended'
                db_session.commit()
예제 #5
0
class TimerController(threading.Thread):
    """
    class for controlling timers

    """
    def __init__(self, ready, timer_id):
        threading.Thread.__init__(self)

        self.logger = logging.getLogger(
            "mycodo.timer_{id}".format(id=timer_id))

        self.thread_startup_timer = timeit.default_timer()
        self.thread_shutdown_timer = 0
        self.ready = ready
        self.timer_id = timer_id
        self.control = DaemonControl()

        timer = db_retrieve_table_daemon(Timer, device_id=self.timer_id)
        self.timer_type = timer.timer_type
        self.output_unique_id = timer.relay_id
        self.method_id = timer.method_id
        self.method_period = timer.method_period
        self.state = timer.state
        self.time_start = timer.time_start
        self.time_end = timer.time_end
        self.duration_on = timer.duration_on
        self.duration_off = timer.duration_off

        self.output_id = db_retrieve_table_daemon(
            Output, unique_id=self.output_unique_id).id

        # Time of day split into hour and minute
        if self.time_start:
            time_split = self.time_start.split(":")
            self.start_hour = time_split[0]
            self.start_minute = time_split[1]
        else:
            self.start_hour = None
            self.start_minute = None

        if self.time_end:
            time_split = self.time_end.split(":")
            self.end_hour = time_split[0]
            self.end_minute = time_split[1]
        else:
            self.end_hour = None
            self.end_minute = None

        self.duration_timer = time.time()
        self.pwm_method_timer = time.time()
        self.date_timer_not_executed = True
        self.running = False

        if self.method_id:
            method = db_retrieve_table_daemon(Method, device_id=self.method_id)
            method_data = db_retrieve_table_daemon(MethodData)
            method_data = method_data.filter(
                MethodData.method_id == self.method_id)
            method_data_repeat = method_data.filter(
                MethodData.duration_sec == 0).first()
            self.method_type = method.method_type
            self.method_start_act = timer.method_start_time
            self.method_start_time = None
            self.method_end_time = None

            if self.method_type == 'Duration':
                if self.method_start_act == 'Ended':
                    self.stop_controller(ended_normally=False,
                                         deactivate_timer=True)
                    self.logger.warning(
                        "Method has ended. "
                        "Activate the Timer controller to start it again.")
                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_timer = db_session.query(Timer)
                        mod_timer = mod_timer.filter(
                            Timer.id == self.timer_id).first()
                        mod_timer.method_start_time = self.method_start_time
                        mod_timer.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(timer.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(timer.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=self.method_id,
                                                start=self.method_start_time,
                                                end=self.method_end_time))
                    else:
                        self.method_start_act = 'Ended'
                else:
                    self.method_start_act = 'Ended'

    def run(self):
        self.running = True
        self.logger.info("Activated in {:.1f} ms".format(
            (timeit.default_timer() - self.thread_startup_timer) * 1000))
        self.ready.set()

        while self.running:

            # Timer is set to react at a specific hour and minute of the day
            if self.timer_type == 'time':
                if (int(self.start_hour) == datetime.datetime.now().hour
                        and int(self.start_minute)
                        == datetime.datetime.now().minute):
                    # Ensure this is triggered only once at this specific time
                    if self.date_timer_not_executed:
                        self.date_timer_not_executed = False
                        message = "At {st}, turn Output {id} {state}".format(
                            st=self.time_start,
                            id=self.output_id,
                            state=self.state)
                        if self.state == 'on' and self.duration_on:
                            message += " for {sec} seconds".format(
                                sec=self.duration_on)
                        else:
                            self.duration_on = 0
                        self.logger.debug(message)

                        modulate_output = threading.Thread(
                            target=self.control.output_on_off,
                            args=(
                                self.output_id,
                                self.state,
                            ),
                            kwargs={'duration': self.duration_on})
                        modulate_output.start()
                elif not self.date_timer_not_executed:
                    self.date_timer_not_executed = True

            # Timer is set to react at a specific time duration of the day
            elif self.timer_type == 'timespan':
                if time_between_range(self.time_start, self.time_end):
                    current_output_state = self.control.relay_state(
                        self.output_id)
                    if self.state != current_output_state:
                        message = "Output {output} should be {state}, but is " \
                                  "{cstate}. Turning {state}.".format(
                                    output=self.output_id,
                                    state=self.state,
                                    cstate=current_output_state)
                        modulate_output = threading.Thread(
                            target=self.control.output_on_off,
                            args=(
                                self.output_id,
                                self.state,
                            ))
                        modulate_output.start()
                        self.logger.debug(message)

            # Timer is a simple on/off duration timer
            elif self.timer_type == 'duration':
                if time.time() > self.duration_timer:
                    self.duration_timer = (time.time() + self.duration_on +
                                           self.duration_off)
                    self.logger.debug("Turn Output {output} on for {onsec} "
                                      "seconds, then off for {offsec} "
                                      "seconds".format(
                                          output=self.output_id,
                                          onsec=self.duration_on,
                                          offsec=self.duration_off))
                    output_on = threading.Thread(target=self.control.relay_on,
                                                 args=(
                                                     self.output_id,
                                                     self.duration_on,
                                                 ))
                    output_on.start()

            # Timer is a PWM Method timer
            elif self.timer_type == 'pwm_method':
                try:
                    if time.time() > self.pwm_method_timer:
                        if self.method_start_act == 'Ended':
                            self.stop_controller(ended_normally=False,
                                                 deactivate_timer=True)
                            self.logger.info(
                                "Method has ended. "
                                "Activate the Timer controller to start it again."
                            )
                        else:
                            this_controller = db_retrieve_table_daemon(
                                Timer, device_id=self.timer_id)
                            setpoint, ended = calculate_method_setpoint(
                                self.method_id, Timer, this_controller, Method,
                                MethodData, self.logger)
                            if ended:
                                self.method_start_act = 'Ended'
                            if setpoint > 100:
                                setpoint = 100
                            elif setpoint < 0:
                                setpoint = 0
                            self.logger.debug(
                                "Turn Output {output} to a PWM duty cycle of "
                                "{dc:.1f} %".format(output=self.output_id,
                                                    dc=setpoint))
                            # Activate pwm with calculated duty cycle
                            self.control.relay_on(self.output_id,
                                                  duty_cycle=setpoint)
                        self.pwm_method_timer = time.time(
                        ) + self.method_period
                except Exception:
                    self.logger.exception(1)

            time.sleep(0.1)

        self.control.relay_off(self.output_id)
        self.running = False
        self.logger.info("Deactivated in {:.1f} ms".format(
            (timeit.default_timer() - self.thread_shutdown_timer) * 1000))

    def is_running(self):
        return self.running

    def stop_controller(self, ended_normally=True, deactivate_timer=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_timer = db_session.query(Timer).filter(
                    Timer.id == self.timer_id).first()
                mod_timer.method_start_time = 'Ended'
                mod_timer.method_end_time = None
                db_session.commit()

        if deactivate_timer:
            with session_scope(MYCODO_DB_PATH) as db_session:
                mod_timer = db_session.query(Timer).filter(
                    Timer.id == self.timer_id).first()
                mod_timer.is_activated = False
                db_session.commit()
예제 #6
0
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))

        self.running = False
        self.thread_startup_timer = timeit.default_timer()
        self.thread_shutdown_timer = 0
        self.ready = ready
        self.pid_id = pid_id
        self.pid_unique_id = db_retrieve_table_daemon(
            PID, device_id=self.pid_id).unique_id
        self.control = DaemonControl()

        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.set_point = 0.0
        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.pid_type = None
        self.measurement = None
        self.method_id = None
        self.direction = None
        self.raise_relay_id = None
        self.raise_min_duration = None
        self.raise_max_duration = None
        self.raise_min_off_duration = None
        self.lower_relay_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_set_point = None
        self.set_point = None

        self.sensor_unique_id = None
        self.sensor_duration = None

        self.initialize_values()

        self.timer = t.time() + self.period

        # Check if a method is set for this PID
        if self.method_id:
            method = db_retrieve_table_daemon(Method, device_id=self.method_id)
            self.method_type = method.method_type
            self.method_start_time = method.start_time

            if self.method_type == 'Duration':
                if self.method_start_time == 'Ended':
                    # Method has ended and hasn't been instructed to begin again
                    pass
                elif self.method_start_time == 'Ready' or self.method_start_time is None:
                    # Method has been instructed to begin
                    with session_scope(MYCODO_DB_PATH) as db_session:
                        mod_method = db_session.query(Method)
                        mod_method = mod_method.filter(
                            Method.id == self.method_id).first()
                        mod_method.time_start = datetime.datetime.now()
                        self.method_start_time = mod_method.start_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(self.method_start_time), '%Y-%m-%d %H:%M:%S.%f')
                    self.logger.warning(
                        "Resuming method {id} started at {time}".format(
                            id=self.method_id, time=self.method_start_time))

    def run(self):
        try:
            self.running = True
            self.logger.info("Activated in {:.1f} ms".format(
                (timeit.default_timer() - self.thread_startup_timer) * 1000))
            if self.is_paused:
                self.logger.info("Paused")
            elif self.is_held:
                self.logger.info("Held")
            self.ready.set()

            while self.running:
                if t.time() > self.timer:
                    # Ensure the timer ends in the future
                    while t.time() > self.timer:
                        self.timer = self.timer + self.period

                    # If PID is active, retrieve sensor measurement and update PID output
                    if self.is_activated and not self.is_paused:
                        self.get_last_measurement()

                        if self.last_measurement_success:
                            # Update setpoint using a method if one is selected
                            if self.method_id:
                                self.calculate_method_setpoint(self.method_id)

                            write_setpoint_db = threading.Thread(
                                target=write_influxdb_value,
                                args=(
                                    self.pid_unique_id,
                                    'setpoint',
                                    self.set_point,
                                ))
                            write_setpoint_db.start()

                            # Update PID and get control variable
                            self.control_variable = self.update_pid_output(
                                self.last_measurement)

                            if self.pid_type == 'relay':
                                pid_entry_type = 'pid_output'
                                pid_entry_value = self.control_variable
                            elif self.pid_type == 'pwm':
                                pid_entry_type = 'duty_cycle'
                                pid_entry_value = self.control_var_to_duty_cycle(
                                    abs(self.control_variable))
                                if self.control_variable < 0:
                                    pid_entry_value = -pid_entry_value
                            else:
                                pid_entry_type = None
                                pid_entry_value = None

                            if pid_entry_type:
                                write_pid_out_db = threading.Thread(
                                    target=write_influxdb_value,
                                    args=(
                                        self.pid_unique_id,
                                        pid_entry_type,
                                        pid_entry_value,
                                    ))
                                write_pid_out_db.start()

                    # If PID is active or on hold, activate relays
                    if ((self.is_activated and not self.is_paused)
                            or (self.is_activated and self.is_held)):
                        self.manipulate_output()
                t.sleep(0.1)

            # Turn off relay used in PID when the controller is deactivated
            if self.raise_relay_id and self.direction in ['raise', 'both']:
                self.control.relay_off(self.raise_relay_id,
                                       trigger_conditionals=True)
            if self.lower_relay_id and self.direction in ['lower', 'both']:
                self.control.relay_off(self.lower_relay_id,
                                       trigger_conditionals=True)

            self.running = False
            self.logger.info("Deactivated in {:.1f} ms".format(
                (timeit.default_timer() - self.thread_shutdown_timer) * 1000))
        except Exception as except_msg:
            self.logger.exception("Run Error: {err}".format(err=except_msg))

    def initialize_values(self):
        """Set PID parameters"""
        pid = db_retrieve_table_daemon(PID, device_id=self.pid_id)
        self.is_activated = pid.is_activated
        self.is_held = pid.is_held
        self.is_paused = pid.is_paused
        self.pid_type = pid.pid_type
        self.measurement = pid.measurement
        self.method_id = pid.method_id
        self.direction = pid.direction
        self.raise_relay_id = pid.raise_relay_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_relay_id = pid.lower_relay_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_set_point = pid.setpoint
        self.set_point = pid.setpoint

        sensor = db_retrieve_table_daemon(Sensor, device_id=pid.sensor_id)
        self.sensor_unique_id = sensor.unique_id
        self.sensor_duration = sensor.period

        return "success"

    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 sensor)
        :type current_value: float
        """
        self.error = self.set_point - 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

        # 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 get_last_measurement(self):
        """
        Retrieve the latest sensor measurement from InfluxDB

        :rtype: None
        """
        self.last_measurement_success = False
        # Get latest measurement (from within the past minute) from influxdb
        try:
            if self.sensor_duration < 60:
                duration = 60
            else:
                duration = int(self.sensor_duration * 1.5)
            self.last_measurement = read_last_influxdb(self.sensor_unique_id,
                                                       self.measurement,
                                                       duration)
            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(
                        t.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(t.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_relay_id:

                if self.control_variable > 0:
                    # Turn off lower_relay if active, because we're now raising
                    if (self.direction == 'both' and self.lower_relay_id
                            and self.control.relay_state(
                                self.lower_relay_id) != 'off'):
                        self.control.relay_off(self.lower_relay_id)

                    if self.pid_type == 'relay':
                        # Ensure the relay 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_relay for a duration
                            self.logger.debug(
                                "Setpoint: {sp} Output: {cv} to relay "
                                "{id}".format(sp=self.set_point,
                                              cv=self.control_variable,
                                              id=self.raise_relay_id))
                            self.control.relay_on(
                                self.raise_relay_id,
                                duration=self.raise_seconds_on,
                                min_off=self.raise_min_off_duration)

                    elif self.pid_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.set_point,
                                cv=self.control_variable,
                                id=self.raise_relay_id,
                                dc=self.raise_duty_cycle))

                        # Activate pwm with calculated duty cycle
                        self.control.relay_on(self.raise_relay_id,
                                              duty_cycle=self.raise_duty_cycle)

                else:
                    if self.pid_type == 'relay':
                        self.control.relay_off(self.raise_relay_id)
                    elif self.pid_type == 'pwm':
                        self.control.relay_on(self.raise_relay_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_relay_id:

                if self.control_variable < 0:
                    # Turn off raise_relay if active, because we're now raising
                    if (self.direction == 'both' and self.raise_relay_id
                            and self.control.relay_state(
                                self.raise_relay_id) != 'off'):
                        self.control.relay_off(self.raise_relay_id)

                    if self.pid_type == 'relay':
                        # Ensure the relay 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 = abs(
                                float("{0:.2f}".format(self.control_variable)))

                        if self.lower_seconds_on > self.lower_min_duration:
                            # Activate lower_relay for a duration
                            self.logger.debug("Setpoint: {sp} Output: {cv} to "
                                              "relay {id}".format(
                                                  sp=self.set_point,
                                                  cv=self.control_variable,
                                                  id=self.lower_relay_id))
                            self.control.relay_on(
                                self.lower_relay_id,
                                duration=self.lower_seconds_on,
                                min_off=self.lower_min_off_duration)

                    elif self.pid_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.set_point,
                                cv=self.control_variable,
                                id=self.lower_relay_id,
                                dc=self.lower_duty_cycle))

                        # Turn back negative for proper logging
                        if self.control_variable < 0:
                            self.lower_duty_cycle = -self.lower_duty_cycle

                        # Activate pwm with calculated duty cycle
                        self.control.relay_on(self.lower_relay_id,
                                              duty_cycle=self.lower_duty_cycle)
                else:
                    if self.pid_type == 'relay':
                        self.control.relay_off(self.lower_relay_id)
                    elif self.pid_type == 'pwm':
                        self.control.relay_on(self.lower_relay_id,
                                              duty_cycle=0)

        else:
            if self.direction in ['raise', 'both'] and self.raise_relay_id:
                self.control.relay_off(self.raise_relay_id)
            if self.direction in ['lower', 'both'] and self.lower_relay_id:
                self.control.relay_off(self.lower_relay_id)

    def calculate_method_setpoint(self, method_id):
        method = db_retrieve_table_daemon(Method)

        method_key = method.filter(Method.id == method_id).first()

        method_data = db_retrieve_table_daemon(MethodData)
        method_data = method_data.filter(MethodData.method_id == method_id)

        method_data_all = method_data.filter(MethodData.relay_id == None).all()
        method_data_first = method_data.filter(
            MethodData.relay_id == None).first()

        now = datetime.datetime.now()

        # Calculate where the current time/date is within the time/date method
        if method_key.method_type == 'Date':
            for each_method in method_data_all:
                start_time = datetime.datetime.strptime(
                    each_method.time_start, '%Y-%m-%d %H:%M:%S')
                end_time = datetime.datetime.strptime(each_method.time_end,
                                                      '%Y-%m-%d %H:%M:%S')
                if start_time < now < end_time:
                    setpoint_start = each_method.setpoint_start
                    if each_method.setpoint_end:
                        setpoint_end = each_method.setpoint_end
                    else:
                        setpoint_end = each_method.setpoint_start

                    setpoint_diff = abs(setpoint_end - setpoint_start)
                    total_seconds = (end_time - start_time).total_seconds()
                    part_seconds = (now - start_time).total_seconds()
                    percent_total = part_seconds / total_seconds

                    if setpoint_start < setpoint_end:
                        new_setpoint = setpoint_start + (setpoint_diff *
                                                         percent_total)
                    else:
                        new_setpoint = setpoint_start - (setpoint_diff *
                                                         percent_total)

                    self.logger.debug("[Method] Start: {} End: {}".format(
                        start_time, end_time))
                    self.logger.debug("[Method] Start: {} End: {}".format(
                        setpoint_start, setpoint_end))
                    self.logger.debug(
                        "[Method] Total: {} Part total: {} ({}%)".format(
                            total_seconds, part_seconds, percent_total))
                    self.logger.debug(
                        "[Method] New Setpoint: {}".format(new_setpoint))
                    self.set_point = new_setpoint
                    return 0

        # Calculate where the current Hour:Minute:Seconds is within the Daily method
        elif method_key.method_type == 'Daily':
            daily_now = datetime.datetime.now().strftime('%H:%M:%S')
            daily_now = datetime.datetime.strptime(str(daily_now), '%H:%M:%S')
            for each_method in method_data_all:
                start_time = datetime.datetime.strptime(
                    each_method.time_start, '%H:%M:%S')
                end_time = datetime.datetime.strptime(each_method.time_end,
                                                      '%H:%M:%S')
                if start_time < daily_now < end_time:
                    setpoint_start = each_method.setpoint_start
                    if each_method.setpoint_end:
                        setpoint_end = each_method.setpoint_end
                    else:
                        setpoint_end = each_method.setpoint_start

                    setpoint_diff = abs(setpoint_end - setpoint_start)
                    total_seconds = (end_time - start_time).total_seconds()
                    part_seconds = (daily_now - start_time).total_seconds()
                    percent_total = part_seconds / total_seconds

                    if setpoint_start < setpoint_end:
                        new_setpoint = setpoint_start + (setpoint_diff *
                                                         percent_total)
                    else:
                        new_setpoint = setpoint_start - (setpoint_diff *
                                                         percent_total)

                    self.logger.debug("[Method] Start: {} End: {}".format(
                        start_time.strftime('%H:%M:%S'),
                        end_time.strftime('%H:%M:%S')))
                    self.logger.debug("[Method] Start: {} End: {}".format(
                        setpoint_start, setpoint_end))
                    self.logger.debug(
                        "[Method] Total: {} Part total: {} ({}%)".format(
                            total_seconds, part_seconds, percent_total))
                    self.logger.debug(
                        "[Method] New Setpoint: {}".format(new_setpoint))
                    self.set_point = new_setpoint
                    return 0

        # Calculate sine y-axis value from the x-axis (seconds of the day)
        elif method_key.method_type == 'DailySine':
            new_setpoint = sine_wave_y_out(method_data_first.amplitude,
                                           method_data_first.frequency,
                                           method_data_first.shift_angle,
                                           method_data_first.shift_y)
            self.set_point = new_setpoint
            return 0

        # Calculate Bezier curve y-axis value from the x-axis (seconds of the day)
        elif method_key.method_type == 'DailyBezier':
            new_setpoint = bezier_curve_y_out(
                method_data_first.shift_angle,
                (method_data_first.x0, method_data_first.y0),
                (method_data_first.x1, method_data_first.y1),
                (method_data_first.x2, method_data_first.y2),
                (method_data_first.x3, method_data_first.y3))
            self.set_point = new_setpoint
            return 0

        # Calculate the duration in the method based on self.method_start_time
        elif method_key.method_type == 'Duration' and self.method_start_time != 'Ended':
            seconds_from_start = (now - self.method_start_time).total_seconds()
            total_sec = 0
            previous_total_sec = 0
            for each_method in method_data_all:
                total_sec += each_method.duration_sec
                if previous_total_sec <= seconds_from_start < total_sec:
                    row_start_time = float(
                        self.method_start_time.strftime(
                            '%s')) + previous_total_sec
                    row_since_start_sec = (
                        now - (self.method_start_time + datetime.timedelta(
                            0, previous_total_sec))).total_seconds()
                    percent_row = row_since_start_sec / each_method.duration_sec

                    setpoint_start = each_method.setpoint_start
                    if each_method.setpoint_end:
                        setpoint_end = each_method.setpoint_end
                    else:
                        setpoint_end = each_method.setpoint_start
                    setpoint_diff = abs(setpoint_end - setpoint_start)
                    if setpoint_start < setpoint_end:
                        new_setpoint = setpoint_start + (setpoint_diff *
                                                         percent_row)
                    else:
                        new_setpoint = setpoint_start - (setpoint_diff *
                                                         percent_row)

                    self.logger.debug(
                        "[Method] Start: {} Seconds Since: {}".format(
                            self.method_start_time, seconds_from_start))
                    self.logger.debug("[Method] Start time of row: {}".format(
                        datetime.datetime.fromtimestamp(row_start_time)))
                    self.logger.debug(
                        "[Method] Sec since start of row: {}".format(
                            row_since_start_sec))
                    self.logger.debug(
                        "[Method] Percent of row: {}".format(percent_row))
                    self.logger.debug(
                        "[Method] New Setpoint: {}".format(new_setpoint))
                    self.set_point = new_setpoint
                    return 0
                previous_total_sec = total_sec

            # Duration method has ended, reset method_start_time locally and in DB
            if self.method_start_time:
                with session_scope(MYCODO_DB_PATH) as db_session:
                    mod_method = db_session.query(Method).filter(
                        Method.id == self.method_id).first()
                    mod_method.method_start_time = 'Ended'
                    db_session.commit()
                self.method_start_time = 'Ended'

        # Setpoint not needing to be calculated, use default setpoint
        self.set_point = self.default_set_point

    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 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, set_point):
        """ Initilize the setpoint of PID """
        self.set_point = set_point
        self.integrator = 0
        self.derivator = 0

    def set_integrator(self, integrator):
        """ Set the integrator of the controller """
        self.integrator = integrator

    def set_derivator(self, derivator):
        """ Set the derivator of the controller """
        self.derivator = derivator

    def set_kp(self, p):
        """ Set Kp gain of the controller """
        self.Kp = p

    def set_ki(self, i):
        """ Set Ki gain of the controller """
        self.Ki = i

    def set_kd(self, d):
        """ Set Kd gain of the controller """
        self.Kd = d

    def get_setpoint(self):
        return self.set_point

    def get_error(self):
        return self.error

    def get_integrator(self):
        return self.integrator

    def get_derivator(self):
        return self.derivator

    def is_running(self):
        return self.running

    def stop_controller(self):
        self.thread_shutdown_timer = timeit.default_timer()
        self.running = False
        # Unset method start time
        if self.method_id:
            with session_scope(MYCODO_DB_PATH) as db_session:
                mod_method = db_session.query(Method).filter(
                    Method.id == self.method_id).first()
                mod_method.method_start_time = 'Ended'
                db_session.commit()
예제 #7
0
class PIDController(threading.Thread):
    """
    Class to operate discrete PID controller

    """

    def __init__(self, ready, logger, pid_id):
        threading.Thread.__init__(self)

        self.thread_startup_timer = timeit.default_timer()
        self.thread_shutdown_timer = 0
        self.ready = ready
        self.logger = logger
        self.pid_id = pid_id
        self.control = DaemonControl()

        with session_scope(MYCODO_DB_PATH) as new_session:
            pid = new_session.query(PID).filter(PID.id == self.pid_id).first()
            self.sensor_id = pid.sensor_id
            self.measure_type = pid.measure_type
            self.method_id = pid.method_id
            self.direction = pid.direction
            self.raise_relay_id = pid.raise_relay_id
            self.raise_min_duration = pid.raise_min_duration
            self.raise_max_duration = pid.raise_max_duration
            self.lower_relay_id = pid.lower_relay_id
            self.lower_min_duration = pid.lower_min_duration
            self.lower_max_duration = pid.lower_max_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.measure_interval = pid.period
            self.default_set_point = pid.setpoint
            self.set_point = pid.setpoint
            sensor = new_session.query(Sensor).filter(Sensor.id == self.sensor_id).first()
            self.sensor_duration = sensor.period

        self.Derivator = 0
        self.Integrator = 0
        self.error = 0.0
        self.P_value = None
        self.I_value = None
        self.D_value = None
        self.raise_seconds_on = 0
        self.timer = t.time()+self.measure_interval

        # Check if a method is set for this PID
        if self.method_id:
            with session_scope(MYCODO_DB_PATH) as new_session:
                method = new_session.query(Method)
                method = method.filter(Method.method_id == self.method_id)
                method = method.filter(Method.method_order == 0).first()
                self.method_type = method.method_type
                self.method_start_time = method.start_time

            if self.method_type == 'Duration':
                if self.method_start_time == 'Ended':
                    # Method has ended and hasn't been instructed to begin again
                    pass
                elif self.method_start_time == 'Ready' or self.method_start_time == None:
                    # Method has been instructed to begin
                    with session_scope(MYCODO_DB_PATH) as db_session:
                        mod_method = db_session.query(Method)
                        mod_method = mod_method.filter(Method.method_id == self.method_id)
                        mod_method = mod_method.filter(Method.method_order == 0).first()
                        mod_method.start_time = datetime.datetime.now()
                        self.method_start_time = mod_method.start_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(
                        self.method_start_time, '%Y-%m-%d %H:%M:%S.%f')
                    self.logger.warning("[PID {}] Resuming method {} started at {}".format(
                        self.pid_id, self.method_id, self.method_start_time))


    def run(self):
        try:
            self.running = True
            self.logger.info("[PID {}] Activated in {:.1f} ms".format(
                self.pid_id,
                (timeit.default_timer()-self.thread_startup_timer)*1000))
            self.ready.set()

            while (self.running):
                if t.time() > self.timer:
                    self.timer = self.timer+self.measure_interval
                    self.get_last_measurement()
                    self.manipulate_relays()
                t.sleep(0.1)

            if self.raise_relay_id:
                self.control.relay_off(self.raise_relay_id)
            if self.lower_relay_id:
                self.control.relay_off(self.lower_relay_id)

            self.running = False
            self.logger.info("[PID {}] Deactivated in {:.1f} ms".format(
                self.pid_id,
                (timeit.default_timer()-self.thread_shutdown_timer)*1000))
        except Exception as except_msg:
                self.logger.exception("[PID {}] Error: {}".format(self.pid_id,
                                                                except_msg))


    def update(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 sensor)
        :type current_value: float
        """
        self.error = self.set_point - 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.measure_interval is not None:  
        #     if self.Integrator * self.Ki > self.measure_interval:
        #         self.Integrator = self.measure_interval / self.Ki
        #     elif self.Integrator * self.Ki < -self.measure_interval:
        #         self.Integrator = -self.measure_interval / self.Ki

        self.I_value = self.Integrator * self.Ki

        # 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 = self.P_value + self.I_value + self.D_value

        return PID


    def get_last_measurement(self):
        """
        Retrieve the latest sensor measurement from InfluxDB

        :rtype: None
        """
        self.last_measurement_success = False
        # Get latest measurement (from within the past minute) from influxdb
        try:
            if self.sensor_duration/60 < 1:
                duration = 1
            else:
                duration = self.sensor_duration/60*1.5
            self.last_measurement = read_last_influxdb(
                INFLUXDB_HOST,
                INFLUXDB_PORT,
                INFLUXDB_USER,
                INFLUXDB_PASSWORD,
                INFLUXDB_DATABASE,
                self.sensor_id,
                self.measure_type,
                duration)
            if self.last_measurement:
                measurement_list = list(self.last_measurement.get_points(
                    measurement=self.measure_type))
                self.last_time = measurement_list[0]['time']
                self.last_measurement = measurement_list[0]['value']
                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("[PID {}] Latest {}: {} @ {}".format(
                    self.pid_id, self.measure_type,
                    self.last_measurement, local_timestamp))
                self.last_measurement_success = True
            else:
                self.logger.warning("[PID {}] No data returned "
                                    "from influxdb".format(self.pid_id))
        except Exception as except_msg:
            self.logger.exception("[PID {}] Failed to read "
                                "measurement from the influxdb "
                                "database: {}".format(self.pid_id,
                                                      except_msg))


    def manipulate_relays(self):
        """
        Activate a relay based on PID output (control variable) and whether
        the manipulation directive is to raise, lower, or both.

        :rtype: None
        """
        # If there was a measurement able to be retrieved from
        # influxdb database that was entered within the past minute
        if self.last_measurement_success:

            # Update setpoint if a method is selected
            if self.method_id != '':
                self.calculate_method_setpoint(self.method_id)

            self.addSetpointInfluxdb(self.pid_id, self.set_point)

            # Update PID and get control variable
            self.control_variable = self.update(self.last_measurement)

            #
            # PID control variable positive to raise environmental condition
            #
            if self.direction in ['raise', 'both'] and self.raise_relay_id:
                if self.control_variable > 0:
                    # Ensure the relay 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))

                    # Turn off lower_relay if active, because we're now raising
                    if self.lower_relay_id:
                        with session_scope(MYCODO_DB_PATH) as new_session:
                            relay = new_session.query(Relay).filter(
                                Relay.id == self.lower_relay_id).first()
                            if relay.is_on():
                                self.control.relay_off(self.lower_relay_id)

                    if self.raise_seconds_on > self.raise_min_duration:
                        # Activate raise_relay for a duration
                        self.logger.debug("[PID {}] Setpoint: {} "
                            "Output: {} to relay {}".format(
                                self.pid_id,
                                self.set_point,
                                self.control_variable,
                                self.raise_relay_id))
                        self.control.relay_on(self.raise_relay_id,
                                         self.raise_seconds_on)
                else:
                    self.control.relay_off(self.raise_relay_id)

            #
            # PID control variable negative to lower environmental condition
            #
            if self.direction in ['lower', 'both'] and self.lower_relay_id:
                if self.control_variable < 0:
                    # Ensure the relay 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 = abs(float("{0:.2f}".format(self.control_variable)))

                    # Turn off raise_relay if active, because we're now lowering
                    if self.raise_relay_id:
                        with session_scope(MYCODO_DB_PATH) as new_session:
                            relay = new_session.query(Relay).filter(
                                Relay.id == self.raise_relay_id).first()
                            if relay.is_on():
                                self.control.relay_off(self.raise_relay_id)

                    if self.lower_seconds_on > self.lower_min_duration:
                        # Activate lower_relay for a duration
                        self.logger.debug("[PID {}] Setpoint: {} "
                            "Output: {} to relay {}".format(
                                self.pid_id,
                                self.set_point,
                                self.control_variable,
                                self.lower_relay_id))
                        self.control.relay_on(self.lower_relay_id,
                                         self.lower_seconds_on)
                else:
                    self.control.relay_off(self.lower_relay_id)

        else:
            if self.direction in ['raise', 'both'] and self.raise_relay_id:
                self.control.relay_off(self.raise_relay_id)
            if self.direction in ['lower', 'both'] and self.lower_relay_id:
                self.control.relay_off(self.lower_relay_id)


    def now_in_range(self, start_time, end_time):
        """
        Check if the current time is between start_time and end_time

        :return: 1 is within range, 0 if not within range
        :rtype: int
        """
        start_hour = int(start_time.split(":")[0])
        start_min = int(start_time.split(":")[1])
        end_hour = int(end_time.split(":")[0])
        end_min = int(end_time.split(":")[1])
        now_time = datetime.datetime.now().time()
        now_time = now_time.replace(second=0, microsecond=0)
        if ((start_hour < end_hour) or
                (start_hour == end_hour and start_min < end_min)):
            if now_time >= datetime.time(start_hour, start_min) and now_time <= datetime.time(end_hour, end_min):
                return 1  # Yes now within range
        else:
            if now_time >= datetime.time(start_hour, start_min) or now_time <= datetime.time(end_hour, end_min):
                return 1  # Yes now within range
        return 0 # No now not within range


    def calculate_method_setpoint(self, method_id):
        with session_scope(MYCODO_DB_PATH) as new_session:
            method = new_session.query(Method)
            new_session.expunge_all()
            new_session.close()

        method_key = method.filter(Method.method_id == method_id)
        method_key = method_key.filter(Method.method_order == 0).first()

        method = method.filter(Method.method_id == method_id)
        method = method.filter(Method.relay_id == None)
        method = method.filter(Method.method_order > 0)
        method = method.order_by(Method.method_order.asc()).all()

        now = datetime.datetime.now()

        # Calculate where the current time/date is within the time/date method
        if method_key.method_type == 'Date':
            for each_method in method:
                start_time = datetime.datetime.strptime(each_method.start_time, '%Y-%m-%d %H:%M:%S')
                end_time = end_time = datetime.datetime.strptime(each_method.end_time, '%Y-%m-%d %H:%M:%S')
                if start_time < now < end_time:
                    start_setpoint = each_method.start_setpoint
                    if each_method.end_setpoint:
                        end_setpoint = each_method.end_setpoint
                    else:
                        end_setpoint = each_method.start_setpoint

                    setpoint_diff = abs(end_setpoint-start_setpoint)
                    total_seconds = (end_time-start_time).total_seconds()
                    part_seconds = (now-start_time).total_seconds()
                    percent_total = part_seconds/total_seconds

                    if start_setpoint < end_setpoint:
                        new_setpoint = start_setpoint+(setpoint_diff*percent_total)
                    else:
                        new_setpoint = start_setpoint-(setpoint_diff*percent_total)

                    self.logger.debug("[Method] Start: {} End: {}".format(
                        start_time, end_time))
                    self.logger.debug("[Method] Start: {} End: {}".format(
                        start_setpoint, end_setpoint))
                    self.logger.debug("[Method] Total: {} Part total: {} ({}%)".format(
                        total_seconds, part_seconds, percent_total))
                    self.logger.debug("[Method] New Setpoint: {}".format(
                        new_setpoint))
                    self.set_point = new_setpoint
                    return 0

        # Calculate where the current Hour:Minute:Seconds is within the Daily method
        elif method_key.method_type == 'Daily':
            daily_now = datetime.datetime.now().strftime('%H:%M:%S')
            daily_now = datetime.datetime.strptime(str(daily_now), '%H:%M:%S')
            for each_method in method:
                start_time = datetime.datetime.strptime(each_method.start_time, '%H:%M:%S')
                end_time = end_time = datetime.datetime.strptime(each_method.end_time, '%H:%M:%S')
                if start_time < daily_now < end_time:
                    start_setpoint = each_method.start_setpoint
                    if each_method.end_setpoint:
                        end_setpoint = each_method.end_setpoint
                    else:
                        end_setpoint = each_method.start_setpoint

                    setpoint_diff = abs(end_setpoint-start_setpoint)
                    total_seconds = (end_time-start_time).total_seconds()
                    part_seconds = (daily_now-start_time).total_seconds()
                    percent_total = part_seconds/total_seconds

                    if start_setpoint < end_setpoint:
                        new_setpoint = start_setpoint+(setpoint_diff*percent_total)
                    else:
                        new_setpoint = start_setpoint-(setpoint_diff*percent_total)

                    self.logger.debug("[Method] Start: {} End: {}".format(
                        start_time.strftime('%H:%M:%S'), end_time.strftime('%H:%M:%S')))
                    self.logger.debug("[Method] Start: {} End: {}".format(
                        start_setpoint, end_setpoint))
                    self.logger.debug("[Method] Total: {} Part total: {} ({}%)".format(
                        total_seconds, part_seconds, percent_total))
                    self.logger.debug("[Method] New Setpoint: {}".format(
                        new_setpoint))
                    self.set_point = new_setpoint
                    return 0

        # Calculate sine y-axis value from the x-axis (seconds of the day)
        elif method_key.method_type == 'DailySine':
            new_setpoint = sine_wave_y_out(method_key.amplitude,
                                           method_key.frequency,
                                           method_key.shift_angle,
                                           method_key.shift_y)
            self.set_point = new_setpoint
            return 0

        # Calculate Bezier curve y-axis value from the x-axis (seconds of the day)
        elif method_key.method_type == 'DailyBezier':
            new_setpoint = bezier_curve_y_out(method_key.shift_angle,
                                              (method_key.x0, method_key.y0),
                                              (method_key.x1, method_key.y1),
                                              (method_key.x2, method_key.y2),
                                              (method_key.x3, method_key.y3))
            self.set_point = new_setpoint
            return 0

        # Calculate the duration in the method based on self.method_start_time
        elif method_key.method_type == 'Duration' and self.method_start_time != 'Ended':
            seconds_from_start = (now-self.method_start_time).total_seconds()
            total_sec = 0
            previous_total_sec = 0
            for each_method in method:
                total_sec += each_method.duration_sec
                if previous_total_sec <= seconds_from_start < total_sec:
                    row_start_time = float(self.method_start_time.strftime('%s'))+previous_total_sec
                    row_since_start_sec = (now-(self.method_start_time+datetime.timedelta(0, previous_total_sec))).total_seconds()
                    percent_row = row_since_start_sec/each_method.duration_sec

                    start_setpoint = each_method.start_setpoint
                    if each_method.end_setpoint:
                        end_setpoint = each_method.end_setpoint
                    else:
                        end_setpoint = each_method.start_setpoint
                    setpoint_diff = abs(end_setpoint-start_setpoint)
                    if start_setpoint < end_setpoint:
                        new_setpoint = start_setpoint+(setpoint_diff*percent_row)
                    else:
                        new_setpoint = start_setpoint-(setpoint_diff*percent_row)
                    
                    self.logger.debug("[Method] Start: {} Seconds Since: {}".format(
                        self.method_start_time, seconds_from_start))
                    self.logger.debug("[Method] Start time of row: {}".format(
                        datetime.datetime.fromtimestamp(row_start_time)))
                    self.logger.debug("[Method] Sec since start of row: {}".format(
                        row_since_start_sec))
                    self.logger.debug("[Method] Percent of row: {}".format(
                        percent_row))
                    self.logger.debug("[Method] New Setpoint: {}".format(
                        new_setpoint))
                    self.set_point = new_setpoint
                    return 0
                previous_total_sec = total_sec

            # Duration method has ended, reset start_time locally and in DB
            if self.method_start_time:
                with session_scope(MYCODO_DB_PATH) as db_session:
                    mod_method = db_session.query(Method).filter(
                        Method.method_id == self.method_id)
                    mod_method = mod_method.filter(Method.method_order == 0).first()
                    mod_method.start_time = 'Ended'
                    db_session.commit()
                self.method_start_time = 'Ended'

        # Setpoint not needing to be calculated, use default setpoint
        self.set_point = self.default_set_point


    def addSetpointInfluxdb(self, pid_id, setpoint):
        """
        Add a setpoint entry to InfluxDB

        :rtype: None
        """
        write_db = threading.Thread(
            target=write_influxdb_value,
            args=(self.logger, INFLUXDB_HOST,
                  INFLUXDB_PORT, INFLUXDB_USER,
                  INFLUXDB_PASSWORD, INFLUXDB_DATABASE,
                  'pid', pid_id, 'setpoint', setpoint,))
        write_db.start()


    def setPoint(self, set_point):
        """Initilize the setpoint of PID"""
        self.set_point = set_point
        self.Integrator = 0
        self.Derivator = 0


    def setIntegrator(self, Integrator):
        """Set the Integrator of the controller"""
        self.Integrator = Integrator


    def setDerivator(self, Derivator):
        """Set the Derivator of the controller"""
        self.Derivator = Derivator


    def setKp(self, P):
        """Set Kp gain of the controller"""
        self.Kp = P


    def setKi(self, I):
        """Set Ki gain of the controller"""
        self.Ki = I


    def setKd(self, D):
        """Set Kd gain of the controller"""
        self.Kd = D


    def getPoint(self):
        return self.set_point


    def getError(self):
        return self.error


    def getIntegrator(self):
        return self.Integrator


    def getDerivator(self):
        return self.Derivator


    def isRunning(self):
        return self.running


    def stopController(self):
        self.thread_shutdown_timer = timeit.default_timer()
        self.running = False
        # Unset method start time
        if self.method_id:
            with session_scope(MYCODO_DB_PATH) as db_session:
                mod_method = db_session.query(Method)
                mod_method = mod_method.filter(Method.method_id == self.method_id)
                mod_method = mod_method.filter(Method.method_order == 0).first()
                mod_method.start_time = 'Ended'
                db_session.commit()