Tutorial#

You will write some code to create and manipulate quadratic expressions. With sympy this is not necessary as all functionality required is available within sympy however this will be a good exercise in understanding how to build such functionality.

Problem

Consider the following quadratics:

\[\begin{split} f(x) = 5 x ^ 2 + 2 x - 7\\ g(x) = - 4 x ^ 2 - 3 x + 12\\ h(x) = f(x) + g(x) \end{split}\]

Without using sympy, obtain the roots for all the quadratics.

Start by defining an object to represent a quadratic. This is called a class.

import math


class QuadraticExpression:
    """A class for a quadratic expression"""

    def __init__(self, a, b, c):
        self.a = a
        self.b = b
        self.c = c
        self.discriminant = self.b ** 2 - 4 * self.a * self.c

    def get_roots(self):
        """
        Return the real valued roots of the quadratic expression

        Returns
        -------
        array
            The roots of the quadratic
        """
        if self.discriminant >= 0:
            x1 = -(self.b + math.sqrt(self.discriminant)) / (2 * self.a)
            x2 = -(self.b - math.sqrt(self.discriminant)) / (2 * self.a)
            return x1, x2
        return ()

    def __add__(self, other):
        """A magic method: let's us have addition between expressions"""
        return QuadraticExpression(self.a + other.a, self.b + other.b, self.c + other.c)

    def __repr__(self):
        """A magic method: changes the default way an instance is displayed"""
        return f"Quadratic expression: {self.a} x ^ 2 + {self.b} x + {self.c}"

Tip

Four functions were created with this class:

  • __init__: as this is surrounded by __ (two underscores) this is a magic function that is run when we create an instance of our class.

  • get_roots: this returns the two real valued roots if the discriminant is positive.

  • __add__: another magic function that is run when the + operator is used.

  • __repr__: another magic function that gives the string representation of the instance.

Now use this class to solve the specified problem. First create instances of the class that correspond to \(f\) and \(g\). This is using the __init__ function in the background.

f = QuadraticExpression(a=5, b=2, c=-7)
g = QuadraticExpression(a=-4, b=-3, c=12)

You can now take a look at both of these instances. This is using the __repr__ function in the background:

f
Quadratic expression: 5 x ^ 2 + 2 x + -7
g
Quadratic expression: -4 x ^ 2 + -3 x + 12

Now you are going to create \(h(x) = f(x) + g(x)\). This is using the __add__ function in the background:

h = f + g
h
Quadratic expression: 1 x ^ 2 + -1 x + 5

You can now iterate over our quadratics and find the roots. This is using the get_roots function in the background:

roots = [quadratic.get_roots() for quadratic in (f, g, h)]
roots
[(-1.4, 1.0), (1.3971808598447282, -2.1471808598447284), ()]

Note that \(f\) and \(g\) have real valued roots but \(h\) does not. You can check the value of the discriminant of \(h\):

h.discriminant
-19

You are going to create a new class from QuadraticExpression replacing the get_roots function with a new one that can handle imaginary roots and update the __add__ function to return an instance of the new class.

class QuadraticExpressionWithAllRoots(QuadraticExpression):
    """
    A class for a quadratic expression that can return imaginary roots

    The `get_roots` function returns two tuples of the form (re, im) where re is
    the real part and im is the imaginary part.
    """

    def get_roots(self):
        """
        Return the real valued roots of the quadratic expression

        Returns
        -------
        array
            The roots of the quadratic
        """
        if self.discriminant >= 0:
            x1 = -(self.b + math.sqrt(self.discriminant)) / (2 * self.a)
            x2 = -(self.b - math.sqrt(self.discriminant)) / (2 * self.a)
            return (x1, 0), (x2, 0)

        real_part = self.b / (2 * self.a)
        im1 = math.sqrt(-self.discriminant) / (2 * self.a)
        im2 = -math.sqrt(-self.discriminant) / (2 * self.a)
        return ((real_part, im1), (real_part, im2))

    def __add__(self, other):
        """A special method: let's us have addition between expressions"""
        return QuadraticExpressionWithAllRoots(
            self.a + other.a, self.b + other.b, self.c + other.c
        )

Now define the quadratics once again but using this new class:

f = QuadraticExpressionWithAllRoots(a=5, b=2, c=-7)
g = QuadraticExpressionWithAllRoots(a=-4, b=-3, c=12)
h = f + g
f
Quadratic expression: 5 x ^ 2 + 2 x + -7
g
Quadratic expression: -4 x ^ 2 + -3 x + 12
h
Quadratic expression: 1 x ^ 2 + -1 x + 5

Attention

There is no need to redefine __init__, or __repr__ as the new class inherits these from QuadraticExpression due to this statement:

class QuadraticExpressionWithAllRoots(QuadraticExpression):

You can now get all the roots for the quadratics:

roots = [quadratic.get_roots() for quadratic in (f, g, h)]
roots
[((-1.4, 0), (1.0, 0)),
 ((1.3971808598447282, 0), (-2.1471808598447284, 0)),
 ((-0.5, 2.179449471770337), (-0.5, -2.179449471770337))]