Skip to content

Using Sensors with Actions

Using Sensors instead of Time

The previous section of this guide implemented ActionSeries that drove two shapes, a square and a triangle, using time-based drive actions. In reality, this time-based method of driving a distance or rotating to a certain angle is not ideal. There are many things that affect the relationship between time in motion and distance / angle reached including, battery level, the surface being driven on, and what robot is running the code. These factors make driving shapes using time, difficult and inconsistent.

To improve this, sensor data will be used instead. Two types of sensors will be used in this section. First, a gyroscope will be used to determine what angle the robot is at relative to where it started. Second, encoders will be used to determine how many times the wheels have rotated, which can be used to determine how far the robot has driven.

Code to use an Mpu6050 sensor's IMU via an Arduino coprocessor was shown in the Sensor Data & NetworkTable section of this guide. The following code can be added to use encoders (assumed to be single channel encoders, not quadrature encoders)

# Add with other imports
from arpirobot.arduino.sensor import SingleEncoder

# Create the devices in __init__
# Assuming encoders are connected to pins 2 and 3 on the Arduino
# Change if this is not the case
self.lenc = SingleEncoder(2)
self.renc = SingleEncoder(3)

# In robot_started, before self.arduino.begin()
self.arduino.add_device(self.lenc)
self.arduino.add_device(self.renc)
// Add with other includes
#include <arpirobot/arduino/sensor/SingleEncoder.hpp>

// Create devices with other member variables
// Assuming encoders are connected to pins 2 and 3 on the Arduino
// Change if this is not the case
SingleEncoder lenc {2};
SingleEncoder renc {3};
// In robotStarted, before arduino.begin()
arduino.addDevice(lenc);
arduino.addDevice(renc);

Rotate Angle Action

The following code implements an action that rotates a certain number of degrees from where it starts. This is done using the Z-Axis gyroscope on the robot.

class RotateAngleAction(Action):
    def __init__(self, degrees: float):
        super().__init__()
        self.degrees = degrees
        self.target_angle = 0       # This will be set in begin()

    def locked_devices(self) -> LockedDeviceList:
        # This needs to be the only action using motors while running
        return [ main.robot.flmotor, main.robot.frmotor, 
            main.robot.rlmotor, main.robot.rrmotor ]

    def begin(self):
        # Calculate target angle
        # Action should rotate self.degrees degrees from where robot is initially facing
        self.target_angle = main.robot.imu.get_gyro_z() + self.degrees

        # Use brake mode to reduce how much the robot continues rotating after the action stops
        main.robot.flmotor.set_brake_mode(True)
        main.robot.frmotor.set_brake_mode(True)
        main.robot.rlmotor.set_brake_mode(True)
        main.robot.rrmotor.set_brake_mode(True)

        # Start rotating in the correct direction
        # Note: It is assumed that positive rotation in the drive helper 
        #       results in positive change in IMU angle. If this is not the
        #       case, swap the +0.9 and -0.9 in the if / else below.
        #       If these signs are incorrect, the robot will rotate forever
        #       as it never reaches its target angle.
        if self.degrees >= 0:
            main.robot.drive_helper.update(0, 0.9)
        else:
            main.robot.drive_helper.update(0, -0.9)

    def process(self):
        # Nothing to do here. Just let it keep rotating.
        pass

    def finish(self, was_interrupted: bool):
        # Stop rotating
        main.robot.drive_helper.update(0, 0)

    def should_continue(self) -> bool:
        if self.degrees >= 0:
            # Rotating in positive direction
            # This means that the angle will be increasing
            # Done rotating when angle is at greater than or equal to target
            # If not done, should continue running (continue if less than target)
            return main.robot.imu.get_gyro_z() < self.target_angle
        else:
            # Rotating in negative direction
            # This means that the angle will be decreasing
            # Done rotating when angle is less than or equal to target
            # If not done, should continue running (continue if greater than target)
            return main.robot.imu.get_gyro_z() > self.target_angle
class RotateAngleAction : public Action{
public:
    RotateAngleAction(double degrees);

protected:
    LockedDeviceList lockedDevices() override;
    void begin() override;
    void process() override;
    void finish(bool wasInterrupted) override;
    bool shouldContinue() override;

private:
    double degrees;
    double targetAngle = 0;     // Will be set in begin()
};
RotateAngleAction::RotateAngleAction(double degrees) : degrees(degrees){

}

LockedDeviceList RotateAngleAction::lockedDevices(){
    // This needs to be the only action using motors while running
    return { Main::robot->flmotor, Main::robot->frmotor, 
        Main::robot->rlmotor, Main::robot->rrmotor };
}

void RotateAngleAction::begin(){
    // Calculate target angle
    // Action should rotate this->degrees degrees from where the robot is initially facing
    targetAngle = degrees + Main::robot->imu.getGyroZ();

    // Use brake mode to reduce how much the robot continues rotating after the action stops
    Main::robot->flmotor.setBrakeMode(true);
    Main::robot->frmotor.setBrakeMode(true);
    Main::robot->rlmotor.setBrakeMode(true);
    Main::robot->rrmotor.setBrakeMode(true);

    // Start rotating in the correct direction
    // Note: It is assumed that positive rotation in the drive helper 
    //       results in positive change in IMU angle. If this is not the
    //       case, swap the +0.9 and -0.9 in the if / else below.
    //       If these signs are incorrect, the robot will rotate forever
    //       as it never reaches its target angle.
    if(degrees >= 0){
        Main::robot->driveHelper.update(0, 0.9);
    }else{
        Main::robot->driveHelper.update(0, -0.9);
    }
}

void RotateAngleAction::process(){
    // Nothing to do here. Just let it keep rotating.
}

void RotateAngleAction::finish(bool wasInterrupted){
    // Stop rotating
    Main::robot->driveHelper.update(0, 0);
}

bool RotateAngleAction::shouldContinue(){
    if(degrees >= 0){
        // Rotating in positive direction
        // This means that the angle will be increasing
        // Done rotating when angle is at greater than or equal to target
        // If not done, should continue running (continue if less than target)
        return Main::robot->imu.getGyroZ() < targetAngle;
    }else{
        // Rotating in negative direction
        // This means that the angle will be decreasing
        // Done rotating when angle is less than or equal to target
        // If not done, should continue running (continue if greater than target)
        return Main::robot->imu.getGyroZ() > targetAngle;
    }
}

When the action starts, it calculates the target angle (the angle it should go to) which is a sum of the angle the robot starts at and the angle the action is supposed to rotate (passed as a constructor argument). The robot continues rotating at a constant speed until it reaches (or passes) this angle. The should_continue / shouldContinue function is used to stop the action once it has rotated far enough. Note that negative rotation is simply rotating in the opposite direction. As such, it is necessary to know which way the robot is rotating to determine if it has rotated far enough (hence the if statement in should_continue / shouldContinue).

Drive Distance Action

A drive distance action can be implemented similarly. Since SingleEncoders are used, it is not possible to determine the direction the robot is moving using encoders, only how far it has moved (the encoder count will always be positive). In other words, rotating the wheel one revolution forward increases the encoder count and rotating the wheel one revolution backward increases the encoder count (no rotation decreases the encoder count). As such, to move in the negative direction, the motors move in reverse (negative speed), but the encoder count still grows. The action below is implemented to handle this. Note that if your robot has Quadrature encoders, this action will not work without modifications. You could either adapt this action to handle directions (similar to RotateAngleAction above), or use SingleEncoder objects in code instead of QuadEncoder objects (just pick one signal wire per encoder).

class DriveDistanceAction(Action):
    def __init__(self, distance_ticks: int):
        super().__init__()

        # Absolute value of distance_ticks = distance to travel with no sign
        self.distance_ticks = abs(distance_ticks)

        # True if robot is moving forward (positive direction). Otherwise False.
        self.forward = (distance_ticks >= 0)

    def locked_devices(self) -> LockedDeviceList:
        # This needs to be the only action using motors while running
        return [ main.robot.flmotor, main.robot.frmotor, 
            main.robot.rlmotor, main.robot.rrmotor ]

    def begin(self):
        # Reset encoder counts to zero (starting from zero ticks)
        main.robot.lenc.set_position(0)
        main.robot.renc.set_position(0)

        # Use brake mode to reduce how much the robot continues rotating after the action stops
        main.robot.flmotor.set_brake_mode(True)
        main.robot.frmotor.set_brake_mode(True)
        main.robot.rlmotor.set_brake_mode(True)
        main.robot.rrmotor.set_brake_mode(True)

        # Move the robot in the correct direction
        if(self.forward):
            main.robot.drive_helper.update(0.8, 0)
        else:
            main.robot.drive_helper.update(-0.8, 0)


    def process(self):
        # Nothing to do here. Just let it keep driving.
        pass

    def finish(self, was_interrupted: bool):
        # Stop driving
        main.robot.drive_helper.update(0, 0)

    def should_continue(self) -> bool:
        # Average the distance traveled by left and right encoders
        # Treat this as the distance traveled
        distance_traveled = (main.robot.lenc.get_position() + main.robot.renc.get_position()) / 2.0

        # Continue if robot has not traveled far enough (distance traveled is less than target distance)
        # Target distance = self.distance_ticks since initial position is always zero
        return distance_traveled < self.distance_ticks
class DriveDistanceAction : pubic Action{
public:
    DriveDistanceAction(int distanceTicks);

protected:
    LockedDeviceList lockedDevices() override;
    void begin() override;
    void process() override;
    void finish(bool wasInterrupted) override;
    bool shouldContinue() override;

private:

    // Set in constructor initializer list.
    // Always positive number = distance to travel without sign
    int distanceTicks;

    // Set in constructor initializer list
    // true if robot is moving forward (positive direction). Otherwise false.
    bool forward;
};
DriveDistanceAction::DriveDistanceAction(int distanceTicks) :
        distanceTicks(std::abs(distanceTicks)), forward(distanceTicks >= 0){

}

LockedDeviceList DriveDistanceAction::lockedDevices(){
    // This needs to be the only action using motors while running
    return { Main::robot->flmotor, Main::robot->frmotor, 
        Main::robot->rlmotor, Main::robot->rrmotor };
}

void DriveDistanceAction::begin(){
    // Reset encoder counts to zero (starting from zero ticks)
    Main::robot->lenc.setPosition(0);
    Main::robot->renc.setPosition(0);

    // Use brake mode to reduce how much the robot continues rotating after the action stops
    Main::robot->flmotor.setBrakeMode(true);
    Main::robot->frmotor.setBrakeMode(true);
    Main::robot->rlmotor.setBrakeMode(true);
    Main::robot->rrmotor.setBrakeMode(true);

    // Move the robot in the correct direction
    if(forward){
        Main::robot->driveHelper.update(0.8, 0);
    }else{
        Main::robot->driveHelper.update(-0.8, 0);
    }
}

void DriveDistanceAction::process(){
    // Nothing to do here. Just let it keep driving.
}

void DriveDistanceAction::finish(bool wasInterrupted){
    // Stop driving
    Main::robot->driveHelper.update(0, 0);
}

bool DriveDistanceAction::shouldContinue(){
    // Average the distance traveled by left and right encoders
    // Treat this as distance traveled
    int distanceTraveled = (Main::robot->lenc.getPosition() + Main::robot->renc.getPosition()) / 2.0;

    // Continue if robot has not traveled far enough (distance traveled is less than target distance)
    // Target distance = this->distanceTicks since initial position is always zero
    return distanceTraveled < distanceTicks;
}

When the action starts, it resets encoder counts to zero. This makes detecting when the robot has driven far enough easy, as both encoders always start from zero. The robot will then begin driving (forward if distance given to the action in the constructor was positive, reverse if negative). The robot continues driving while the action is running. The should_continue / shouldContinue function is used to determine if the robot has driven far enough. If so, the function returns false stopping the action. The function uses both encoder values by averaging them to determine how far the robot has driven.

Using the Actions

The actions implemented above can be used to replace the "drive square" and "drive triangle" ActionSeries from the previous section of this guide. Time based rotations are replaced with angle-based rotations and time-based drives are replaced with distance-based drives. The only challenging part is determining how far to drive.

The DriveDistanceAction drives a certain number of encoder "ticks" or "counts". An encoder "tick" (for a SingleEncoder) is any rising or falling edge of the signal. This means that for a typical optical encoder there will be twice as many "ticks" per wheel revolution as there are slots in the disk. For example, when using a standard 20-slot disk, there are 40 "ticks" per wheel revolution. Converting wheel revolutions into distance traveled (linear distance traveled by the robot) is also fairly simple. Each revolution, the robot drives a distance equal to the circumference of the wheel. The circumference of the wheel is pi (π ≈ 3.14) times the diameter of the wheel. If using standard black and yellow TT motor wheels the diameter is 2.5 inches.

The following equation can be used to calculate how many "ticks" should be used with the DriveDistanceAction to drive a given distance (note that distance and wheel_diameter must have the same unit). Always round to the nearest whole number.

        ticks = distance ÷ (π × wheel_diameter) × ticks_per_revolution

For example, to drive two feet (24 inches) with the encoder disk and wheels mentioned earlier:

        ticks = 24 ÷ (π × 2.5) × 40

        ticks ≈ 122

This number is used in the code below to implement the "drive square" and "drive triangle" ActionSeries.

The following code can replace the same ActionSeries from the previous section to use the sensor-based actions instead of the time-based ones.

self.drive_square_series = ActionSeries(
    # This is a list of actions to run sequentially
    [
        DriveDistanceAction(122),       # First edge
        RotateAngleAction(90),          # Rotate 90

        WaitAction(0.5),                # Delay before driving next edge

        DriveDistanceAction(122),       # Second edge
        RotateAngleAction(90),          # Rotate 90

        WaitAction(0.5),                # Delay before driving next edge

        DriveDistanceAction(122),       # Third edge
        RotateAngleAction(90),          # Rotate 90

        WaitAction(0.5),                # Delay before driving next edge

        DriveDistanceAction(122),       # Fourth edge
        RotateAngleAction(90),          # Rotate 90

        WaitAction(0.1)                 # Small delay so brake mode has time to work

    ],

    # Start JSDriveAction instance when this action series finishes
    self.js_drive_action
)
self.drive_triangle_series = ActionSeries(
    # This is a list of actions to run sequentially
    [
        DriveDistanceAction(122),       # First edge
        RotateAngleAction(120),         # Rotate 120

        WaitAction(0.5),                # Delay before driving next edge

        DriveDistanceAction(122),       # Second edge
        RotateAngleAction(120),         # Rotate 120

        WaitAction(0.5),                # Delay before driving next edge

        DriveDistanceAction(122),       # Third edge
        RotateAngleAction(120),         # Rotate 120

        WaitAction(0.1)                 # Small delay so brake mode has time to work
    ],

    # Start JSDriveAction instance when this action series finishes
    self.js_drive_action
)
ActionSeries driveSquareSeries{
    // This is a list of actions to run sequentially
    {
        std::make_shared<DriveDistanceAction>(122),     // First edge
        std::make_shared<RotateAngleAction>(90),        // Rotate 90

        std::make_shared<WaitAction>(0.5),              // Delay before driving next edge

        std::make_shared<DriveDistanceAction>(122),     // Second edge
        std::make_shared<RotateAngleAction>(90),        // Rotate 90

        std::make_shared<WaitAction>(0.5),              // Delay before driving next edge

        std::make_shared<DriveDistanceAction>(122),     // Third edge
        std::make_shared<RotateAngleAction>(90),        // Rotate 90

        std::make_shared<WaitAction>(0.5),              // Delay before driving next edge

        std::make_shared<DriveDistanceAction>(122),     // Fourth edge
        std::make_shared<RotateAngleAction>(90),        // Rotate 90

        std::make_shared<WaitAction>(0.1)               // Small delay so brake mode has time to work

    },

    // Start JSDriveAction instance when this action series finishes
    jsDriveAction
};
ActionSeries driveTriangleSeries {
    // This is a list of actions to run sequentially
    {
        std::make_shared<DriveDistanceAction>(122),     // First edge
        std::make_shared<RotateAngleAction>(120),       // Rotate 120 (adjust time as needed)

        std::make_shared<WaitAction>(0.5),              // Delay before driving next edge

        std::make_shared<DriveDistanceAction>(122),     // Second edge
        std::make_shared<RotateAngleAction>(120),       // Rotate 120 (adjust time as needed)

        std::make_shared<WaitAction>(0.5),              // Delay before driving next edge

        std::make_shared<DriveDistanceAction>(122),     // Third edge
        std::make_shared<RotateAngleAction>(120),       // Rotate 120 (adjust time as needed)

        std::make_shared<WaitAction>(0.1)               // Small delay so brake mode has time to work
    },

    // Start JSDriveAction instance when this action series finishes
    jsDriveAction
};

Build and deploy the code. This should result in much more accurate and consistent shapes than the time-based method.

Note: If your robot rotates forever at the first rotation, you probably need to swap the +0.9 and -0.9 in the if / else statement in RotateAngleAction's begin function.

Limitations

While better than the time-based method, this method of using sensor data still has some limitations. Most significantly, drive and rotate actions will often "overshoot" or go too far. This is because the robot does not stop until it reaches the target. Once it does attempt to stop, it will still continue moving for a short time (mostly due to momentum) before actually stopping. This often causes DriveDistanceActions to drive too far and RotateAngleActions to rotate too far. There are a few ways to address this, listed below.

Note: Easiest solutions are listed at the top, but also are the least effective solutions. Solutions at the bottom are "better" but more complex to implement.

  • Reduce distances and angles: The distances the robot drives and angles it rotates could be reduces to account for this. This is an easy "fix" however it is far from perfect. The amount of adjustment necessary will depend on the surface the robot is driving on, how weak the batteries are, motor characteristics, etc. In other words, it is inconsistent.
  • Reduce speed as the robot gets close to its target: Instead of driving or rotating at a constant speed, the robot could slow down until it reaches its target. If the robot is moving slowly when it reaches its target, it will overshoot less. This solution has some challenges though. If the robot does not slow down enough, it will still overshoot. However, if the robot slows down too much, it may stop moving before reaching the target and get "suck". The is due to the fact that there will be some minimum amount of power necessary to make the robot rotate or drive. Below this power, the motors will be too "weak" to move the robot (and the motor becomes stalled, which is bad for the motors). Unfortunately, this "minimum power" varies with the surface, battery level, etc as described previously still leaving some inconsistency (although it's still better than the previous solution).
  • PID control: A more proper fix would be to use a more complex controller to handle driving or rotating. A PID controller can be used to slow down as the robot approaches a target, but also has controls to compensate for steady state error (which is what that "stalling" before reaching the target is called in the previous example). While this would be the best solution (assuming the goal is to accurately and consistently reach the target) it has its own challenges. Primarily, the PID controller must be tuned for the specific use case. This is not a trivial process. As such (especially for beginners) this method may be impractical and other solutions (above two) may be good enough anyway. However, if one does want to use PID controllers one more problem remains: the single channel encoders. A PID controller for DriveDistanceAction would require detecting which direction the wheels are rotating. This would require quadrature encoders. The rotate action is easier to implement with a PID as the IMU can measure angle changes and in which direction (positive or negative). This is shown in the next section of the guide.