A document from MCS 275 Spring 2023, instructor David Dumas. You can also get the notebook file.

MCS 275 Spring 2023 Worksheet 4 Solutions

  • Course instructor: David Dumas
  • Contributors to this document: Johnny Joyce, Kylash Viswanathan

Topics

This worksheet focuses on subclasses and inheritance. Part of it involves extending the robot simulation from lectures 5 and 6.

Resources

These things might be helpful while working on the problems. Remember that for worksheets, we don't strictly limit what resources you can consult, so these are only suggestions.

1. Get the Project 1 starter pack

Project 1 is due on 10 February. To prepare for working on it, please download and extract ths starter pack, which is a ZIP file:

You don't just want to view the contents of the ZIP file in Windows explorer; it's important to actually extract the files so they exist in a directory where you can do your project work.

When you've extracted the starter pack, check that you know the location of simulation.py and that you can run it in the terminal.

The point of asking you to do it during lab is to ensure the TA can help you if you run into any problems.

2. Additional bots

Download these files related to the robot simulation from the course sample code repository and put them in a directory where you'll do your work for this problem.

Then, build these new robots in bots.py that are subclasses of Bot:

  • class DelayMarchBot

    • A robot that waits for a specified number of time units, and thereafter marches in a direction (default is Vector(1,0), but any direction can be specified in an optional constructor argument)
  • class ParallelogramPatrolBot

    • The constructor takes two vectors v1 and v2 and two integers n1 and n2.
    • The robot marches in a parallelogram (as shown below) by
      • Taking n1 steps in direction v1, then
      • Taking n2 steps in direction v2, then
      • Taking n1 steps in direction -v1, then
      • Taking n2 steps in direction -v2, then
      • Repeating this cycle

  • class RandomItinerantBot
    • At any given time, this robot can be in either of two "modes": walking or waiting
    • When waiting, at each time step there is a 5% chance it decides to switch to walking mode, and a 95% chance it stays in waiting mode. (Use the random module to decide.)
    • When switching to walking mode, the robot chooses two parameters:
      • A direction, which is a Vector2 randomly selected from a list of four vectors representing up, down, left, and right.
      • A length, which is the number of steps it will take in this direction
    • When in walking mode, the robot takes another step in the chosen direction. If it has completed length steps, then it switches back to waiting mode.
    • Finally, this class has a new method startle() that, when called, will make it so that the robot switches to walking mode the next time update() is called.
    • Overall, the route of this robot might look like the one shown below (but will be different each time the simulation is run).

Add these robots to the simulation and confirm they exhibit the expected behavior. Use class attributes to give the new robot classes their own symbols.

Solution

In [ ]:
class DelayMarchBot(Bot):
    """Robot that waits, and then proceeds at a constant pace in a consistent direction"""
    
    symbol = "M"
    
    def __init__(self, position, wait_time, direction=plane.Vector2(1,0)):
        """Initialize as a bot. Also save `direction` and `wait_time` as attributes."""
        super().__init__(position)
        self.direction = direction
        self.wait_time = wait_time
        
    def update(self):
        """Wait for given `wait_time`. Else, move in given direction."""
        
        if self.wait_time>0: # Do nothing if we still need to wait
            self.wait_time -= 1
        else:
            self.move(self.direction) # After the waiting is done, march!
            
            
class ParallelogramPatrolBot(Bot):
    """Robot that patrols perimeter of a parallelogram defined by starting args"""
    
    symbol = "\u25B1" # Unicode symbol for parallelogram: ▱
    
    def __init__(self, position, v1, v2, n1, n2):
        """Save given args. Initialize step_count to track distance patrolled"""
        super().__init__(position)
        self.v1 = v1
        self.v2 = v2
        self.n1 = n1
        self.n2 = n2
        self.step_count = 0  # We use this int to track state
                             # Could also use a state variable like in PatrolBot
        
    def update(self):
        """March in direction v1 for n1 steps,  v2 for n2 steps, 
                             -v1 for n1 steps, -v2 for n2 steps.
           Reset step_count after 1 full patrol"""
        
        # Reset step count after going around the parallelogram
        if self.step_count == 2 * (self.n1 + self.n2):
            self.step_count = 0
        
        # Decide which side of the parallelogram bot is on, then move
        if self.step_count <= self.n1:
            self.move(self.v1)
        elif self.step_count <= self.n1 + self.n2:
            self.move(self.v2)
        elif self.step_count <= (2 * self.n1) + self.n2:
            self.move( - self.v1)
        else:
            self.move( - self.v2)
            
        self.step_count += 1
        
class RandomIterantBot(Bot):
    """Randomly changes between walking and waiting mode. 
    When walking, randomly decides on direction and length."""
    
    symbol = "R"
    steps = [
        plane.Vector2(1, 0),
        plane.Vector2(-1, 0),
        plane.Vector2(0, 1),
        plane.Vector2(0, -1),
    ]
    
    def __init__(self, position):
        """Start in waiting mode. Keep a list of vectors in each direction to randomly choose from."""
        super().__init__(position)
        self.state = "waiting"
        
    def update(self):
        """If in waiting mode, there is a 5% chance to change to walking mode.
        When in walking mode, move desired number of steps either up/down/left/right"""
        
        if self.state == "waiting":
            if random.random() <= 0.05: # 5% chance
                self.startle()
             
        else:
            self.move(self.direction)
            self.step_count += 1
            
            if self.step_count == self.length:
                self.state = "waiting"
                
    def startle(self):
        """Set robot to walking mode"""
        self.state = "walking"
        self.direction = random.choice(self.steps)
        self.step_count = 0
        self.length = random.randint(1, 5) # Total steps can be between 1 and 5 (inclusive)

3. UnitVector2

In plane.py add a subclass UnitVector2 of Vector2 that represents a unit vector (a vector of length 1) in a specified direction. The constructor should accept a single float theta instead of the two coordinates x and y that are expected by the Vector2 constructor. The constructor should then initialize the object so that the x coordinate is cos(theta) and the y coordinate is sin(theta). All three quantities (theta, x, y) should be stored as instance attributes.

The functions sin and cos are found in the math module.

Also, recall that Vector2 objects support addition and scalar multiplication. But the sum of two unit vectors is usually not a unit vector, nor is a scalar multiple of a unit vector. Is this going to cause problems? If you add two UnitVector2 instances, do you get a UnitVector2 or Vector2?

Solution

  • Code for the subclass:
In [ ]:
class UnitVector2(Vector2):
    """Subclass of Vector2 with length 1"""
    
    def __init__(self, theta):
        """Initialize the angle theta. Use angle to determine x and y."""
        
        self.theta = theta
        
        x = math.cos(self.theta)
        y = math.sin(self.theta)
        super().__init__(x,y)
        
        def __str__(self):
            """Same string representation as usual, but replace Vector2 with UnitVector2."""
            return "UnitVector2({},{})".format(self.x, self.y)
  • One way to test addition and scalar multiplication of our new UnitVector2 class is as follows:
In [ ]:
v1 = UnitVector2(0)
v2 = UnitVector2(math.pi/2)
  • v1 has (x,y) values (1,0), and v2 has (x,y) values (0,1). Then the following commands show us that we get instances of Vector2 when using addition or scalar multiplication:
In [2]:
print(v1 + v2)
Vector2(1.0,1.0)
In [3]:
print(5 * v1)
Vector2(5.0,0.0)

Bonus round

Work on these open-ended problems if you finish the exercises above. We don't plan to include solutions to these in the worksheet solutions.

ParametricWanderBot

Make a robot class that behaves like WanderBot or FastWanderBot, but which allows any list of possible direction vectors to be given as an argument to the constructor. The robot then chooses a random element of the provided list of vectors for each step.

NotifyDestructBot

Make a robot class (a subclass of DestructBot) that stands still for a specified number of steps and then deactivates. But before it does so, this class calls a user-specified function. The function is given as an argument to the constructor. So, for example:

def bye():
    """Robot says goodbye"""
    print("Thanks for including me in this simulation.  My battery is running low so if it's OK with you I'll just power down now. Bye.")

R = bots.NotifyDestructBot(position=Point(3,3),active_time=10,action=bye)

# ... code to run the simulation ...

should make a robot that sits at position (3,3) for 10 steps, prints a message, and then deactivates.

The action argument of the constructor should default to None, and the class should know to not do anything if action==None. That way, any code that works with DestructBot will also work with NotifyDestructBot.