From b8f1a099aa8e0c41f8cb1acd5fa70445afe5da40 Mon Sep 17 00:00:00 2001 From: Carlos Adir Date: Sat, 12 Jul 2025 00:36:21 +0200 Subject: [PATCH] feat: add Angle class --- src/shapepy/scalar/angle.py | 325 ++++++++++++++++++++++++++++++++++++ tests/scalar/test_angle.py | 287 +++++++++++++++++++++++++++++++ 2 files changed, 612 insertions(+) create mode 100644 src/shapepy/scalar/angle.py create mode 100644 tests/scalar/test_angle.py diff --git a/src/shapepy/scalar/angle.py b/src/shapepy/scalar/angle.py new file mode 100644 index 00000000..2ebcf111 --- /dev/null +++ b/src/shapepy/scalar/angle.py @@ -0,0 +1,325 @@ +""" +Defines the Angle class + +This class is used to handle conversions between radians/degrees/turns +It is an abstraction that to not handle float angle measured in radians +""" + +from __future__ import annotations + +import re +from numbers import Real + +from .reals import Is, Math, To + + +class Angle: + """ + Class that stores an angle. + + Handles the operations such as __add__, __sub__, etc + """ + + @classmethod + def radians(cls, value: Real) -> Angle: + """ + Gives an Angle instance for given value measured in radians + + Parameters + ---------- + value : Real + The angle measured in radians + + Return + ------ + Angle + The Angle instance + + Example + ------- + >>> Angle.radians(0) + 0 deg + >>> Angle.radians(math.pi/2) + 90 deg + >>> Angle.radians(math.pi) + 180 deg + >>> Angle.radians(3*math.pi/2) + 270 deg + >>> Angle.radians(2*math.pi) + 0 deg + """ + value = To.finite(Math.fmod(value, Math.tau)) + return cls.degrees(Math.degrees(value)) + + @classmethod + def degrees(cls, value: Real) -> Angle: + """ + Gives an Angle instance for given value measured in degrees + + Parameters + ---------- + value : Real + The angle measured in degrees + + Return + ------ + Angle + The Angle instance + + Example + ------- + >>> Angle.degrees(0) + 0 deg + >>> Angle.degrees(90) + 90 deg + >>> Angle.degrees(180) + 180 deg + >>> Angle.degrees(270) + 270 deg + >>> Angle.degrees(360) + 0 deg + >>> Angle.degrees(720) + 0 deg + """ + value = To.finite(value) + value %= 360 + value = ( + To.rational(value, 360) if Is.rational(value) else (value / 360) + ) + return cls.turns(value) + + @classmethod + def turns(cls, value: Real) -> Angle: + """ + Gives an Angle instance for given value measured in turns + + Parameters + ---------- + value : Real + The angle measured in turns + + Return + ------ + Angle + The Angle instance + + Example + ------- + >>> Angle.turns(0) + 0 deg + >>> Angle.turns(0.25) + 90 deg + >>> Angle.turns(0.50) + 180 deg + >>> Angle.turns(0.75) + 270 deg + >>> Angle.turns(1) + 0 deg + >>> Angle.turns(2) + 0 deg + """ + value = To.finite(value) + quad, part = divmod(4 * value, 1) + return cls(int(quad), part) + + @classmethod + def atan2(cls, ycoord: Real, xcoord: Real): + """ + Compute the complex argument of the point (x, y) + + Parameters + ---------- + ycoord : Real + The y-coordinate of the point + xcoord : Real + The x-coordinate of the point + + Returns + ------- + Angle + The Angle instance such tangent gives y/x + + Examples + -------- + >>> Angle.atan2(0, 1) # 0 degrees + 0 deg + >>> Angle.atan2(1, 1) # 45 degrees + 45 deg + >>> Angle.atan2(1, -1) # 135 degrees + 135 deg + >>> Angle.atan2(-1, 1) # -45 degrees + 315 deg + """ + if ycoord == 0: + return cls(0, 0) if xcoord >= 0 else cls(2, 0) + if xcoord == 0: + return cls(1, 0) if ycoord > 0 else cls(3, 0) + return cls.radians(Math.atan2(ycoord, xcoord)) + + @classmethod + def arg(cls, xcoord: Real, ycoord: Real): + """ + Compute the complex argument of the point (x, y) + + Parameters + ---------- + xcoord : Real + The x-coordinate of the point + ycoord : Real + The y-coordinate of the point + + Returns + ------- + Angle + The Angle instance such tangent gives y/x + + Examples + -------- + >>> Angle.arg(1, 0) # 0 degrees + 0 deg + >>> Angle.arg(1, 1) # 45 degrees + 45 deg + >>> Angle.arg(0, 1) # 90 degrees + 90 deg + >>> Angle.arg(-1, 1) # 135 degrees + 135 deg + """ + return cls.atan2(ycoord, xcoord) + + def __init__(self, quad: int = 0, part: Real = 0): + if not Is.integer(quad): + raise TypeError(f"Expected integer value, got {type(quad)}") + if not Is.real(part): + raise TypeError(f"Expected numeric value, got {type(part)}") + self.quad: int = quad % 4 + self.part: Real = part + + def __eq__(self, other: object) -> bool: + if isinstance(other, Angle): + return self.quad == other.quad and (self.part - other.part == 0) + return self == Angle.radians(other) + + def __float__(self): + return float(Math.tau * (self.quad + self.part) / 4) + + def __add__(self, other: Angle) -> Angle: + if not isinstance(other, Angle): + raise TypeError(f"Cannot add {type(self)} with {type(other)}") + return self.__class__.turns( + ((self.quad + other.quad) + (self.part + other.part)) / 4 + ) + + def __sub__(self, other: Angle) -> Angle: + if not isinstance(other, Angle): + raise TypeError(f"Cannot sub {type(self)} with {type(other)}") + return self.__class__.turns( + ((self.quad - other.quad) + (self.part - other.part)) / 4 + ) + + def __mul__(self, other: Real) -> Angle: + return self.turns(other * (self.quad + self.part) / 4) + + def __rmul__(self, other: Real) -> Angle: + return self.__mul__(other) + + def __str__(self): + return f"{90 * (self.quad + self.part)} deg" + + def __repr__(self): + return f"Angle({str(self)})" + + def sin(self) -> Real: + """ + Computes the sinus value for the angle + + Return + ------ + Real + The sinus result of the angle + + Example + ------- + >>> Angle.degrees(0).sin() + 0 + >>> Angle.degrees(45).sin() + 0.7071067811865476 + >>> Angle.degrees(90).sin() + 1 + """ + if self.part == 0: + if self.quad % 2: + return To.finite(1 if self.quad == 1 else -1) + return To.finite(0) + + if self.quad % 2: + result = Math.turcos(self.part / 4) + else: + result = Math.tursin(self.part / 4) + if self.quad > 1: + result *= -1 + return result + + def cos(self) -> Real: + """ + Computes the cossinus value for the angle + + Return + ------ + Real + The cossinus result of the angle + + Example + ------- + >>> Angle.degrees(0).cos() + 1 + >>> Angle.degrees(45).cos() + 0.7071067811865476 + >>> Angle.degrees(90).cos() + 0 + """ + if self.part == 0: + if self.quad % 2: + return To.finite(0) + return To.finite(1 if self.quad == 0 else -1) + + if self.quad % 2: + result = Math.tursin(self.part / 4) + else: + result = Math.turcos(self.part / 4) + if 0 < self.quad < 3: + result *= -1 + return result + + +def to_angle(obj: object) -> Angle: + """ + Converts an object to an Angle instance + + * If it's already an angle, gives the same instance + * If it's a string, decides depending on the content: + * "10deg" -> Angle.degrees(10) + * "0.25tur" -> Angle.turns(0.25) + * "2.1rad" -> Angle.radians(2.1) + * If it's any another type, converts to a number, and gives it in radians + + Example + ------- + >>> angle("10deg") + >>> angle("0.25tur") + >>> angle("2.1rad") + >>> angle(1.25) + """ + if isinstance(obj, Angle): + return obj + if isinstance(obj, str): + tipo = re.findall(r"([a-zA-Z]+)$", obj)[0] + value = To.finite(obj.replace(tipo, "")) + if "deg" in tipo: + return Angle.degrees(value) + if "tur" in tipo: + return Angle.turns(value) + return Angle.radians(value) + return Angle.radians(obj) + + +To.angle = to_angle diff --git a/tests/scalar/test_angle.py b/tests/scalar/test_angle.py new file mode 100644 index 00000000..1af6b488 --- /dev/null +++ b/tests/scalar/test_angle.py @@ -0,0 +1,287 @@ +import math +import random + +import pytest + +from shapepy.scalar.angle import Angle, to_angle +from shapepy.scalar.reals import Math, To + + +@pytest.mark.order(2) +@pytest.mark.timeout(1) +@pytest.mark.dependency( + depends=[ + "tests/scalar/test_reals.py::test_all", + ], + scope="session", +) +def test_build_radians(): + Angle.radians(0) + Angle.radians(math.pi) + Angle.radians(2 * math.pi) + + +@pytest.mark.order(2) +@pytest.mark.timeout(1) +@pytest.mark.dependency( + depends=[ + "tests/scalar/test_reals.py::test_all", + ], + scope="session", +) +def test_build_degrees(): + Angle.radians(0) + Angle.radians(90) + Angle.radians(180) + Angle.radians(270) + Angle.radians(360) + + +@pytest.mark.order(2) +@pytest.mark.timeout(1) +@pytest.mark.dependency( + depends=[ + "tests/scalar/test_reals.py::test_all", + ], + scope="session", +) +def test_build_turns(): + Angle.turns(0) + Angle.turns(0.125) + Angle.turns(0.250) + Angle.turns(0.375) + Angle.turns(0.500) + Angle.turns(0.625) + Angle.turns(0.750) + Angle.turns(0.875) + Angle.turns(1.000) + + +@pytest.mark.order(2) +@pytest.mark.timeout(1) +@pytest.mark.dependency( + depends=["test_build_radians", "test_build_degrees", "test_build_turns"] +) +def test_build_atan2(): + coords = (-2, -1, 0, 1, 2) + for xval in coords: + for yval in coords: + Angle.atan2(yval, xval) + + for xval in coords: + Angle.atan2(xval, Math.NEGINF) + Angle.atan2(xval, Math.POSINF) + + for yval in coords: + Angle.atan2(Math.NEGINF, yval) + Angle.atan2(Math.POSINF, yval) + + Angle.atan2(Math.NEGINF, Math.NEGINF) + Angle.atan2(Math.NEGINF, Math.POSINF) + Angle.atan2(Math.POSINF, Math.NEGINF) + Angle.atan2(Math.POSINF, Math.POSINF) + + +@pytest.mark.order(2) +@pytest.mark.timeout(1) +@pytest.mark.dependency( + depends=["test_build_radians", "test_build_degrees", "test_build_turns"] +) +def test_build_arg(): + coords = (-2, -1, 0, 1, 2) + for xval in coords: + for yval in coords: + Angle.arg(xval, yval) + + for xval in coords: + Angle.arg(xval, Math.NEGINF) + Angle.arg(xval, Math.POSINF) + + for yval in coords: + Angle.arg(Math.NEGINF, yval) + Angle.arg(Math.POSINF, yval) + + Angle.arg(Math.NEGINF, Math.NEGINF) + Angle.arg(Math.NEGINF, Math.POSINF) + Angle.arg(Math.POSINF, Math.NEGINF) + Angle.arg(Math.POSINF, Math.POSINF) + + +@pytest.mark.order(2) +@pytest.mark.timeout(1) +@pytest.mark.dependency( + depends=["test_build_radians", "test_build_degrees", "test_build_turns"] +) +def test_compare(): + assert Angle.radians(0) == Angle.degrees(0) + assert Angle.radians(math.pi / 6) == Angle.degrees(30) + assert Angle.radians(math.pi / 4) == Angle.degrees(45) + assert Angle.radians(math.pi / 3) == Angle.degrees(60) + assert Angle.radians(math.pi / 2) == Angle.degrees(90) + assert Angle.radians(math.pi) == Angle.degrees(180) + assert Angle.radians(3 * math.pi / 2) == Angle.degrees(270) + assert Angle.radians(2 * math.pi) == Angle.degrees(0) + + for deg in range(0, 360, 15): + assert Angle.degrees(deg) == Angle.turns(To.rational(deg, 360)) + + assert Angle.degrees(0) == 0 + assert Angle.degrees(180) == math.pi + assert Angle.degrees(90) == math.pi / 2 + assert Angle.degrees(270) == -math.pi / 2 + assert float(Angle.degrees(0)) == 0 + assert float(Angle.degrees(180)) == math.pi + assert float(Angle.degrees(90)) == math.pi / 2 + assert float(Angle.degrees(270)) == 3 * math.pi / 2 + str(Angle()) + repr(Angle()) + + +@pytest.mark.order(2) +@pytest.mark.timeout(1) +@pytest.mark.dependency( + depends=["test_build_radians", "test_build_degrees", "test_build_turns"] +) +def test_evaluate_arg(): + assert Angle.arg(0, 0) == Angle.degrees(0) + assert Angle.arg(1, 0) == Angle.degrees(0) + assert Angle.arg(2, 0) == Angle.degrees(0) + assert Angle.arg(1, 1) == Angle.degrees(45) + assert Angle.arg(0, 1) == Angle.degrees(90) + assert Angle.arg(-1, 1) == Angle.degrees(135) + assert Angle.arg(-1, 0) == Angle.degrees(180) + assert Angle.arg(-1, -1) == Angle.degrees(225) + assert Angle.arg(0, -1) == Angle.degrees(270) + assert Angle.arg(1, -1) == Angle.degrees(315) + assert Angle.arg(1, 0) == Angle.degrees(360) + + NEGINF = Math.NEGINF + POSINF = Math.POSINF + assert Angle.arg(POSINF, 0) == Angle.degrees(0) + assert Angle.arg(POSINF, POSINF) == Angle.degrees(45) + assert Angle.arg(0, POSINF) == Angle.degrees(90) + assert Angle.arg(NEGINF, POSINF) == Angle.degrees(135) + assert Angle.arg(NEGINF, 0) == Angle.degrees(180) + assert Angle.arg(NEGINF, NEGINF) == Angle.degrees(225) + assert Angle.arg(0, NEGINF) == Angle.degrees(270) + assert Angle.arg(POSINF, NEGINF) == Angle.degrees(315) + assert Angle.arg(POSINF, 0) == Angle.degrees(360) + + +@pytest.mark.order(2) +@pytest.mark.timeout(1) +@pytest.mark.dependency( + depends=["test_build_radians", "test_build_degrees", "test_build_turns"] +) +def test_evaluate_atan2(): + assert Angle.atan2(0, 0) == Angle.degrees(0) + assert Angle.atan2(0, 1) == Angle.degrees(0) + assert Angle.atan2(0, 2) == Angle.degrees(0) + assert Angle.atan2(1, 1) == Angle.degrees(45) + assert Angle.atan2(1, 0) == Angle.degrees(90) + assert Angle.atan2(1, -1) == Angle.degrees(135) + assert Angle.atan2(0, -1) == Angle.degrees(180) + assert Angle.atan2(-1, -1) == Angle.degrees(225) + assert Angle.atan2(-1, 0) == Angle.degrees(270) + assert Angle.atan2(-1, 1) == Angle.degrees(315) + assert Angle.atan2(0, 1) == Angle.degrees(360) + + NEGINF = Math.NEGINF + POSINF = Math.POSINF + assert Angle.atan2(0, POSINF) == Angle.degrees(0) + assert Angle.atan2(POSINF, POSINF) == Angle.degrees(45) + assert Angle.atan2(POSINF, 0) == Angle.degrees(90) + assert Angle.atan2(POSINF, NEGINF) == Angle.degrees(135) + assert Angle.atan2(0, NEGINF) == Angle.degrees(180) + assert Angle.atan2(NEGINF, NEGINF) == Angle.degrees(225) + assert Angle.atan2(NEGINF, 0) == Angle.degrees(270) + assert Angle.atan2(NEGINF, POSINF) == Angle.degrees(315) + assert Angle.atan2(0, POSINF) == Angle.degrees(360) + + +@pytest.mark.order(2) +@pytest.mark.timeout(1) +@pytest.mark.dependency( + depends=["test_build_radians", "test_build_degrees", "test_build_turns"] +) +def test_evaluate_sincos(): + degs = (0, 90, 180, 270, 360) + coss = (1, 0, -1, 0, 1) + sins = (0, 1, 0, -1, 0) + + for deg, cos, sin in zip(degs, coss, sins): + angle = Angle.degrees(deg) + assert angle.cos() == cos + assert angle.sin() == sin + + a = math.sqrt(2) / 2 + degs = (45, 135, 225, 315) + coss = (a, -a, -a, a) + sins = (a, a, -a, -a) + + for deg, cos, sin in zip(degs, coss, sins): + angle = Angle.degrees(deg) + assert abs(angle.cos() - cos) < 1e-15 + assert abs(angle.sin() - sin) < 1e-15 + + +@pytest.mark.order(2) +@pytest.mark.timeout(1) +@pytest.mark.dependency( + depends=["test_build_radians", "test_build_degrees", "test_build_turns"] +) +def test_evaluate_operations(): + + for _ in range(1000): + a = random.randint(0, 720) + b = random.randint(0, 720) + anglea = Angle.degrees(a) + angleb = Angle.degrees(b) + + anglec = Angle.degrees(a + b) + angled = anglea + angleb + assert angled == anglec + + anglec = Angle.degrees(a - b) + angled = anglea - angleb + assert angled == anglec + + assert 2 * anglea == anglea + anglea + + +@pytest.mark.order(2) +@pytest.mark.timeout(1) +@pytest.mark.dependency( + depends=["test_build_radians", "test_build_degrees", "test_build_turns"] +) +def test_convert(): + + assert to_angle("90deg") == Angle.degrees(90) + assert to_angle("0.25tur") == Angle.turns(0.25) + assert to_angle("1.25rad") == Angle.radians(1.25) + + assert to_angle(1.25) == Angle.radians(1.25) + + anglea = Angle.degrees(30) + assert to_angle(anglea) is anglea + + +@pytest.mark.order(2) +@pytest.mark.timeout(1) +@pytest.mark.dependency( + depends=[ + "test_build_radians", + "test_build_degrees", + "test_build_turns", + "test_build_atan2", + "test_build_arg", + "test_compare", + "test_evaluate_arg", + "test_evaluate_atan2", + "test_evaluate_sincos", + "test_evaluate_operations", + "test_convert", + ] +) +def test_all(): + pass