class Lane_Follower:
    '''
    Control the robut to follow simple lanes.
    '''
    def __init__(self, node_name="lane_follower"):
        '''
        Initialize the lane follower.

        Parameters:
            node_name: The name of the lane follower node
        Returns:
            N/A
        '''
        rospy.init_node(node_name)

        #TOPICS
        lane_tracking_topic = rospy.get_namespace() + "lane_tracking"
        odom_topic = rospy.get_namespace() + "raw/odometry"
        desired_movement_topic = rospy.get_namespace() + "desired/movement"

        #Lane tracking data from subscriber. Data comes from the lane detection.
        #node.
        rospy.Subscriber(lane_tracking_topic, Float32MultiArray,
                         self._lane_tracking_callback)

        #Subscriber to the odometry data
        rospy.Subscriber(odom_topic, Odometry, self._odom_data_callback)

        #Publisher to the movement controller api
        self.desired_movement_pub = rospy.Publisher(desired_movement_topic,
                                                    Float32MultiArray,
                                                    queue_size=1)

        #Initialize the pid controller for tracking the lane
        #TODO: Add parameters for lane tracking pid to config file
        param_path = '/vision_params/lane_follower/pid/'

        #Get the pid controller parameters from the parameter server
        k_p = rospy.get_param(param_path + 'k_p')
        k_i = rospy.get_param(param_path + 'k_i')
        k_d = rospy.get_param(param_path + 'k_d')
        integral_min = rospy.get_param(param_path + 'integral_min')
        integral_max = rospy.get_param(param_path + 'integral_max')
        max_control_effort = rospy.get_param(param_path + 'max_control_effort')
        min_control_effort = rospy.get_param(param_path + 'min_control_effort')

        self.lane_tracking_pid_controller = PID_Controller(
            k_p=k_p,
            k_i=k_i,
            k_d=k_d,
            integral_min=integral_min,
            integral_max=integral_max,
            max_control_effort=max_control_effort,
            min_control_effort=min_control_effort)

        #Service the enable or disable line following.
        #This can be called to enable or disable the control of the robot based
        #on line following
        rospy.Service('enable_line_following', bool_call,
                      self._line_following_state)
        self.rate = rospy.Rate(10)

        #Initialize variables
        self.lane_detected = False
        self.camera_center_point = 0.0
        self.lane_center_point = 0.0

        self.lane_follow_enabled = True

        self.measured_velocity = 0.0
        self.measured_orientation = 0.0

        #Base velocity
        self.desired_velocity = 0.5

    def _line_following_state(self, req):
        '''
        The service callback to enable or disable line following.

        Parameters:
            req: The request message
        Returns:
            N/A
        '''
        self.lane_follow_enabled = req.val

        #Set the robots velocity to zero and hold the current orientation
        self.desired_movement_msg.data = [0.0, self.measured_orientation]
        self.desired_movement_pub.publish(self.desired_movement_msg)
        return bool_callResponse()

    def _lane_tracking_callback(self, lane_tracking_msg):
        '''
        Receive the lane tracking data msg. Unpack it

        Parameters:
            lane_tracking_msg. The lane tracking data message from the lane detection
                        node.
                        Format: [lane_detected_bool, set_point, measured_point]
        Returns:
            N/A
        '''
        ret = lane_tracking_msg.data[0]
        if (ret == 1.0):
            self.lane_detected = True

        else:
            self.lane_detected = False

        self.camera_center_point = lane_tracking_msg.data[1]
        self.lane_center_point = lane_tracking_msg.data[2]

    def _odom_data_callback(self, odom_msg):
        '''
        Callback function for the measured odometry data estimated.
        Unpack the data

        Parameters:
            measured_odom_msg: Odometry data message type nav_msgs/Odometry
        Returns:
            N/A
        '''
        self.measured_odom = odom_msg
        self.measured_velocity = self.measured_odom.twist.twist.linear.x

        #Orientation is the direction the robot faces
        #Convert from quaternion to euler
        quaternion = [
            self.measured_odom.pose.pose.orientation.x,
            self.measured_odom.pose.pose.orientation.y,
            self.measured_odom.pose.pose.orientation.z,
            self.measured_odom.pose.pose.orientation.w
        ]
        [roll, pitch, yaw] = euler_from_quaternion(quaternion)

        self.measured_orientation = yaw

    def run(self):
        '''
        Main loop for running the lane following.

        Parameters:
            N/A
        Returns:
            N/A
        '''
        self.desired_movement_msg = Float32MultiArray()
        try:

            while not rospy.is_shutdown():

                if (self.lane_follow_enabled):
                    #print(self.camera_center_point, self.lane_center_point)
                    #Get the pid control effort the determine how much to steer
                    control_effort, error = self.lane_tracking_pid_controller.update(\
                                    self.camera_center_point, self.lane_center_point)

                    #Translate the control effort into a yaw angle for the robot to
                    #face
                    adjustment_angle = self.measured_orientation + control_effort
                    if (adjustment_angle < -1 * math.pi):
                        self.desired_orientation = 2.0 * math.pi + adjustment_angle

                    elif (adjustment_angle > math.pi):
                        self.desired_orientation = adjustment_angle - 2.0 * math.pi

                    else:
                        self.desired_orientation = adjustment_angle

                    #Write the velocity and orientation to the robot.
                    velocity = self.desired_velocity - abs(0.005 * error)
                    self.desired_movement_msg.data = [
                        velocity, self.desired_orientation
                    ]
                    self.desired_movement_pub.publish(
                        self.desired_movement_msg)
                else:
                    self.desired_movement_msg.data = [
                        0.0, self.desired_orientation
                    ]

                self.rate.sleep()

        except rospy.ROSInterruptException:
            pass
Exemplo n.º 2
0
class Movement_Controller:
    """
    Control the robot using PID controllers for linear velocity and
    direction orientation.
    """
    def __init__(self, node_name="movement_controller"):
        '''
        Initialize the movement controller as a ros node.

        Parameters:
            N/A
        Returns:
            N/A
        '''
        self.node_name = node_name
        rospy.init_node(node_name)

        #TOPICS - This format of name space is given for ability to simulate multiple
        #by providing different names

        measured_odom_topic = rospy.get_namespace() + "raw/odometry"
        pwm_topic = rospy.get_namespace() + "pwm"
        desired_movement_topic = rospy.get_namespace() + "desired/movement"

        #Initialize subscriber for raw odometry
        rospy.Subscriber(measured_odom_topic, Odometry,
                         self._odom_data_callback)

        #Initialize subscriber for desired_odometry
        rospy.Subscriber(desired_movement_topic, Float32MultiArray,
                         self._desired_movement_callback)

        #Initialize PWM controllers
        self._initialize_pid_controlers()

        self.pwm_pub = rospy.Publisher(pwm_topic,
                                       Int16MultiArray,
                                       queue_size=10)
        self.pwm_msg = Int16MultiArray()

        self.pid_timer = rospy.Rate(100)  #100Hz

        #Initialize publisher for writing PWM
        self.measured_velocity = 0.0
        self.desired_velocity = 0.0
        self.measured_orientation = 0.0
        self.desired_orientation = 0.0
        self.velocity_control = 0.0
        self.steering_control = 0.0

    def _initialize_pid_controlers(self):
        '''
        Initialize communication with the pid controllers.

        Parameters:
            N/A
        Returns:
            N/A
        '''

        pid_params = rospy.get_param("/pid")
        velocity_pid_params = pid_params['velocity']
        steering_pid_params = pid_params['steering']

        velocity_k_p = velocity_pid_params['k_p']
        velocity_k_i = velocity_pid_params['k_i']
        velocity_k_d = velocity_pid_params['k_d']

        self.velocity_pid_controller = PID_Controller(k_p=velocity_k_p,
                                                      k_i=velocity_k_i,
                                                      k_d=velocity_k_d,
                                                      integral_min=-10,
                                                      integral_max=10,
                                                      max_control_effort=255,
                                                      min_control_effort=-255)

        steering_k_p = steering_pid_params['k_p']
        steering_k_i = steering_pid_params['k_i']
        steering_k_d = steering_pid_params['k_d']
        self.steering_pid_controller = PID_Controller(k_p=steering_k_p,
                                                      k_i=steering_k_i,
                                                      k_d=steering_k_d,
                                                      max_control_effort=255,
                                                      min_control_effort=-255,
                                                      integral_min=-10,
                                                      integral_max=10,
                                                      angle_error=True)

        #Initialize the service for updating the pid controller gains
        rospy.Service('update_pid_gains', pid_gains,
                      self.handle_pid_gains_update)

        return

    def handle_pid_gains_update(self, req):
        '''
        Handler function for when a PID gain upate is requested. This will update
        the PIDS in the parameter server.

        Parameters:
            reg: The request message containing the desired gains
        Returns:
            N/A
        '''
        self.velocity_pid_controller.set_gains(req.velocity_k_p,
                                               req.velocity_k_i,
                                               req.velocity_k_d)
        self.steering_pid_controller.set_gains(req.steering_k_p,
                                               req.steering_k_i,
                                               req.steering_k_d)

        #Update the parameter server on the correct pid values
        rospy.set_param(
            "/pid/velocity", {
                'k_p': req.velocity_k_p,
                'k_i': req.velocity_k_i,
                'k_d': req.velocity_k_d
            })
        rospy.set_param(
            "/pid/steering", {
                'k_p': req.steering_k_p,
                'k_i': req.steering_k_i,
                'k_d': req.steering_k_d
            })

        return pid_gainsResponse()

    def _odom_data_callback(self, odom_msg):
        '''
        Callback function for the measured odometry data estimated.
        Unpack the data

        Parameters:
            measured_odom_msg: Odometry data message type nav_msgs/Odometry
        Returns:
            N/A
        '''
        self.measured_odom = odom_msg
        self.measured_velocity = self.measured_odom.twist.twist.linear.x

        #Orientation is the direction the robot faces
        #Convert from quaternion to euler
        quaternion = [
            self.measured_odom.pose.pose.orientation.x,
            self.measured_odom.pose.pose.orientation.y,
            self.measured_odom.pose.pose.orientation.z,
            self.measured_odom.pose.pose.orientation.w
        ]
        [roll, pitch, yaw] = euler_from_quaternion(quaternion)

        self.measured_orientation = yaw

    def _desired_movement_callback(self, desired_movement_msg):
        '''
        Callback function for the desired movement for the robot to hold. The
        message contains the desired velocity and orientation.

        Parameters:
            desired_movement_msg: The desired velocity (m/s) and orientation (rad/s).
                                  The message is of type std_msgs/Float32MultiArray.
                                  [desired_velocity, desired_orientation]
        Returns:
            N/A
        '''
        self.desired_velocity = desired_movement_msg.data[0]

        #Orientation is the direction the robot faces
        self.desired_orientation = desired_movement_msg.data[1]

    def map_control_efforts_to_pwms(self, vel_control_effort,
                                    steering_control_effort):
        '''
        Maps the control efforts for velocity and steering to individual motor
        PWMS.

        Parameters:
            vel_control_effort: Velocity Control effort output from PID.
            steering_control_effort: sterring control effor output from PID
        Returns:
            pwms: [pwm_1, pwm_2, pwm_3, pwm_4]
        '''
        vel_control = vel_control_effort
        steering_control = steering_control_effort

        pwm_1 = vel_control - steering_control
        pwm_2 = vel_control + steering_control
        pwm_3 = vel_control + steering_control
        pwm_4 = vel_control - steering_control

        pwms = [pwm_1, pwm_2, pwm_3, pwm_4]
        for i in range(4):
            if (pwms[i] < -255):
                pwms[i] = -255
            elif (pwms[i] > 255):
                pwms[i] = 255
        return pwms

    def run(self):
        '''
        Run the main loop of the movement controller. Receive desired and
        measured odometry data and control.

        Parameters:
            N/A
        Returns:
            N/A
        '''
        #Publish errror for debug
        error_pub = rospy.Publisher('pid_errors',
                                    Float32MultiArray,
                                    queue_size=1)
        error_msg = Float32MultiArray()

        try:
            while not rospy.is_shutdown():

                #Take the control effort output from PID controller and map to
                #PID's of individual motor
                self.velocity_control, velocity_error = self.velocity_pid_controller.update(
                    self.desired_velocity, self.measured_velocity)
                self.steering_control, steering_error = self.steering_pid_controller.update(
                    self.desired_orientation, self.measured_orientation)
                error_msg.data = [velocity_error, steering_error]
                error_pub.publish(error_msg)

                motor_pwms = self.map_control_efforts_to_pwms(
                    self.velocity_control, self.steering_control)

                self.pwm_msg.data = motor_pwms
                self.pwm_pub.publish(self.pwm_msg)

                #100Hz
                self.pid_timer.sleep()

        except rospy.ROSInterruptException:
            pass
class TBC_Controller:
    def __init__(self, PCR_Machine, Peltier, start_time=0, update_period=0.05, volume=10):
        self.pcr = PCR_Machine
        self.peltier = Peltier
        self.time = self.checkpoint = self.start_time = start_time              
        self.period = update_period
        self.volume = volume
        self.set_point = self.pcr.sample_temp
        self.stage = "Hold"
        self.max_block_temp = 109
        self.max_ramp_dist = 35
        self.unachievable = 10
        self.smpWinInRampUpFlag = False
        self.smpWinInRampDownFlag = False
        self.init_pid()        
        self.load_tuning_params()
        self.pid.load(self.pid_const, "Hold")
        self.max_up_ramp = self.calc_poly_eqn(self.upRrEqn, self.volume)
        self.max_down_ramp = self.calc_poly_eqn(self.downRrEqn, self.volume)

    def reset(self, set_point=60):
        self.pid.reset()
        self.start_time = self.time = 0
        self.time_elapsed = 0
        self.set_point = set_point
        self.smpWinInRampUpFlag = False
        self.smpWinInRampDownFlag = False        
    
    def calc_poly_eqn(self, eqn, vol):
        return sum([vol**deg * coeff for deg, coeff in enumerate(eqn)])
    
    def init_pid(self):
        self.pid = PID_Controller()
        self.pid.dt = self.period / 60 # dt is in minute
        self.pid2 = PID_Controller()
        self.pid2.dt = self.period / 60 # dt is in minute

    def load_tuning_params(self):
        self.blockMCP = 10.962
        self.upRrEqn = [
            3.95293071,
           -0.03074861,
            0.00008966,
            0.0
        ]
        self.downRrEqn = [
            3.03500389,
           -0.02403729,
            0.00008891,
            0.0
        ]
        self.pid_const = {}
        self.pid_const["Ramp Up"        ] = {
            "P"  : 2.5, 
            "I"  : 0.1, 
            "D"  : 0.0001, 
            "KI" : 10, 
            "KD" : 20
        }
        self.pid_const["Overshoot Over" ] = {
            "P"  : 6, 
            "I"  : 0.1, 
            "D"  : 0.0, 
            "KI" : 2, 
            "KD" : 5
        }
        self.pid_const["Hold Over"      ] = {
            "P"  : 2, 
            "I"  : 0.02, 
            "D"  : 0.01, 
            "KI" : 5, 
            "KD" : 10
        }
        self.pid_const["Land Over"      ] = {
            "P"  : 7, 
            "I"  : 0.05, 
            "D"  : 0.00, 
            "KI" : 2, 
            "KD" : 5
        }
        self.pid_const["Hold"           ] = {
            "P"  : 0.7, 
            "I"  : 0.02, 
            "D"  : 0.01, 
            "KI" : 5, 
            "KD" : 10
        }
        self.pid_const["Ramp Down"      ] = {
            "P"  : 2.5, 
            "I"  : 0.05, 
            "D"  : 0.0001, 
            "KI" : 10, 
            "KD" : 20
        }
        self.pid_const["Overshoot Under"] = {
            "P"  : 3, 
            "I"  : 0.1, 
            "D"  : 0.00, 
            "KI" : 2, 
            "KD" : 5
        }
        self.pid_const["Hold Under"     ] = {
            "P"  : 1.2, 
            "I"  : 0.02, 
            "D"  : 0.01, 
            "KI" : 5, 
            "KD" : 10
        }
        self.pid_const["Land Under"     ] = {
            "P"  : 7, 
            "I"  : 0.1, 
            "D"  : 0.00, 
            "KI" : 2, 
            "KD" : 5
        }
        self.slow_upramp_qpid = -15
        self.block_slow_rate = 0.5
        self.sample_slow_rate = 0.0226
        self.rampup_minP = 3
        self.rampdown_minP = 3
        self.smoothRegionOverRR = 1.6
        self.smoothRegionUnderRR = 1.5
        self.smoothRegionOverWin = 1.5
        self.smoothRegionUnderWin = 1.75

        if self.volume <= 5:
            pass
        elif self.volume <= 10:
            self.maxHeatBlkOS       = 2.8
            self.maxCoolBlkOS       = 3.4
            self.maxHeatSmpWin      = 2
            self.maxCoolSmpWin      = 2
            self.qMaxHoldPid        = 40
            self.qMaxRampPid        = 150
            self.maxHeatIset        = 6.5
            self.maxCoolIset        = 6.5
            self.maxHeatBlkWin      = 4
            self.maxCoolBlkWin      = 3
            self.qHeatLoss          = 10
            self.heat_brake_const   = 1.2
            self.heatSpCtrlActivRR  = 0.2
            self.heatSpCtrlActivSP  = 0.1
            self.cool_brake_const   = 1.5
            self.coolSpCtrlActivRR  = 0.3
            self.coolSpCtrlActivSP  =-0.1
        elif self.volume <= 30:
            pass
        elif self.volume <= 50:
            pass
        else:
            pass


    def update_feedback(self):
        # update pid process variable (PV)
        if self.stage == "Ramp Up" or self.stage == "Ramp Down":
            self.pid.PV = self.pcr.sample_rate
        elif self.stage == "Overshoot Over" or self.stage == "Overshoot Under":
            if self.smpWinInRampDownFlag or self.smpWinInRampUpFlag:
                self.pid.PV = self.pcr.sample_rate
            else:
                self.pid.PV = self.pcr.block_rate
            self.pid2.PV = self.pcr.block_temp * 0.5
        elif self.stage == "Land Over" or self.stage == "Land Under":
            self.pid.PV = self.pcr.block_rate
        else:
            self.pid.PV = self.pcr.block_temp * 0.5
        self.time_elapsed = self.time - self.start_time        

    def prepare_overshoot_over(self):
        self.pid.reset()
        self.pid.load(self.pid_const, "Overshoot Over")

        self.pid2.reset()
        self.pid2.load(self.pid_const, "Hold Over")        
        if self.pcr.block_temp >= self.max_block_temp or self.set_point + self.calcHeatBlkOS >= self.max_block_temp:
            self.pid2.SP = self.max_block_temp * 0.5
        else:
            self.pid2.SP = (self.set_point + self.calcHeatBlkOS) * 0.5
        self.pid2.ffwd = self.qHeatLoss / self.qMaxRampPid * 100
        self.pid2.y = self.pcr.block_temp * 0.5

        self.stage = "Overshoot Over"
        self.spCtrlFirstActFlag = False
        self.rampUpStageRampTime = self.time_elapsed

    def prepare_hold(self):
        self.stage = "Hold"
        self.pid.reset()
        self.pid.load(self.pid_const, "Hold")
        self.pid.SP = self.set_point * 0.5
        self.pid.y = self.pcr.block_temp * 0.5
        self.pid.ffwd = self.qHeatLoss / self.qMaxHoldPid * 100

    def calcBlockRate(self, timeConst):
        # assume ramp_time and ramp_dist are calculated before calling this function
        if self.ramp_time == 0 or timeConst == 0:
            return self.target_sample_rate
        return self.ramp_dist / (self.ramp_time - timeConst * (1 - exp(-self.ramp_time / timeConst)))

    def prepare_ramp_up(self):
        self.stage = "Ramp Up"    
        self.target_block_rate = self.calcBlockRate(self.pcr.heat_const)

        self.pid.load(self.pid_const, "Ramp Up")
        self.pid.SP = self.target_sample_rate
        initial_qpid = self.blockMCP * self.target_block_rate
        self.pid.ffwd = initial_qpid / self.qMaxRampPid * 100

        if self.target_block_rate < self.block_slow_rate:
            self.pid.ffwd = self.slow_upramp_qpid / self.qMaxRampPid * 100
        
        if self.pid.ffwd > 100:
            self.pid.ffwd = 100

        overshoot_dt_const = (self.ramp_dist - 2) / (self.max_ramp_dist - 2)
        overshoot_rr_const = 1 if self.target_sample_rate >= self.max_up_ramp else self.target_sample_rate / self.max_up_ramp

        if overshoot_dt_const > 1:
            blkOS_const = overshoot_rr_const
            smpWin_const = overshoot_rr_const
        else:
            blkOS_const = overshoot_dt_const * overshoot_rr_const
            smpWin_const = overshoot_dt_const * overshoot_rr_const

        self.calcHeatBlkOS  = blkOS_const * self.maxHeatBlkOS
        self.calcHeatSmpWin = smpWin_const * self.maxHeatSmpWin
        self.calcHeatBlkWin = overshoot_rr_const * self.maxHeatBlkWin
        self.smpWinInRampUpFlag = False

    def prepare_ramp_down(self):
        self.stage = "Ramp Down"        
        self.target_block_rate = self.calcBlockRate(self.pcr.cool_const) # target_block_rate is negative        

        self.pid.load(self.pid_const, "Ramp Down")
        self.pid.SP = self.target_sample_rate

        if abs(self.target_block_rate) < self.block_slow_rate:
            initial_qpid = self.blockMCP * self.block_slow_rate
        else:
            initial_qpid = self.blockMCP * self.target_block_rate

        self.pid.ffwd = -initial_qpid / self.qMaxRampPid * 100        
        
        if self.pid.ffwd < -100:
            self.pid.ffwd = -100

        overshoot_dt_const = (self.ramp_dist - 2)/ (self.max_ramp_dist - 2)
        if abs(self.target_sample_rate) <= self.max_down_ramp:
            overshoot_rr_const = 1
        else:
            overshoot_rr_const = self.target_sample_rate / self.max_down_ramp

        if overshoot_dt_const > 1:
            blkOS_const = overshoot_rr_const            
        else:
            blkOS_const = overshoot_dt_const * overshoot_rr_const
        smpWin_const = overshoot_dt_const * overshoot_rr_const            

        self.calcCoolBlkOS  = blkOS_const * self.maxCoolBlkOS
        self.calcCoolSmpWin = smpWin_const * self.maxCoolSmpWin
        self.calcCoolBlkWin = overshoot_rr_const * self.maxCoolBlkWin        

    def ctrl_ramp_up(self):
        if self.pcr.block_temp < self.set_point \
            and self.pcr.block_temp + self.calcHeatBlkWin <= self.set_point + self.calcHeatBlkOS:

            if self.time_elapsed >= self.ramp_time:
                self.pid.SP = self.unachievable
            else:
                self.pid.SP = (self.set_point - self.pcr.sample_temp) / (self.ramp_time - self.time_elapsed)

            if self.pid.SP > 1.05 * self.target_sample_rate:
                self.pid.SP = 1.05 * self.target_sample_rate

            if self.target_block_rate <= self.block_slow_rate:
                if self.target_sample_rate / self.max_up_ramp < 0.1:
                    stash = self.pid.P
                    factor = self.target_sample_rate / self.max_up_ramp * 10
                    self.pid.P *= factor
                    if self.pid.P < self.rampup_minP:
                        self.pid.P = self.rampup_minP          
                    self.qpid = self.qMaxRampPid * self.pid.update() * 0.01
                    self.pid.P = stash
                else:
                    self.qpid = self.qMaxRampPid * self.pid.update() * 0.01

            else:
                self.qpid = self.qMaxRampPid * self.pid.update() * 0.01

        else: # block temp. has overshot the set point
            if self.pcr.sample_rate <= self.sample_slow_rate:
                self.prepare_hold()
            else:
                self.smpWinInRampUpFlag = False
                overshoot_gap = self.set_point + self.calcHeatBlkOS - self.pcr.block_temp
                if self.pcr.block_rate > self.pcr.sample_rate:
                    time_to_maxOS = overshoot_gap / self.pcr.block_rate
                    self.rampUpStageRate = self.pcr.block_rate
                else:
                    time_to_maxOS = overshoot_gap / self.pcr.sample_rate
                    self.rampUpStageRate = self.pcr.sample_rate
                if self.calcHeatBlkOS > self.calcHeatBlkWin:
                    self.heat_brake = self.heat_brake_const * self.calcHeatBlkOS / time_to_maxOS
                else:
                    self.heat_brake = self.heat_brake_const * self.calcHeatBlkWin / time_to_maxOS

                self.prepare_overshoot_over()

        if self.pcr.sample_temp >= self.set_point - self.calcHeatSmpWin:
            if self.pid.PV <= 0:
                return

            self.smpWinInRampUpFlag = True
            time_to_setpoint = (self.set_point - self.pcr.sample_temp) / self.pid.PV
            self.heat_brake = self.heat_brake_const * self.calcHeatSmpWin / time_to_setpoint
            self.rampUpStageRate = self.pid.PV
            self.prepare_overshoot_over()

        self.peltier.mode = "heat"

    def prepare_hold_over(self):
        if self.pcr.block_temp >= self.max_block_temp:
            self.pid.SP = self.max_block_temp * 0.5
        else:
            self.pid.SP = (self.set_point + self.calcHeatBlkOS) * 0.5

        if self.pid.SP > self.max_block_temp * 0.5:
            self.pid.SP = self.max_block_temp * 0.5

        self.pid.m = self.pid2.m
        self.pid.b = self.pid2.b
        self.pid.y = self.pid2.y
        self.pid.ffwd = self.qHeatLoss / self.qMaxHoldPid * 100
        self.pid.load(self.pid_const, "Hold Over")
        self.stage = "Hold Over"


    def ctrl_overshoot_over(self):
        if not self.smpWinInRampUpFlag:
            if self.pcr.sample_temp >= self.set_point - self.calcHeatSmpWin:
                self.prepare_hold()
                return

            try:
                if self.calcHeatBlkOS >= self.calcHeatBlkWin:
                    tempSP = self.rampUpStageRate * exp(-self.heat_brake * (self.time_elapsed - self.rampUpStageRampTime) / self.calcHeatBlkOS)
                else:
                    tempSP = self.rampUpStageRate * exp(-self.heat_brake * (self.time_elapsed - self.rampUpStageRampTime) / self.calcHeatBlkWin)
            except:
                tempSP = 0
        else:
            try:
                if self.calcHeatSmpWin != 0:
                    tempSP = self.rampUpStageRate * exp(-self.heat_brake * (self.time_elapsed - self.rampUpStageRampTime) / self.calcHeatSmpWin)
                else:
                    tempSP = 0
            except:
                tempSP = 0
            
            if self.pcr.sample_temp >= self.set_point - 0.2 or self.pcr.block_temp > self.set_point:
                tempSP = self.pid.b
                temp_b = self.pid2.b
                temp_ffwd = self.pid2.ffwd
                temp_y = self.pid2.y
                self.prepare_hold()
                self.pid.b  = temp_b + tempSP
                self.pid.m  = self.pid.b
                self.pid.b -= temp_ffwd
                self.pid.y  = temp_y
            return
        
        self.pid.SP = tempSP
        qPower = tempSP / self.rampUpStageRate * self.qMaxRampPid + (1 - tempSP / self.rampUpStageRate) * self.qMaxHoldPid
        self.pid.ffwd = self.pid.SP * self.blockMCP / qPower * 100
        self.qpid = qPower * self.pid.update() / 100

        if self.pid.SP <= self.heatSpCtrlActivRR * self.rampUpStageRate \
            or self.pid.SP <= self.heatSpCtrlActivSP:
            if not self.spCtrlFirstActFlag:
                self.pid2.y = self.pcr.block_temp * 0.5
                self.spCtrlFirstActFlag = True
            self.qpid += qPower * self.pid2.update() / 100

        if self.pcr.block_temp >= self.set_point + self.calcHeatBlkOS \
            or self.pcr.block_temp >= self.max_block_temp:

            self.prepare_hold_over()
        
        self.peltier.mode = "heat"

    def prepare_land_over(self):
        self.pid.reset()
        self.pid.load(self.pid_const, "Land Over")
        self.stage = "Land Over"

    def ctrl_hold_over(self):
        self.qpid = self.qMaxHoldPid * self.pid.update() / 100
        if self.pcr.sample_temp >= self.set_point - self.calcHeatSmpWin:
            self.prepare_land_over()
        self.peltier.mode = "heat"

    def ctrl_land_over(self):
        timeToSetPt = (self.set_point - self.pcr.sample_temp) / self.pcr.sample_rate
        if timeToSetPt > 0:
            self.pid.SP = (self.set_point - self.pcr.block_temp) / timeToSetPt
        else:
            self.pid.SP = -3
        if self.pcr.block_temp - self.smoothRegionOverWin <= self.set_point:
            if self.pid.SP < -self.smoothRegionOverRR:
                self.pid.SP = -self.smoothRegionOverRR
        self.pid.ffwd = self.pid.SP * self.blockMCP / self.qMaxRampPid * 100
        self.qpid = -self.qMaxRampPid * self.pid.update() / 100
        if self.pcr.block_temp - 0.25 <= self.set_point:
            self.prepare_hold()
        self.peltier.mode = "cool"

    def ctrl_ramp_down(self):
        if self.pcr.block_temp > self.set_point \
            and self.pcr.block_temp - self.calcCoolBlkWin >= self.set_point - self.calcCoolBlkOS:

            if self.time_elapsed >= self.ramp_time:
                self.pid.SP = -self.unachievable
            else:
                self.pid.SP = (self.set_point - self.pcr.sample_temp) / (self.ramp_time - self.time_elapsed)

            if self.pid.SP < -1.05 * self.target_sample_rate:
                self.pid.SP = -1.05 * self.target_sample_rate

            if self.target_block_rate <= self.block_slow_rate:
                if self.target_sample_rate / self.max_down_ramp < 0.1:
                    stash = self.pid.P
                    factor = self.target_sample_rate / self.max_down_ramp * 10
                    self.pid.P *= factor
                    if self.pid.P < self.rampdown_minP:
                        self.pid.P = self.rampdown_minP          
                    self.qpid = -self.qMaxRampPid * self.pid.update() * 0.01
                    self.pid.P = stash
                else:
                    self.qpid = -self.qMaxRampPid * self.pid.update() * 0.01

            else:
                self.qpid = -self.qMaxRampPid * self.pid.update() * 0.01

        else: # block temp. has overshot the set point
            if self.pcr.sample_rate >= -self.sample_slow_rate:
                self.prepare_hold()
            else:
                self.smpWinInRampDownFlag = False
                overshoot_gap = self.set_point - self.calcCoolBlkOS - self.pcr.block_temp
                if self.pcr.block_rate < self.pcr.sample_rate:
                    time_to_maxOS = overshoot_gap / self.pcr.block_rate
                    self.rampDownStageRate = abs(self.pcr.block_rate)
                else:
                    time_to_maxOS = overshoot_gap / self.pcr.sample_rate
                    self.rampDownStageRate = abs(self.pcr.sample_rate)

                if self.calcCoolBlkOS > self.calcCoolBlkWin:
                    self.cool_brake = self.cool_brake_const * self.calcCoolBlkOS / time_to_maxOS
                else:
                    self.cool_brake = self.cool_brake_const * self.calcCoolBlkWin / time_to_maxOS
                
                self.prepare_overshoot_under()

        if self.pcr.sample_temp <= self.set_point + self.calcCoolSmpWin:
            if self.pid.PV >= 0:
                return

            self.smpWinInRampDownFlag = True
            time_to_setpoint = (self.set_point - self.pcr.sample_temp) / self.pid.PV
            self.cool_brake = self.cool_brake_const * self.calcCoolSmpWin / time_to_setpoint
            self.rampDownStageRate = self.pid.PV
            self.prepare_overshoot_under()

        self.peltier.mode = "cool"

    def prepare_overshoot_under(self):
        self.rampDownStageRampTime = self.time_elapsed
        self.pid.load(self.pid_const, "Overshoot Under")
        self.pid2.reset()
        self.pid2.load(self.pid_const, "Hold Under")
        self.pid2.SP = (self.set_point - self.calcCoolBlkOS) * 0.5
        self.pid2.ffwd = -self.qHeatLoss / self.qMaxHoldPid * 100
        self.pid2.y = self.pcr.block_temp * 0.5
        self.spCtrlFirstActFlag = False
        self.stage = "Overshoot Under"

    def prepare_land_under(self):
        self.pid.reset()
        self.pid.load(self.pid_const, "Land Under")
        self.stage = "Land Under"

    def ctrl_overshoot_under(self):
        if self.smpWinInRampDownFlag == False:
            if self.calcCoolBlkOS >= self.calcCoolBlkWin:
                tempSP = self.rampDownStageRate * exp(-self.cool_brake / self.calcCoolBlkOS * (self.time_elapsed - self.rampDownStageRampTime))
            else:
                tempSP = self.rampDownStageRate * exp(-self.cool_brake / self.calcCoolBlkWin * (self.time_elapsed - self.rampDownStageRampTime))

            if self.pcr.sample_temp <= self.set_point + self.calcCoolSmpWin:
                self.prepare_land_under()
                return

        else:
            if self.calcCoolSmpWin != 0:
                tempSP = self.rampDownStageRate * exp(-self.cool_brake / self.calcCoolSmpWin *(self.time_elapsed - self.rampDownStageRampTime))
            else:
                tempSP = 0

            if self.pcr.sample_temp <= self.set_point + 1:
                self.prepare_hold()
                self.pid.m = self.pid2.m
                self.pid.b = self.pid2.b
                self.pid.y = self.pid2.y
                return
        qPower  = (tempSP / self.rampDownStageRate)     * self.qMaxRampPid
        qPower += (1 - tempSP / self.rampDownStageRate) * self.qMaxHoldPid
        self.pid.SP = -tempSP
        self.pid.ffwd = self.pid.SP * self.blockMCP / qPower * 100
        self.qpid = -qPower * self.pid.update() / 100
        if self.pid.SP >= self.coolSpCtrlActivRR * self.rampDownStageRate \
            or self.pid.SP >= self.coolSpCtrlActivSP:
            if self.spCtrlFirstActFlag == False:
                self.pid2.y = self.pcr.block_temp * 0.5
                self.spCtrlFirstActFlag = True
            self.qpid -= qPower * self.pid2.update() / 100
        if self.pcr.block_temp <= self.set_point - self.calcCoolBlkOS:
            self.prepare_hold_under()
        self.peltier.mode = "cool"

    def ctrl_land_under(self):
        timeToSetPt = (self.set_point - self.pcr.sample_temp) / self.pcr.sample_rate
        if timeToSetPt < 0:
            self.pid.SP = (self.set_point - self.pcr.block_temp) / timeToSetPt
        else:
            self.pid.SP = 3
        if self.pcr.block_temp + self.smoothRegionOverWin >= self.set_point:
            if self.pid.SP > self.smoothRegionOverRR:
                self.pid.SP = self.smoothRegionOverRR
        self.pid.ffwd = self.pid.SP * self.blockMCP / self.qMaxRampPid * 100
        self.qpid = self.qMaxRampPid * self.pid.update() / 100
        if self.pcr.block_temp + 0.25 >= self.set_point:
            self.prepare_hold()
        self.peltier.mode = "heat"

    def ctrl_hold_under(self):
        self.qpid = self.qMaxHoldPid * self.pid.update() / 100
        if self.pcr.sample_temp <= self.set_point + self.calcCoolSmpWin:
            self.prepare_land_under()
        self.peltier.mode = "heat"

    def ctrl_hold(self):
        self.qpid = self.qMaxHoldPid * self.pid.update() / 100
        self.peltier.mode = "heat"

    def prepare_hold_under(self):
        self.pid.reset()
        self.pid.load(self.pid_const, "Hold Under")
        self.pid.SP = (self.set_point - self.calcCoolBlkOS) * 0.5
        self.pid.m = self.pid2.m
        self.pid.b = self.pid2.b
        self.pid.y = self.pid2.y
        self.pid.ffwd = -self.qHeatLoss / self.qMaxHoldPid * 100        
        self.stage = "Hold Under"        

    def run_control_stage(self):
        if self.stage == "Ramp Up":
            self.ctrl_ramp_up()

        elif self.stage == "Overshoot Over":
            self.ctrl_overshoot_over()

        elif self.stage == "Hold Over":
            self.ctrl_hold_over()

        elif self.stage == "Land Over":
            self.ctrl_land_over()

        elif self.stage == "Ramp Down":
            self.ctrl_ramp_down()

        elif self.stage == "Overshoot Under":
            self.ctrl_overshoot_under()

        elif self.stage == "Overshoot Under":
            self.ctrl_overshoot_under()

        elif self.stage == "Hold Under":
            self.ctrl_hold_under()

        elif self.stage == "Land Under":
            self.ctrl_land_under()

        elif self.stage == "Hold":
            self.ctrl_hold()

    def is_timer_fired(self):
        if round(self.time - self.checkpoint, 3) >= self.period:
            self.checkpoint = self.time
            return True
        return False

    def calc_feed_forward(self):
        return 0

    def ramp_to(self, new_set_point, sample_rate):
        if self.set_point == new_set_point:            
            return            
        self.pid.reset()
        self.start_time = self.time
        self.time_elapsed = 0
        self.set_point = new_set_point
        self.smpWinInRampUpFlag = False
        self.smpWinInRampDownFlag = False

        if new_set_point - self.pcr.block_temp > 2:
            self.ramp_dist = new_set_point - self.pcr.block_temp
            self.target_sample_rate = sample_rate / 100 * self.max_up_ramp
            self.ramp_time = self.ramp_dist / self.target_sample_rate
            self.prepare_ramp_up()
        elif new_set_point - self.pcr.block_temp < -2:
            self.ramp_dist = self.pcr.block_temp - new_set_point
            self.target_sample_rate = sample_rate / 100 * self.max_down_ramp
            self.ramp_time = self.ramp_dist / self.target_sample_rate
            self.prepare_ramp_down()
        else:
            self.prepare_hold()

    def output(self):
        Iset, Vset = self.peltier.output(   self.qpid, 
                                            self.pcr.heat_sink_temp,
                                            self.pcr.block_temp,
                                            self.maxHeatIset,
                                            self.maxCoolIset
                                        )
        self.pcr.Iset = Iset        
        self.pcr.Vset = Vset
 
    def update(self):
        self.update_feedback()
        self.run_control_stage()
        self.output()
    
    def tick(self, tick):
        self.time += tick
        if self.is_timer_fired():
            self.update()