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

MCS 275 Spring 2023 Worksheet 3 Solutions

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

Topics

This worksheet deviates from the original plan in order to account for the lost week of lecture time during the UICUF strike (Jan 17-22, 2023).

It includes some material on object-oriented programming, based on the discussion from the end of our Python tour (lecture 3) and the material on operator overloading in lecture 4.

Some of you will complete this worksheet after lecture 5, but that material will be covered on worksheet 4. In general, each worksheet after this one will focus on the previous week's lecture material. Usually that will mean 3 lectures of material is available for exploration on a worksheet.

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. Adding features to the plane module

First, download the plane.py module we wrote in lecture and save it somewhere you can find in the terminal and in VS code:

  • plane.py - download from the course sample code repo

It has a couple of features we didn't discuss in lecture, such as scalar multiplication, e.g.

In [4]:
v = Vector2(1,5)
v*10  # test out Vector2.__mul__
Out[4]:
Vector2(10,50)

and the reflected version, where the scalar comes before the vector but the vector object still handles the computation:

In [5]:
5*v  # test out Vector2.__rmul__
Out[5]:
Vector2(5,25)

I've also added unary plus and minus for vectors:

In [6]:
-v  # negates all components
Out[6]:
Vector2(-1,-5)
In [7]:
+v  # same as v
Out[7]:
Vector2(1,5)

Finally, there is now a method __abs__ that makes it so abs(v) returns a float which is the length of the vector v. It's natural to use abs for this since in mathematics, both the length of a vector and the absolute value of a real number are referred to as the "magnitude" of the corresponding object.

In [8]:
abs(v)
Out[8]:
5.0990195135927845

This means you can find the distance between two points using abs(P-Q)!

In [10]:
# Distance from (1,2) to (4,6) should be 5
abs(Point2(1,2) - Point2(4,6))
Out[10]:
5.0

However, some things are missing, and this problem asks you to add them.

A. Vector subtraction

At the moment, vectors support addition but not subtraction. Fix that. The difference of two vectors u-v should give a new vector with the property that (u-v)+v is equal to u.

In [11]:
v-v  # subtraction doesn't work yet
Out[11]:
Vector2(0,0)

B. Boolean coercion

The special method __bool__ decides whether an object evaluates to True or False if used in a place where a boolean is expected (e.g. if A: is equivalent to if A.__bool__():).

For numbers in Python, zero converts to False and all other numbers convert to True.

It would be natural to make Vector2 objects behave similarly, where the zero vector (with components 0,0) evaluates to False and all other vectors evaluate to True.

Add a __bool__ method to the Vector2 class for this purpose. You can find more info about the __bool__ method at https://docs.python.org/3/reference/datamodel.html#object.__bool__

In [20]:
if v:
    print("Not the zero vector")
else:
    print("Zero Vector")
Zero Vector

C. Indexed item access

If v is a vector or point, we can get the x component with v.x. In some cases, it might be natural to also treat the vector or point like a list and retrieve the x component with v[0]. Similarly, we'd want v[1] to give the y component.

Thankfully, Python translates v[i] into the method call v.__getitem__(i), so this is possible! Write a __getitem__ method for the Vector2 and Point2 classes so that index 0 gives the x component, index 1 gives the y component, and any other index raises the same type of error (IndexError) you get when you ask for an invalid index from a list:

In [24]:
print(v[0])
print(v[1])
print(v[2])
1
5
---------------------------------------------------------------------------
IndexError                                Traceback (most recent call last)
/var/folders/q8/vvlly3bd5q79nhv8sphycr3w0000gn/T/ipykernel_16884/2319442406.py in <module>
      1 print(v[0])
      2 print(v[1])
----> 3 print(v[2])

/var/folders/q8/vvlly3bd5q79nhv8sphycr3w0000gn/T/ipykernel_16884/3204550560.py in __getitem__(self, index)
    158             return self.y
    159         else:
--> 160             raise IndexError('list index out of range')

IndexError: list index out of range

Solution

In [1]:
class Point2:
    "Point in the plane"
    # Note: Base code (without additions) obtained from MCS 275 Spring 2023 Lecture 4 (plane.py)
    def __init__(self, x, y):
        "Initialize new point from x and y coordinates"
        self.x = x
        self.y = y

    def __eq__(self, other):
        "points are equal if and only if they have same coordinates"
        if isinstance(other, Point2):
            return (self.x == other.x) and (self.y == other.y)
        else:
            return False

    def __add__(self, other):
        "point+vector addition"
        if isinstance(other, Vector2):
            # we have been asked to add this Point2 to another Vector2
            return Point2(self.x + other.x, self.y + other.y)
        else:
            # we have been asked to add this Point2 to some other object
            # we don't want to allow this
            return NotImplemented  # return this to forbid the requested operation
    
    def __sub__(self, other):
        "point+vector subtraction"
        if isinstance(other, Vector2):
            # we have been asked to add this Point2 to another Vector2
            return Point2(self.x - other.x, self.y - other.y)
        else:
            # we have been asked to add this Point2 to some other object
            # we don't want to allow this
            return NotImplemented  # return this to forbid the requested operation        

    def __getitem__(self, index):
        "obtain the x and y coordinates in list access format"
        if index == 0:
            return self.x
        elif index == 1:
            return self.y
        else:
            raise IndexError('list index out of range')
            
    def __str__(self):
        "human-readable string representation"
        return "Point2({},{})".format(self.x, self.y)

    def __repr__(self):
        "unambiguous string representation"
        return str(self)

    def distance_to(self, other):
        "get distance between two points"
        if isinstance(other, Point2):
            return abs(self - other)
        else:
            raise TypeError("distance_to requires argument of type Point2")
    
    def __getitem__(self, index):
        "obtain the  x and y coordinates in list access format"
        if index == 0:
            return self.x
        elif index == 1:
            return self.y
        else:
            raise IndexError('list index out of range')
            
class Vector2:
    "Displacement vector in the plane"

    def __init__(self, x, y):
        "Initialize new vector from x and y components"
        self.x = x
        self.y = y

    def __eq__(self, other):
        "vectors are equal if and only if they have same components"
        if isinstance(other, Vector2):
            return (self.x == other.x) and (self.y == other.y)
        else:
            return False

    def __add__(self, other):
        "vector addition"
        if isinstance(other, Vector2):
            # vector+vector = vector
            return Vector2(self.x + other.x, self.y + other.y)
        elif isinstance(other, Point2):
            # vector + point = point
            return Point2(self.x + other.x, self.y + other.y)
        else:
            # vector + anything else = nonsense
            return NotImplemented  # return this to forbid the requested operation
        
    def __sub__(self, other):
        "vector subtraction"
        if isinstance(other, Vector2):
            # vector-vector = vector
            return Vector2(self.x - other.x, self.y - other.y)
        else:
            # vector - anything else = nonsense
            return NotImplemented  # return this to forbid the requested operation

    def __mul__(self, other):
        "vector-scalar multiplication"
        if isinstance(other, (float, int)):  # isinstance allows a tuple of types
            # vector*scalar is vector
            return Vector2(self.x * other, self.y * other)
        else:
            return NotImplemented

    def __rmul__(self, other):
        "scalar-vector multiplication"
        # Called if other*self already attempted but failed
        # for example if other is an int or float and self is a Vector2
        # This "second chance" reflected version of multiplication lets the
        # right hand operand decide what to do.  In this case, we just decide
        # that other*self is the same as self*other (handled by Vector2.__mul__ above)
        return self * other

    def __neg__(self):
        "unary minus"
        return Vector2(-self.x, -self.y)

    def __pos__(self):
        "unary plus: return a copy of the object"
        return self

    def __bool__(self):
        "vector is not the zero vector"
        return not(self.x == 0 and self.y == 0)

    def __abs__(self):
        "abs means length of a vector"
        return (self.x * self.x + self.y * self.y) ** 0.5  # sqrt( deltax^2 + deltay^2 )

    def __str__(self):
        "human-readable string representation"
        return "Vector2({},{})".format(self.x, self.y)

    def __repr__(self):
        "unambiguous string representation"
        return str(self)
    
    def __getitem__(self, index):
        "obtain the vector x and y coordinates in list access format"
        if index == 0:
            return self.x
        elif index == 1:
            return self.y
        else:
            raise IndexError('list index out of range')

2. Antistr

In Physics, antimatter refers to a type of matter composed of particles that are "opposite" of the ones that make up the majority of the matter around us. For example, there are antiprotons, antielectrons (positrons), etc..

When a particle of matter collides with its corresponding antiparticle, the two particles annihilate and a huge amount of energy is released. (For this reason, keeping any amount of antimatter around is both dangerous and difficult.)

Make a class Antistr that behaves like an "antimatter" to ordinary Python strings: An Antistr can be created from a string, but then represents the sequence of "anticharacters" of all the characters in the string.

Adding strings is like putting matter together. Usually, when you add two Python strings you just get a longer string obtained by joining the two strings together:

In [15]:
"van" + "illa"
Out[15]:
'vanilla'

But if you add a Python string to the corresponding antistring (Antistr), they should annihilate and release energy. This should correspond to the message "BOOM!" being printed on the terminal, and an empty string being returned:

In [26]:
Antistr("quail") + "quail"  # prints a message about energy release, then returns empty str
BOOM!
Out[26]:
''
In [14]:
Antistr("quail") + "quail"  # prints a message about energy release, then returns empty str
BOOM!
Out[14]:
''
In [15]:
"shark" + Antistr("shark")
BOOM!
Out[15]:
''

More generally, it should be possible for an antistring to annihilate just part of a string, as long as the string ends with or begins with the same characters as are in the antistring:

In [16]:
"carpet" + Antistr("pet")  # anti-pet annihilates pet, leaving just car
BOOM!
Out[16]:
'car'
In [29]:
Antistr("un") + "uninspired"
BOOM!
Out[29]:
'inspired'

However, if any anticharacters are left over in this process (don't annihilate identical characters from the string), then an exception should be raised.

In [28]:
# time and anti-time annihilate, but antistring " for a snack" is left 
# and that is an error
"extensive downtime" + Antistr("time for a snack") 
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
/var/folders/q8/vvlly3bd5q79nhv8sphycr3w0000gn/T/ipykernel_16884/633364138.py in <module>
      1 # time and anti-time annihilate, but antistring " for a snack" is left
      2 # and that is an error
----> 3 "extensive downtime" + Antistr("time for a snack")

/var/folders/q8/vvlly3bd5q79nhv8sphycr3w0000gn/T/ipykernel_16884/3389070780.py in __radd__(self, other)
     19                 return other[:-len(self.s)]
     20             else:
---> 21                 raise ValueError("Operation results in dangerous unshielded antistring")
     22 
     23     def __repr__(self):

ValueError: Operation results in dangerous unshielded antistring

To do this, you'll need to have suitable __add__ and __radd__ methods in your Antistr class, as well as a constructor that accepts a string.

Solution

In [27]:
class Antistr:
    "Antistr class models behavior of an antistring"
    def __init__(self,s):
        "Initialize a new antistring with a string attribute"
        self.s = s
    def __add__(self,other):
        "antistring addition/annihilation operation"
        if isinstance(other,str):
            if other.startswith(self.s):
                print("BOOM!")
                return other[len(self.s):]
            else:
                raise ValueError("Operation results in dangerous unshielded antistring")
    def __radd__(self,other):
        "antistring right operand addition/annihilation operation"
        if isinstance(other,str):
            if other.endswith(self.s):
                print("BOOM!")
                return other[:-len(self.s)]
            else:
                raise ValueError("Operation results in dangerous unshielded antistring")

    def __repr__(self):
        "unambiguous string representation"
        return str(self)

3. Thermostat model

This exercise is about making a class that can change state depending on a specified pattern of responses to external input. It's not about operator overloading.

Imagine a simplified thermostat that controls heating and cooling of a hotel room.

It has three buttons: Up, Down, and Mode. It keeps track of the user's desired temperature, the room temperature, and it can turn two devices on or off: a heater and an air conditioner.

Pressing "up" increases the desired temperature by one degree, unless the system is in "off" mode in which case it does nothing. Pressing "down" increases the desired temperature by one degree, unless the system is in "off" mode in which case it does nothing. Pressing "mode" button cycles between operating modes in this order: heat -> cool -> auto -> off (after which it repeats the cycle, going back to Heat). When the system is in Heat, Cool, or Auto mode, it shows the desired temperature on its display panel. But in Off mode, it knows the last-set desired temperature, but does not show it anywhere.

The mode, desired temp, and room temp determine what the thermostat does as follows:

  • If the system is in Heat or Auto mode, the heater is turned on exactly when the room temperature is lower than the desired temperature; in any other mode, the heater is off.
  • If the system is in Cool or Auto mode, the AC is turned on exactly when the room temperature is higher than the desired temperature; in any other mode, the AC if off.

Write a class Thermostat with the following attributes and methods that can be used to simulate this system:

  • ac_is_on - attribute, a boolean, always indicating whether the AC is turned on
  • heat_is_on - attribute, a boolean, always indicating whether the heater is turned on
  • __init__(self) - method, initializes a new thermostat in which the mode is "off", and both the room and the desired temperature are 68.
  • room_temp(self,x) - method, tells the thermostat that the room temperature is x and have it react accordingly.
  • press(self,btn) - method, simulates the press of a button; the value of btn should be one of "up", "down", or "mode".
  • get_display(self) - method, retrieves the text currently displayed on the control panel, in one of these formats:
    • "72/cool" in cool mode (with 72 being the desired temp)
    • "65/heat" in heat mode (with 65 being the desired temp)
    • "70/auto" in auto mode (with 70 being the desired temp)
    • "--/off" in off mode
  • __str__(self) and __repr__(self) - methods that return the same thing, a string in this format:
Thermostat(mode="off",display="--/off",room=68,desired=68,ac_is_on=False,heat_is_on=False)

Hints:

  • Have attributes of the class that represent its current state (i.e. mode, room temp, desired temp)
  • The response to button presses will behave differently depending on aspects of that state, and may modify the state attributes

Below is an example of using the class, with commentary.

In [32]:
T = Thermostat()
print(T) # Shows initial state
Thermostat(mode="off",display="--/off",room=68,desired=68,ac_is_on=False,heat_is_on=False)
In [33]:
T.press("up") # In "off" mode, pressing up does nothing.  Still set to 68
print(T)
Thermostat(mode="off",display="--/off",room=68,desired=68,ac_is_on=False,heat_is_on=False)
In [34]:
T.press("mode") # Cycle from "off" to "heat" mode
# Room still at desired temp, so heater and AC are both off
print(T)
Thermostat(mode="heat",display="68/heat",room=68,desired=68,ac_is_on=False,heat_is_on=False)
In [35]:
T.room_temp(67) # Temp now too low, so the heater will turn on
print(T)
Thermostat(mode="heat",display="68/heat",room=67,desired=68,ac_is_on=False,heat_is_on=True)
In [36]:
T.press("down") # Desired temp goes down, now equal to room temp, so heater off
print(T)
Thermostat(mode="heat",display="67/heat",room=67,desired=67,ac_is_on=False,heat_is_on=False)
In [37]:
T.press("mode") # Cycle from "heat" to "cool" mode.  Heater and AC off.
print(T)
Thermostat(mode="cool",display="67/cool",room=67,desired=67,ac_is_on=False,heat_is_on=False)
In [38]:
T.press("mode") # Cycle from "cool" to "auto" mode.  Heater and AC off.
print(T)
Thermostat(mode="auto",display="67/auto",room=67,desired=67,ac_is_on=False,heat_is_on=False)
In [39]:
T.room_temp(72) # Room temp is now too high, AC will turn on.
print(T)
Thermostat(mode="auto",display="67/auto",room=72,desired=67,ac_is_on=True,heat_is_on=False)
In [40]:
T.room_temp(59) # Room temp is now too low, heater will turn on.
print(T)
Thermostat(mode="auto",display="67/auto",room=59,desired=67,ac_is_on=False,heat_is_on=True)
In [41]:
# Repeatedly lower the desired temp while in auto mode
# For a while, heat will stay on.
# Then the room temp and desired temp will be equal, and both heat and AC will be off
# Then the room temp will be higher than the desired temp, and the AC will turn on
for i in range(10):
    T.press("down")
    print(T)
Thermostat(mode="auto",display="66/auto",room=59,desired=66,ac_is_on=False,heat_is_on=True)
Thermostat(mode="auto",display="65/auto",room=59,desired=65,ac_is_on=False,heat_is_on=True)
Thermostat(mode="auto",display="64/auto",room=59,desired=64,ac_is_on=False,heat_is_on=True)
Thermostat(mode="auto",display="63/auto",room=59,desired=63,ac_is_on=False,heat_is_on=True)
Thermostat(mode="auto",display="62/auto",room=59,desired=62,ac_is_on=False,heat_is_on=True)
Thermostat(mode="auto",display="61/auto",room=59,desired=61,ac_is_on=False,heat_is_on=True)
Thermostat(mode="auto",display="60/auto",room=59,desired=60,ac_is_on=False,heat_is_on=True)
Thermostat(mode="auto",display="59/auto",room=59,desired=59,ac_is_on=False,heat_is_on=False)
Thermostat(mode="auto",display="58/auto",room=59,desired=58,ac_is_on=True,heat_is_on=False)
Thermostat(mode="auto",display="57/auto",room=59,desired=57,ac_is_on=True,heat_is_on=False)

Solution

In [31]:
class Thermostat:
    "Thermostat module class models thermostat status and state transitions"
    def __init__(self):
        "Initialize a new thermostat object with default settings"
        self.next_mode = {
            "off": "heat",
            "heat": "cool",
            "cool": "auto",
            "auto": "off",
        }
        self.mode = "off"
        self.room = 68
        self.desired = 68
        self.ac_is_on = False
        self.heat_is_on = False
        
    def room_temp(self,x):
        "set the room temperature and update state status"
        self.room = x
        self.update()
    
    def ac_is_on(self):
        "is the ac on?"
        return self.ac_is_on
    
    def heat_is_on(self):
        "is the heat on?"
        return self.heat_is_on
        
    def get_display(self):
        "retrieves the control panel text"
        if self.mode == "off":
            numstr = "--"
        else:
            numstr = str(self.desired)
        return numstr + "/" + self.mode
    
    def press(self,btn):
        "pressing a button on panel and adjusting temperature settings"
        if btn == "mode":
            self.mode = self.next_mode[self.mode]
        else:
            if self.mode == "off":
                return
            elif btn == "up":
                self.desired += 1
            elif btn == "down":
                self.desired -= 1
        self.update()
            
    def update(self):
        "adjusts heat/ac control based on desired temp and room temp"
        self.heat_is_on = (self.mode in ["heat","auto"]) and (self.room < self.desired)
        self.ac_is_on = (self.mode in ["ac","auto"]) and (self.room > self.desired)
    
    def __str__(self):
        "readable display of thermostat status data"
        return "Thermostat(mode=\"{}\",display=\"{}\",room={},desired={},ac_is_on={},heat_is_on={})".format(
        self.mode,
        self.get_display(),
        self.room,
        self.desired,
        self.ac_is_on,
        self.heat_is_on)
    def __repr__(self):
        "readable display of thermostat status data"
        return str(self)