mirror of
https://github.com/TheAlgorithms/Python.git
synced 2026-09-28 21:45:27 +08:00
6421e9cb73
* Ellipse perimeter calculation Updated the perimeter calculation for the Ellipse class to use Ramanujan's approximation * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Improve documentation for Side and ellipse perimeter Updated docstring for Side class and added reference for ellipse perimeter calculation. --------- Co-authored-by: Christian Clauss <cclauss@me.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
485 lines
14 KiB
Python
485 lines
14 KiB
Python
from __future__ import annotations
|
|
|
|
import math
|
|
from dataclasses import dataclass, field
|
|
from types import NoneType
|
|
from typing import Self
|
|
|
|
# Building block classes
|
|
|
|
|
|
@dataclass
|
|
class Angle:
|
|
"""
|
|
An Angle in degrees (unit of measurement)
|
|
|
|
>>> Angle()
|
|
Angle(degrees=90)
|
|
>>> Angle(45.5)
|
|
Angle(degrees=45.5)
|
|
>>> Angle(-1)
|
|
Traceback (most recent call last):
|
|
...
|
|
TypeError: degrees must be a numeric value between 0 and 360.
|
|
>>> Angle(361)
|
|
Traceback (most recent call last):
|
|
...
|
|
TypeError: degrees must be a numeric value between 0 and 360.
|
|
"""
|
|
|
|
degrees: float = 90
|
|
|
|
def __post_init__(self) -> None:
|
|
if not isinstance(self.degrees, (int, float)) or not 0 <= self.degrees <= 360:
|
|
raise TypeError("degrees must be a numeric value between 0 and 360.")
|
|
|
|
def to_radians(self) -> float:
|
|
"""
|
|
>>> Angle(90).to_radians()
|
|
1.5707963267948966
|
|
"""
|
|
return math.radians(self.degrees)
|
|
|
|
@classmethod
|
|
def from_radians(cls, radians: float) -> Angle:
|
|
"""
|
|
>>> Angle.from_radians(math.pi / 2)
|
|
Angle(degrees=90.0)
|
|
"""
|
|
degrees = math.degrees(radians) % 360 # Normalize to 0-360
|
|
return cls(degrees)
|
|
|
|
|
|
@dataclass
|
|
class Side:
|
|
"""
|
|
A side of a two-dimensional shape such as Polygon, etc.
|
|
adjacent_sides: a list of sides which are adjacent to the current side
|
|
angle: the angle in degrees between each adjacent side
|
|
length: the length of the current side in meters
|
|
|
|
>>> Side(5)
|
|
Side(length=5, angle=Angle(degrees=90), next_side=None)
|
|
>>> Side(5, Angle(45.6))
|
|
Side(length=5, angle=Angle(degrees=45.6), next_side=None)
|
|
>>> Side(5, Angle(45.6), Side(1, Angle(2))) # doctest: +ELLIPSIS
|
|
Side(length=5, angle=Angle(degrees=45.6), next_side=Side(length=1, angle=Angle(d...
|
|
>>> Side(-1)
|
|
Traceback (most recent call last):
|
|
...
|
|
TypeError: length must be a positive numeric value.
|
|
>>> Side(5, None)
|
|
Traceback (most recent call last):
|
|
...
|
|
TypeError: angle must be an Angle object.
|
|
>>> Side(5, Angle(90), "Invalid next_side")
|
|
Traceback (most recent call last):
|
|
...
|
|
TypeError: next_side must be a Side or None.
|
|
"""
|
|
|
|
length: float
|
|
angle: Angle = field(default_factory=Angle)
|
|
next_side: Side | None = None
|
|
|
|
def __post_init__(self) -> None:
|
|
if not isinstance(self.length, (int, float)) or self.length <= 0:
|
|
raise TypeError("length must be a positive numeric value.")
|
|
if not isinstance(self.angle, Angle):
|
|
raise TypeError("angle must be an Angle object.")
|
|
if not isinstance(self.next_side, (Side, NoneType)):
|
|
raise TypeError("next_side must be a Side or None.")
|
|
|
|
|
|
@dataclass
|
|
class Ellipse:
|
|
"""
|
|
A geometric Ellipse on a 2D surface
|
|
|
|
>>> Ellipse(5, 10)
|
|
Ellipse(major_radius=5, minor_radius=10)
|
|
>>> Ellipse(5, 10) is Ellipse(5, 10)
|
|
False
|
|
>>> Ellipse(5, 10) == Ellipse(5, 10)
|
|
True
|
|
"""
|
|
|
|
major_radius: float
|
|
minor_radius: float
|
|
|
|
@property
|
|
def area(self) -> float:
|
|
"""
|
|
>>> Ellipse(5, 10).area
|
|
157.07963267948966
|
|
"""
|
|
return math.pi * self.major_radius * self.minor_radius
|
|
|
|
@property
|
|
def perimeter(self) -> float:
|
|
"""
|
|
Use Ramanujan's first approximation for ellipse perimeter.
|
|
https://en.wikipedia.org/wiki/Perimeter_of_an_ellipse#First_approximation
|
|
|
|
>>> Ellipse(5, 10).perimeter
|
|
48.44210548835644
|
|
>>> Ellipse(1, 10).perimeter
|
|
40.60552518514097
|
|
"""
|
|
a, b = self.major_radius, self.minor_radius
|
|
return math.pi * (3 * (a + b) - ((3 * a + b) * (a + 3 * b)) ** 0.5)
|
|
|
|
@property
|
|
def eccentricity(self) -> float:
|
|
"""
|
|
>>> Ellipse(5, 10).eccentricity
|
|
0.8660254037844386
|
|
>>> Circle(5).eccentricity
|
|
0.0
|
|
"""
|
|
a, b = (
|
|
max(self.major_radius, self.minor_radius),
|
|
min(self.major_radius, self.minor_radius),
|
|
)
|
|
return math.sqrt(1 - (b / a) ** 2)
|
|
|
|
|
|
class Circle(Ellipse):
|
|
"""
|
|
A geometric Circle on a 2D surface
|
|
|
|
>>> Circle(5)
|
|
Circle(radius=5)
|
|
>>> Circle(5) is Circle(5)
|
|
False
|
|
>>> Circle(5) == Circle(5)
|
|
True
|
|
>>> Circle(5).area
|
|
78.53981633974483
|
|
>>> Circle(5).perimeter
|
|
31.41592653589793
|
|
"""
|
|
|
|
def __init__(self, radius: float) -> None:
|
|
super().__init__(radius, radius)
|
|
self.radius = radius
|
|
|
|
def __repr__(self) -> str:
|
|
return f"Circle(radius={self.radius})"
|
|
|
|
@property
|
|
def diameter(self) -> float:
|
|
"""
|
|
>>> Circle(5).diameter
|
|
10
|
|
"""
|
|
return self.radius * 2
|
|
|
|
def max_parts(self, num_cuts: float) -> float:
|
|
"""
|
|
Return the maximum number of parts that circle can be divided into if cut
|
|
'num_cuts' times.
|
|
|
|
>>> circle = Circle(5)
|
|
>>> circle.max_parts(0)
|
|
1.0
|
|
>>> circle.max_parts(7)
|
|
29.0
|
|
>>> circle.max_parts(54)
|
|
1486.0
|
|
>>> circle.max_parts(22.5)
|
|
265.375
|
|
>>> circle.max_parts(-222)
|
|
Traceback (most recent call last):
|
|
...
|
|
TypeError: num_cuts must be a positive numeric value.
|
|
>>> circle.max_parts("-222")
|
|
Traceback (most recent call last):
|
|
...
|
|
TypeError: num_cuts must be a positive numeric value.
|
|
"""
|
|
if not isinstance(num_cuts, (int, float)) or num_cuts < 0:
|
|
raise TypeError("num_cuts must be a positive numeric value.")
|
|
return (num_cuts + 2 + num_cuts**2) * 0.5
|
|
|
|
|
|
@dataclass
|
|
class Polygon:
|
|
"""
|
|
An abstract class which represents Polygon on a 2D surface.
|
|
|
|
>>> Polygon()
|
|
Polygon(sides=[])
|
|
>>> polygon = Polygon()
|
|
>>> polygon.add_side(Side(5)).get_side(0)
|
|
Side(length=5, angle=Angle(degrees=90), next_side=None)
|
|
>>> polygon.get_side(1)
|
|
Traceback (most recent call last):
|
|
...
|
|
IndexError: list index out of range
|
|
>>> polygon.set_side(0, Side(10)).get_side(0)
|
|
Side(length=10, angle=Angle(degrees=90), next_side=None)
|
|
>>> polygon.set_side(1, Side(10))
|
|
Traceback (most recent call last):
|
|
...
|
|
IndexError: list assignment index out of range
|
|
"""
|
|
|
|
sides: list[Side] = field(default_factory=list)
|
|
|
|
def add_side(self, side: Side) -> Self:
|
|
"""
|
|
>>> polygon = Polygon()
|
|
>>> _ = polygon.add_side(Side(5, Angle(90)))
|
|
>>> _ = polygon.add_side(Side(10, Angle(90)))
|
|
>>> polygon.sides[0].next_side == polygon.sides[1]
|
|
True
|
|
"""
|
|
if self.sides:
|
|
self.sides[-1].next_side = side
|
|
self.sides.append(side)
|
|
return self
|
|
|
|
def get_side(self, index: int) -> Side:
|
|
"""
|
|
>>> Polygon().get_side(0)
|
|
Traceback (most recent call last):
|
|
...
|
|
IndexError: list index out of range
|
|
>>> Polygon().add_side(Side(5)).get_side(-1)
|
|
Side(length=5, angle=Angle(degrees=90), next_side=None)
|
|
"""
|
|
return self.sides[index]
|
|
|
|
def set_side(self, index: int, side: Side) -> Self:
|
|
"""
|
|
>>> Polygon().set_side(0, Side(5))
|
|
Traceback (most recent call last):
|
|
...
|
|
IndexError: list assignment index out of range
|
|
>>> Polygon().add_side(Side(5)).set_side(0, Side(10))
|
|
Polygon(sides=[Side(length=10, angle=Angle(degrees=90), next_side=None)])
|
|
"""
|
|
self.sides[index] = side
|
|
return self
|
|
|
|
def get_vertices(self) -> list[tuple[float, float]]:
|
|
"""
|
|
>>> rect = Rectangle(5, 10)
|
|
>>> vertices = rect.get_vertices()
|
|
>>> len(vertices)
|
|
5
|
|
>>> vertices[0]
|
|
(0.0, 0.0)
|
|
>>> vertices[1]
|
|
(5.0, 0.0)
|
|
"""
|
|
if not self.sides:
|
|
return []
|
|
vertices = [(0.0, 0.0)]
|
|
x, y = 0.0, 0.0
|
|
direction = 0.0 # Initial direction in radians
|
|
|
|
for side in self.sides:
|
|
x += side.length * math.cos(direction)
|
|
y += side.length * math.sin(direction)
|
|
vertices.append((x, y))
|
|
# Turn by exterior angle (180 - interior)
|
|
turn = math.pi - side.angle.to_radians()
|
|
direction += turn
|
|
|
|
# Check closure (tolerance for float precision)
|
|
if (
|
|
math.hypot(
|
|
vertices[-1][0] - vertices[0][0], vertices[-1][1] - vertices[0][1]
|
|
)
|
|
> 1e-6
|
|
):
|
|
raise ValueError("Polygon does not close back to starting point")
|
|
return vertices
|
|
|
|
def perimeter(self) -> float:
|
|
"""
|
|
>>> Rectangle(5, 10).perimeter()
|
|
30
|
|
"""
|
|
return sum(side.length for side in self.sides)
|
|
|
|
def area(self) -> float:
|
|
"""
|
|
>>> Rectangle(5, 10).area()
|
|
50
|
|
"""
|
|
vertices = self.get_vertices()
|
|
if len(vertices) < 3:
|
|
return 0.0
|
|
n = len(vertices)
|
|
a = 0.0
|
|
for i in range(n):
|
|
j = (i + 1) % n
|
|
a += vertices[i][0] * vertices[j][1]
|
|
a -= vertices[j][0] * vertices[i][1]
|
|
return abs(a) / 2.0
|
|
|
|
|
|
class Rectangle(Polygon):
|
|
"""
|
|
A geometric rectangle on a 2D surface.
|
|
|
|
>>> rectangle_one = Rectangle(5, 10)
|
|
>>> rectangle_one.perimeter()
|
|
30
|
|
>>> rectangle_one.area()
|
|
50
|
|
>>> Rectangle(-5, 10)
|
|
Traceback (most recent call last):
|
|
...
|
|
TypeError: length must be a positive numeric value.
|
|
"""
|
|
|
|
def __init__(self, short_side_length: float, long_side_length: float) -> None:
|
|
super().__init__()
|
|
self.short_side_length = short_side_length
|
|
self.long_side_length = long_side_length
|
|
self.post_init()
|
|
|
|
def post_init(self) -> None:
|
|
"""
|
|
>>> rect = Rectangle(5, 10)
|
|
>>> len(rect.sides)
|
|
4
|
|
>>> rect.sides[0].length
|
|
5
|
|
"""
|
|
self.short_side = Side(self.short_side_length)
|
|
self.long_side = Side(self.long_side_length)
|
|
self.short_side_2 = Side(self.short_side_length)
|
|
self.long_side_2 = Side(self.long_side_length)
|
|
|
|
super().add_side(self.short_side)
|
|
super().add_side(self.long_side)
|
|
super().add_side(self.short_side_2)
|
|
super().add_side(self.long_side_2)
|
|
|
|
def perimeter(self) -> float:
|
|
return (self.short_side.length + self.long_side.length) * 2
|
|
|
|
def area(self) -> float:
|
|
return self.short_side.length * self.long_side.length
|
|
|
|
|
|
@dataclass
|
|
class Square(Rectangle):
|
|
"""
|
|
a structure which represents a
|
|
geometrical square on a 2D surface
|
|
>>> square_one = Square(5)
|
|
>>> square_one.perimeter()
|
|
20
|
|
>>> square_one.area()
|
|
25
|
|
"""
|
|
|
|
def __init__(self, side_length: float) -> None:
|
|
super().__init__(side_length, side_length)
|
|
|
|
def perimeter(self) -> float:
|
|
return super().perimeter()
|
|
|
|
def area(self) -> float:
|
|
return super().area()
|
|
|
|
|
|
class Triangle(Polygon):
|
|
"""
|
|
A geometric triangle on a 2D surface.
|
|
|
|
>>> tri = Triangle(3, 4, 5)
|
|
>>> tri.perimeter()
|
|
12
|
|
>>> tri.area()
|
|
6.0
|
|
>>> Triangle(1, 2, 10)
|
|
Traceback (most recent call last):
|
|
...
|
|
ValueError: Sides must satisfy triangle inequality
|
|
>>> Triangle(3, 4, 5, Angle(90), Angle(90), Angle(90))
|
|
Traceback (most recent call last):
|
|
...
|
|
ValueError: Triangle angles must sum to 180 degrees
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
side_a: float,
|
|
side_b: float,
|
|
side_c: float,
|
|
angle_a: Angle | None = None,
|
|
angle_b: Angle | None = None,
|
|
angle_c: Angle | None = None,
|
|
) -> None:
|
|
super().__init__()
|
|
|
|
# validate triangle inequality
|
|
if not (
|
|
side_a + side_b > side_c
|
|
and side_a + side_c > side_b
|
|
and side_b + side_c > side_a
|
|
):
|
|
raise ValueError("Sides must satisfy triangle inequality")
|
|
|
|
self.side_a = side_a
|
|
self.side_b = side_b
|
|
self.side_c = side_c
|
|
|
|
# calculate angles using cosines if not provided
|
|
if angle_a is None:
|
|
cos_a = (side_b**2 + side_c**2 - side_a**2) / (2 * side_b * side_c)
|
|
angle_a = Angle.from_radians(math.acos(max(-1, min(1, cos_a))))
|
|
|
|
if angle_b is None:
|
|
cos_b = (side_a**2 + side_c**2 - side_b**2) / (2 * side_a * side_c)
|
|
angle_b = Angle.from_radians(math.acos(max(-1, min(1, cos_b))))
|
|
|
|
if angle_c is None:
|
|
cos_c = (side_a**2 + side_b**2 - side_c**2) / (2 * side_a * side_b)
|
|
angle_c = Angle.from_radians(math.acos(max(-1, min(1, cos_c))))
|
|
|
|
# validate angle sum
|
|
angle_sum = angle_a.degrees + angle_b.degrees + angle_c.degrees
|
|
if abs(angle_sum - 180) > 0.01:
|
|
raise ValueError("Triangle angles must sum to 180 degrees")
|
|
|
|
self.angle_a = angle_a
|
|
self.angle_b = angle_b
|
|
self.angle_c = angle_c
|
|
|
|
# add sides with their corresponding angles
|
|
self.add_side(Side(side_a, angle_a))
|
|
self.add_side(Side(side_b, angle_b))
|
|
self.add_side(Side(side_c, angle_c))
|
|
|
|
def perimeter(self) -> float:
|
|
"""
|
|
>>> Triangle(3, 4, 5).perimeter()
|
|
12
|
|
"""
|
|
return self.side_a + self.side_b + self.side_c
|
|
|
|
def area(self) -> float:
|
|
"""
|
|
Calculate area using Heron's formula.
|
|
|
|
>>> Triangle(3, 4, 5).area()
|
|
6.0
|
|
>>> round(Triangle(5, 5, 5).area(), 2)
|
|
10.83
|
|
"""
|
|
s = self.perimeter() / 2 # semi-perimeter
|
|
area = math.sqrt(s * (s - self.side_a) * (s - self.side_b) * (s - self.side_c))
|
|
return area
|
|
|
|
|
|
if __name__ == "__main__":
|
|
__import__("doctest").testmod()
|