Creating an Angle Class in Python

In this project I will write a Python class to represent angles in various units, and provide a selection of arithmetic and comparison operators.

Overview

There is a surprising number of different units used to measure angles. Dealing with them in code can be fiddly for a number of reasons so I have written a class to make the process easier.

The class records not only the angle itself but also the unit you wish to use, for which there is a choice of no less than seven. Whichever unit you choose, the angle value itself is stored in seconds. A second (strictly second of arc or arcsecond) is one sixtieth of a minute (minute of arc or arcminute), which is one sixtieth of a degree. Yes, degrees are sliced up in the same way as hours. The reason for the value being stored in arcseconds is to simplify calculations and comparisons using different instances of the Angle class which may use different units.

In addition to a value and unit the class also provides a selection of arithmetic and comparison operators to simplify using instances in expressions. Due to roundings and slight inaccuracies caused by using Pi to calculate radian values, different angles which are intended to be equal might be slightly different, messing up our comparison operators. I have therefore provided a function to test for approximate equality which uses the rather obscure isclose method provided by Python's math module.

Finally, the __str__ dunder method is implemented to provide a standard and human-friendly string representation of the value and unit.

Now let's take a brief look at the seven units of angle provide by the Angle class.

  • Degrees, minutes and seconds - 360 in a circle, each degree is divided into 60 arcminutes, which are divided into 60 arcseconds

  • Radians - see below

  • Gradians - 400 in a circle, therefore 100 in a right angle

  • Turn - 1 in a circle

  • Hour Angle - 24 in a circle

  • Point - one unit of the 32 points of a compass

  • Quadrant - 4 in a circle

Radians

The diagram below illustrates the radian. If you draw a line from the centre of a circle to the edge, another the same length round the circle, and a third back to the centre then the angle where the lines meet at the centre is 1 radian. There are π radians in 180 degrees, and the number of degrees in 1 radian is 180 / π, or approximately 57.29577951. As π is irrational it is impossible to calculate this with absolute accuracy.

The Project

This project consists of the following files:

  • angle.py

  • angledemo.py

The files can be downloaded as a zip, or you can clone/download the Github repository if you prefer.

Source Code Links

ZIP File
GitHub

The angle.py file contains the Angle class, and the angledemo.py contains various functions to show the class in action.

The Code

This is the full code listing for the Angle class.

angle.py

from types import MappingProxyType
import math


class Angle(object):

    """
    The Angle class stores an angle value
    in a selection of units,
    and provides  methods for string representation,
    and arithmetic and comparison operators
    """

    units = MappingProxyType({

                    "degreeminutesecond":
                        {
                            "name": "degreeminutesecond",
                            "toseconds": lambda dms: (dms[0] * 3600) + (dms[1] * 60) + (dms[2]),
                            "fromseconds": lambda s: (s // 3600, (s - ((s // 3600)*3600))//60, s - ((s // 3600)*3600) - (((s - ((s // 3600)*3600))//60)*60)),
                            "tostring": lambda v: f"{v[0]}° {v[1]}′ {v[2]}″"
                        },

                    "radian":
                        {
                            "name": "radian",
                            "toseconds": lambda r: r * ((180 / math.pi) * 3600),
                            "fromseconds": lambda s: (s / 3600) / (180 / math.pi),
                            "tostring": lambda v: f"{v} rad"
                        },

                    "gradian":
                        {
                            "name": "gradian",
                            "toseconds": lambda g: g * 3240,
                            "fromseconds": lambda s: s / 3240,
                            "tostring": lambda v: f"{v} gon"
                        },

                    "turn":
                        {
                            "name": "turn",
                            "toseconds": lambda t: t * 1296000,
                            "fromseconds": lambda s: s / 1296000,
                            "tostring": lambda v: f"{v} tr"
                        },

                    "hourangle":
                        {
                            "name": "hourangle",
                            "toseconds": lambda ha: ha * 54000,
                            "fromseconds": lambda s: s / 54000,
                            "tostring": lambda v: f"{v} ha"
                        },

                    "point":
                        {
                            "name": "point",
                            "toseconds": lambda p: p * 40500,
                            "fromseconds": lambda s: s / 40500,
                            "tostring": lambda v: f"{v} pt"
                        },

                    "quadrant":
                        {
                            "name": "quadrant",
                            "toseconds": lambda q: q * 324000,
                            "fromseconds": lambda s: s / 324000,
                            "tostring": lambda v: f"{v} quad"
                        }

               })


    def __init__(self, value = 0, unit = units["degreeminutesecond"]):

        self.seconds = unit["toseconds"](value)
        self.unit = unit


    @property
    def value(self):
        return self.unit["fromseconds"](self.seconds)


    @value.setter
    def value(self, value):

        self.seconds = self.unit["toseconds"](value)


    def __str__(self):

        value = self.unit['fromseconds'](self.seconds)
        
        return f"{self.unit['tostring'](value)}"


    def approx_equal(self, other):

        return math.isclose(self.seconds, other.seconds)

    
    # arithmetic methods


    def __add__(self, other):

        seconds = self.seconds + other.seconds
        value = self.unit['fromseconds'](seconds)

        return Angle(value, self.unit)


    def __sub__(self, other):

        seconds = self.seconds - other.seconds
        value = self.unit['fromseconds'](seconds)

        return Angle(value, self.unit)


    def __mul__(self, value):

        seconds = self.seconds * value
        value = self.unit['fromseconds'](seconds)

        return Angle(value, self.unit)


    def __rmul__(self, value):
        
        seconds = value * self.seconds
        value = self.unit['fromseconds'](seconds)

        return Angle(value, self.unit)


    def __truediv__(self, value):
        
        seconds = self.seconds / value
        value = self.unit['fromseconds'](seconds)

        return Angle(value, self.unit)

    
    # comparison methods


    def __lt__(self, other):

        return self.seconds < other.seconds


    def __le__(self, other):

        return self.seconds <= other.seconds


    def __eq__(self, other):

        return self.seconds == other.seconds


    def __gt__(self, other):

        return self.seconds > other.seconds


    def __ge__(self, other):

        return self.seconds >= other.seconds


    def __ne__(self, other):

        return self.seconds != other.seconds

units

This consists of a MappingProxyType (basically an immutable dictionary) with the unit names as keys and inner dictionaries as values. As well as a name the inner dictionaries contain three functions to convert the value to and from seconds, and provide a human readable representation.

The logic associated with each unit is therefore kept together in one neat package, and the whole is easily extensible for other units. You might consider this "design pattern" to be ingenious, or on the other hand you might find it freakish and bizarre . . . !

__init__

This is pretty straightforward, just setting the variables for the value and unit. Note though that the value is converted to seconds using the method associated with the relevant unit.

value Property

As values are stored as seconds the value getter converts seconds to the actual unit before returning it. Similarly, the setter converts units to seconds.

__str__

Here we convert the value from seconds, and then call the unit's tostring method.

approx_equal

As I mentioned above small inaccuracies can creep into the seconds value used to store all values. This method allows the values of two instances of the Angle class to be compared for equality even if they are slightly different. It uses the math.isclose function provided for us by Python.

The isclose can take optional rel_tol (relative tolerance) or abs_tol (absolute tolerance) arguments although here I use the default, rel_tol=1e-09, which the Python documentation states "assures that the two values are the same within about 9 decimal digits".

It's worth taking a minute to read the math.isclose function's documentation.

https://docs.python.org/3/library/math.html#math.isclose

Arithmetic Operators

These implement the various arithmetic dunder methods. The actual arithmetic is carried out on the seconds values so that we can carry out calculations with angles of different units. Then the value in the unit of the first angle is calculated, and a new Angle instance returned, again with the unit of the first angle.

Comparison Operators

These are all straightforward one-line implementations, again using seconds to enable angles of different units to be compared.

Let's Try It Out . . .

The Angle class is now complete so we need some bits of code to try it out.

angledemo.py

import math

import angle


def main():

    print("-----------------")
    print("| codedrome.com |")
    print("| Angles        |")
    print("-----------------\n")

    create_and_output()

    # set_value()

    # arithmetic()

    # comparisons()

    # approx_equal()

    # convert_units()


def create_and_output():

    dms = angle.Angle((27,14,33), angle.Angle.units["degreeminutesecond"])
    print(dms.unit["name"])
    print(f"seconds      {dms.seconds}")
    print(f"value        {dms.value}")
    print(f"__str__      {dms}")

    print()

    r = angle.Angle(2 * math.pi, angle.Angle.units["radian"])
    print(r.unit["name"])
    print(f"seconds      {r.seconds}")
    print(f"value        {r.value}")
    print(f"__str__      {r}")

    print()

    g = angle.Angle(400, angle.Angle.units["gradian"])
    print(g.unit["name"])
    print(f"seconds      {g.seconds}")
    print(f"value        {g.value}")
    print(f"__str__      {g}")

    print()

    t = angle.Angle(1, angle.Angle.units["turn"])
    print(t.unit["name"])
    print(f"seconds      {t.seconds}")
    print(f"value        {t.value}")
    print(f"__str__      {t}")

    print()

    ha = angle.Angle(24, angle.Angle.units["hourangle"])
    print(ha.unit["name"])
    print(f"seconds      {ha.seconds}")
    print(f"value        {ha.value}")
    print(f"__str__      {ha}")

    print()

    p = angle.Angle(32, angle.Angle.units["point"])
    print(p.unit["name"])
    print(f"seconds      {p.seconds}")
    print(f"value        {p.value}")
    print(f"__str__      {p}")

    print()

    q = angle.Angle(4, angle.Angle.units["quadrant"])
    print(q.unit["name"])
    print(f"seconds      {q.seconds}")
    print(f"value        {q.value}")
    print(f"__str__      {q}")


def set_value():

    dms = angle.Angle((7,12,39), angle.Angle.units["degreeminutesecond"])
    print(dms)

    dms.value = (29,45,12)
    print(dms)


def arithmetic():

    dms = angle.Angle((45,0,0), angle.Angle.units["degreeminutesecond"])
    q = angle.Angle(1, angle.Angle.units["quadrant"])

    dms_plus_q = dms + q
    q_minus_dms = q - dms
    dms_times_two = dms * 2
    three_times_dms = 3 * dms
    dms_div_nine = dms / 9

    print(f"dms              {dms}")
    print(f"q                {q}")

    print()

    print(f"dms_plus_q       {dms_plus_q}")
    print(f"q_minus_dms      {q_minus_dms}")
    print(f"dms_times_two    {dms_times_two}")
    print(f"three_times_dms  {three_times_dms}")
    print(f"dms_div_nine     {dms_div_nine}")


def comparisons():

    dms = angle.Angle((90,0,0), angle.Angle.units["degreeminutesecond"])
    q = angle.Angle(1, angle.Angle.units["quadrant"])
    r = angle.Angle(1, angle.Angle.units["radian"])

    print(f"{r} < {dms}   = {r < dms}")
    print(f"{dms} <= {q} = {dms <= q}")
    print(f"{dms} == {r}  = {dms == r}")
    print(f"{dms} > {r}   = {dms > r}")
    print(f"{q} >= {dms} = {q >= dms}")
    print(f"{r} != {dms}  = {r != dms}")


def approx_equal():

    dms = angle.Angle((360,0,0), angle.Angle.units["degreeminutesecond"])
    r = angle.Angle(2 * math.pi, angle.Angle.units["radian"])

    print(f"dms.seconds           {dms.seconds}")
    print(f"r.seconds             {r.seconds}")
    print(f"dms == r              {dms == r}")

    print()

    dms = dms / 3
    r = r / 3

    print(f"dms.seconds           {dms.seconds}")
    print(f"r.seconds             {r.seconds}")
    print(f"dms == r              {dms == r}")
    print(f"dms.approx_equal(r)   {dms.approx_equal(r)}")


def convert_units():

    right_angle = angle.Angle((90,0,0), angle.Angle.units["degreeminutesecond"])
    print(right_angle.unit["name"])
    print(right_angle)

    print()

    right_angle.unit = angle.Angle.units["quadrant"]
    print(right_angle.unit["name"])
    print(right_angle)

    print()

    right_angle.unit = angle.Angle.units["gradian"]
    print(right_angle.unit["name"])
    print(right_angle)


if __name__ == "__main__":

    main()

The main Function

This just has six calls to functions demoing the various aspects of the Angle class. We can comment/uncomment them and run one at a time.

The create_and_output Function

For each of the seven units an Angle instance is created, and the unit name, seconds value, unit value and string value are printed.

For the degreeminutesecond unit a tuple is passed to __init__, the other units just needing a single value.

Run the code like this:

Running the Program

python3 angledemo.py

This is the output of the create_and_output function.

Program Output - create_and_output function

degreeminutesecond
seconds      98073
value        (27, 14, 33)
__str__      27° 14′ 33″

radian
seconds      1296000.0
value        6.283185307179586
__str__      6.283185307179586 rad

gradian
seconds      1296000
value        400.0
__str__      400.0 gon

turn
seconds      1296000
value        1.0
__str__      1.0 tr

hourangle
seconds      1296000
value        24.0
__str__      24.0 ha

point
seconds      1296000
value        32.0
__str__      32.0 pt

quadrant
seconds      1296000
value        4.0
__str__      4.0 quad

The set_value Function

This is to demonstrate that value can be set, but the new value needs to be in the Angle instance's unit, in this case a tuple of degrees, minutes and seconds.

Uncomment set_value() in main and run the program. This is the output.

Program Output - set_value function

7° 12′ 39″
29° 45′ 12″

The arithmetic Function

Firstly we create two Angle objects, one with a unit of degreeminutesecond and the other with quadrant. This is to show that it is possible to mix up units.

Next five new Angle objects are created, each the result of a calculation using the first two angles. There are two multiplications, dms * 2 which uses __mul__, and 3 * dms which uses __rmul__.

We then print the original two angles followed by the various results. Uncomment arithmetic() in main and run the program again.

Program Output - arithmetic function

dms              45° 0′ 0″
q                1.0 quad

dms_plus_q       135° 0′ 0″
q_minus_dms      0.5 quad
dms_times_two    90° 0′ 0″
three_times_dms  135° 0′ 0″
dms_div_nine     5.0° 0.0′ 0.0″

The comparisons Function

Three Angle objects are created and then used with the six comparison operators within print functions. As with the arithmetic function the angles use different units just to show that we can.

This is the output from the comparisons Function.

Program Output - comparisons function

1.0 rad < 90° 0′ 0″   = True
90° 0′ 0″ <= 1.0 quad = True
90° 0′ 0″ == 1.0 rad  = False
90° 0′ 0″ > 1.0 rad   = True
1.0 quad >= 90° 0′ 0″ = True
1.0 rad != 90° 0′ 0″  = True

The approx_equal Function

Firstly I created two Angle objects, the first for 360 degrees and the second for 2π radians, the two being equivalent. If you sneak at the output below you'll see that the equality operator returns true as both values resolve to the same value in seconds.

However, dividing each by 3 introduces a tiny difference between the seconds values so the == operator returns false, but approx_equal returns true as the two values are within the tolerances.

Program Output - approx_equal function

dms.seconds           1296000
r.seconds             1296000.0
dms == r              True

dms.seconds           432000.0
r.seconds             431999.99999999994
dms == r              False
dms.approx_equal(r)   True

The convert_units Function

Changing the unit of an Angle object is easy - just do it. As the value is stored as seconds changing the unit has no effect on it, but in future output will of course be shown in the new unit. In the convert_units function we start of with a degreeminutesecond angle, then convert it to quadrant, and finally to gradian. As you can see from the output below the value is retained and output correctly.

Program Output - convert_units function

degreeminutesecond
90° 0′ 0″

quadrant
1.0 quad

gradian
100.0 gon

I hope you found this article interesting and useful. Even if you have no need for an Angle class a few of the language features such as arithmetic and comparison operators, and the math.isclose function, might be usful for other projects.