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
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.