Skip to content

Commit 167c924

Browse files
authored
feat: implement shoelace formula for polygon area calculation (#13815)
* feat: implement shoelace formula for polygon area calculation * feat: add initial bearing calculation function between geographic points
1 parent baadf5e commit 167c924

2 files changed

Lines changed: 148 additions & 0 deletions

File tree

maths/bearing.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
"""
2+
Initial bearing (forward azimuth) between two geographic points.
3+
4+
Points are (latitude, longitude) in decimal degrees. Returns bearing in degrees
5+
clockwise from true north in the range [0, 360).
6+
7+
Reference: https://en.wikipedia.org/wiki/Bearing_(navigation)
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import math
13+
14+
15+
def initial_bearing(
16+
origin: tuple[float, float], destination: tuple[float, float]
17+
) -> float:
18+
"""
19+
Compute the initial bearing from `origin` to `destination`.
20+
21+
Parameters
22+
----------
23+
origin, destination : tuple[float, float]
24+
(latitude, longitude) in decimal degrees.
25+
26+
Returns
27+
-------
28+
float
29+
Initial bearing in degrees, clockwise from north in [0, 360).
30+
31+
Raises
32+
------
33+
TypeError
34+
If inputs are not 2-tuples of numbers.
35+
ValueError
36+
If the two points are identical (bearing undefined).
37+
38+
Examples
39+
>>> round(initial_bearing((50.066389, -5.714722), (58.643889, -3.07)), 3)
40+
9.12
41+
>>> round(initial_bearing((0.0, 0.0), (1.0, 1.0)), 3)
42+
44.996
43+
>>> initial_bearing((0.0, 0.0), (0.0, 0.0))
44+
Traceback (most recent call last):
45+
...
46+
ValueError: origin and destination are the same point; bearing is undefined
47+
"""
48+
try:
49+
lat1, lon1 = float(origin[0]), float(origin[1])
50+
lat2, lon2 = float(destination[0]), float(destination[1])
51+
except Exception as exc:
52+
raise TypeError(
53+
"origin and destination must be 2-tuples of numeric values"
54+
) from exc
55+
56+
if lat1 == lat2 and lon1 == lon2:
57+
raise ValueError(
58+
"origin and destination are the same point; bearing is undefined"
59+
)
60+
61+
# convert degrees to radians
62+
phi1 = math.radians(lat1)
63+
phi2 = math.radians(lat2)
64+
delta_lambda = math.radians(lon2 - lon1)
65+
66+
x = math.sin(delta_lambda) * math.cos(phi2)
67+
y = math.cos(phi1) * math.sin(phi2) - math.sin(phi1) * math.cos(phi2) * math.cos(
68+
delta_lambda
69+
)
70+
71+
theta = math.atan2(x, y) # result in radians relative to north
72+
bearing = (math.degrees(theta) + 360.0) % 360.0
73+
return bearing
74+
75+
76+
if __name__ == "__main__":
77+
# simple demonstration
78+
print(initial_bearing((50.066389, -5.714722), (58.643889, -3.07)))

maths/shoelace_area.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""
2+
Shoelace formula (Gauss's area formula) for polygon area.
3+
4+
The function accepts an iterable of (x, y) pairs and returns the polygon area
5+
as a non-negative float.
6+
7+
References:
8+
- https://en.wikipedia.org/wiki/Shoelace_formula
9+
"""
10+
11+
from __future__ import annotations
12+
13+
from collections.abc import Iterable, Sequence
14+
15+
16+
def shoelace_area(points: Iterable[tuple[float, float]]) -> float:
17+
"""
18+
Compute the area of a simple polygon using the shoelace formula.
19+
20+
Parameters
21+
----------
22+
points:
23+
Iterable of (x, y) coordinate pairs. Points may be ints or floats.
24+
The polygon is assumed closed (the function will wrap the last point
25+
to the first).
26+
27+
Returns
28+
-------
29+
float
30+
Non-negative area of the polygon.
31+
32+
Raises
33+
------
34+
ValueError
35+
If fewer than 3 points are provided.
36+
TypeError
37+
If points are not pairs of numbers.
38+
39+
Examples
40+
>>> shoelace_area([(0, 0), (4, 0), (0, 3)])
41+
6.0
42+
>>> shoelace_area([(0, 0), (1, 0), (1, 1), (0, 1)])
43+
1.0
44+
>>> shoelace_area(list(reversed([(0, 0), (2, 0), (2, 2), (0, 2)])))
45+
4.0
46+
>>> shoelace_area([(0, 0), (2, 0), (2, 2), (0, 2)])
47+
4.0
48+
"""
49+
pts = list(points)
50+
n = len(pts)
51+
if n < 3:
52+
raise ValueError("At least 3 points are required to form a polygon")
53+
54+
try:
55+
coords: Sequence[tuple[float, float]] = [(float(x), float(y)) for x, y in pts]
56+
except Exception as exc:
57+
raise TypeError("points must be an iterable of (x, y) numeric pairs") from exc
58+
59+
s = 0.0
60+
for i in range(n):
61+
x1, y1 = coords[i]
62+
x2, y2 = coords[(i + 1) % n]
63+
s += x1 * y2 - x2 * y1
64+
65+
return abs(s) / 2.0
66+
67+
68+
if __name__ == "__main__":
69+
example = [(0, 0), (4, 0), (0, 3)]
70+
print("example area:", shoelace_area(example))

0 commit comments

Comments
 (0)