Last active
June 18, 2025 14:50
-
-
Save haxwithaxe/8441769f0cca2ff27d26ba6386e34386 to your computer and use it in GitHub Desktop.
Tools I've made for myself for playing around with learning about celestial navigation. Updates as I progress.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
"""Tools for playing with celestial navigation.""" | |
import math | |
import re | |
from typing import Union | |
ANGLE_HOUR = 15 # degrees/hour | |
_DECIMAL_RE = r'\d+(?:\.\d+)?' | |
_DEG_MIN_SEC_RE = re.compile( | |
r'(?P<degrees>-?' | |
+ _DECIMAL_RE | |
+ ')(?:d|deg)(?:(?P<minutes>' | |
+ _DECIMAL_RE | |
+ ''')['m])?(?:(?P<seconds>''' | |
+ _DECIMAL_RE | |
+ ')["s])?', # nofmt | |
) | |
def interpolate3(x_d: float, y1: float, y2: float, y3: float) -> float: | |
"""Interpolate from 3 points. | |
Arguments: | |
x_d: ("x sub delta") the difference between x2 (corresponding to y2) | |
and the target value. | |
y1: The tabular value before the center value. | |
y2: The center tabular value. | |
y3: The tabular value after the center value. | |
""" | |
assert x_d > -1 | |
assert x_d < 1 | |
a = y2 - y1 | |
b = y3 - y2 | |
c = b - a | |
return y2 + x_d / 2 * (a + b + x_d * c) | |
# Alternatively the one line version. | |
# return y2 + x_d / 2 * ((y2 - y1) + (y3 - y2) + x_d * (y1 + y3 - 2 * y2)) | |
def interpolate5( | |
x_d: float, y1: float, y2: float, y3: float, y4: float, y5: float | |
) -> float: | |
"""Interpolate from 3 points. | |
Arguments: | |
x_d: ("x sub delta") the difference between x3 (corresponding to y3) | |
and the target value. | |
y1: The tabular value before the center value. | |
y2: The center tabular value. | |
y3: The tabular value after the center value. | |
""" | |
assert x_d > -1 | |
assert x_d < 1 | |
# First pass | |
a = y2 - y1 | |
b = y3 - y2 | |
c = y4 - y3 | |
d = y5 - y4 | |
# Second pass | |
e = b - a | |
f = c - b | |
g = d - c | |
# Third pass | |
h = f - e | |
j = g - f | |
# Fourth pass | |
k = j - h | |
return ( | |
y3 | |
+ x_d * ((b + c) / 2 - (h + j) / 12) | |
+ x_d**2 * (f / 2 - k / 24) | |
+ x_d**3 * ((h + j) / 12) | |
+ x_d**4 * (k / 24) # nofmt | |
) | |
def time_hr_min_sec_to_hr( | |
hours: int = 0, | |
minutes: int = 0, | |
seconds: Union[float, int] = 0, | |
) -> float: | |
"""Convert time in hours, minutes, seconds to decimal hours.""" | |
return hours + (minutes / 60) + (seconds / 60 / 60) | |
def time_hr_to_deg(hours: float) -> float: | |
"""Convert decimal hours to degrees.""" | |
return hours * ANGLE_HOUR | |
def norm_deg_min_sec(deg_min_sec: str) -> tuple[int, int, float]: | |
"""Normalize a DMS string. | |
Accounts for negative DMS. | |
""" | |
found = _DEG_MIN_SEC_RE.match(deg_min_sec) | |
degrees = float(found.groupdict().get('degrees', 0)) | |
minutes = float(found.groupdict().get('minutes', 0)) | |
seconds = float(found.groupdict().get('seconds', 0)) | |
if degrees >= 0: | |
return (degrees, minutes, seconds) | |
# Angle is negative. Ensure all values are negative | |
return (degrees, 0 - abs(minutes), 0 - abs(seconds)) | |
def deg_min_sec_to_degrees( | |
degrees: Union[float, int], | |
minutes: Union[float, int] = 0, | |
seconds: Union[float, int] = 0, | |
) -> float: | |
"""Convert DMS to decimal degrees. | |
Arguments: | |
degrees: | |
minutes (optional): Defaults to ``0``. | |
seconds (optional): Defaults to ``0``. | |
Returns: | |
`angle` in decimal degrees. | |
""" | |
degrees += minutes / 60 | |
degrees += seconds / 60 / 60 | |
return degrees | |
dmstd = deg_min_sec_to_degrees | |
def degrees_to_deg_min_sec( | |
angle: Union['Angle', float, int], | |
) -> tuple[int, int, float]: | |
"""Convert decimal degrees to DMS. | |
Arguments: | |
angle: Angle in decimal degrees. | |
Returns: | |
A 3-tuple of `(degrees, minutes, seconds)`. | |
""" | |
degrees = int(angle) | |
decimal = angle - degrees | |
minutes = int(decimal * 60) | |
seconds = ((decimal * 60) - minutes) * 60 | |
return (degrees, minutes, float(seconds)) | |
dtdms = degrees_to_deg_min_sec | |
def degrees_to_deg_min(angle: Union['Angle', float, int]) -> tuple[int, float]: | |
"""Convert decimal degrees to DMS. | |
Arguments: | |
angle: Angle in decimal degrees. | |
Returns: | |
A 3-tuple of `(degrees, minutes, seconds)`. | |
""" | |
degrees = int(angle) | |
decimal = angle - degrees | |
minutes = decimal * 60 | |
return (degrees, float(minutes)) | |
# Decorators | |
def _any_to_angle(func: callable) -> callable: | |
"""Take any number or `Angle` as input and return an `Angle` as output.""" | |
def wrapper(slf: 'Angle', other: Union['Angle', float, int]) -> 'Angle': | |
if isinstance(other, slf.__class__.__bases__[0]): | |
other = other.radians | |
return slf.__class__(func(slf, other)) | |
return wrapper | |
def _any_to_bool(func: callable) -> callable: | |
"""Take any number or `Angle` as input and return a `bool`.""" | |
def wrapper(slf: 'Angle', other: Union['Angle', float, int]) -> 'Angle': | |
if isinstance(other, slf.__class__.__bases__[0]): | |
other = other.radians | |
return func(slf, other) | |
return wrapper | |
def _as_angle(func: callable) -> callable: | |
"""Cast the output as an `Angle`.""" | |
def wrapper(slf: 'Angle') -> 'Angle': | |
return slf.__class__(func(slf)) | |
return wrapper | |
class Angle: | |
"""Base class for angle types. | |
The goal is to preserve unit info as much as possible and avoid converting | |
unnecessarily. | |
Arguments: | |
value: An angle in degrees or radians. | |
Attributes: | |
degrees: The angle in degrees. | |
dms: The angle in degrees, minutes, seconds. | |
radians: The angle in radians. | |
""" | |
def __init__( # noqa: D107 | |
self, | |
value: Union['Angle', float, int], | |
) -> None: | |
self.value = float(value) | |
@property | |
def degrees(self) -> 'Angle': # noqa: D102 | |
raise NotImplementedError() | |
@degrees.setter | |
def degrees(self, value: Union[float, int, str]) -> None: # noqa: D102 | |
raise NotImplementedError() | |
@property | |
def dms(self) -> tuple[float, float, float]: # noqa: D102 | |
return degrees_to_deg_min_sec(self.degrees) | |
@dms.setter | |
def dms(self, value: Union[str, tuple]) -> None: # noqa: D102 | |
self.degrees = value | |
@property | |
def dm(self) -> tuple[float, float, float]: # noqa: D102 | |
return degrees_to_deg_min_sec(self.degrees) | |
@dm.setter | |
def dm(self, value: Union[str, tuple]) -> None: # noqa: D102 | |
self.degrees = value | |
@property | |
def radians(self) -> 'Angle': # noqa: D102 | |
raise NotImplementedError() | |
@radians.setter | |
def radians(self, value: Union[float, int]) -> None: # noqa: D102 | |
raise NotImplementedError() | |
def acos(self) -> float: | |
"""Arc cosine of the angle.""" | |
return math.acos(float(self.radians)) | |
def acosh(self) -> float: | |
"""Hyperbolic arc cosine of the angle.""" | |
return math.acosh(float(self.radians)) | |
def asin(self) -> float: | |
"""Arc sine of the angle.""" | |
return math.asin(float(self.radians)) | |
def asinh(self) -> float: | |
"""Hyperbolic arc sine of the angle.""" | |
return math.asinh(float(self.radians)) | |
def atan(self) -> float: | |
"""Arc tangent of the angle.""" | |
return math.atan(float(self.radians)) | |
def atanh(self) -> float: | |
"""Hyperbolic arc tangent of the angle.""" | |
return math.atanh(float(self.radians)) | |
def cos(self) -> float: | |
"""Cosine of the angle.""" | |
return math.cos(float(self.radians)) | |
def cosh(self) -> float: | |
"""Hyperbolic cosine of the angle.""" | |
return math.cosh(float(self.radians)) | |
def sin(self) -> float: | |
"""Sine of the angle.""" | |
return math.sin(float(self.radians)) | |
def sinh(self) -> float: | |
"""Hyperbolic sine of the angle.""" | |
return math.sinh(float(self.radians)) | |
def tan(self) -> float: | |
"""Tangent of the angle.""" | |
return math.tan(float(self.radians)) | |
def tanh(self) -> float: | |
"""Hyperbolic tangent of the angle.""" | |
return math.tanh(float(self.radians)) | |
@_any_to_angle | |
def __add__( # noqa: D105 | |
self, | |
other: Union['Angle', float, int], | |
) -> 'Angle': | |
return self.value + other | |
@_any_to_angle | |
def __mul__( # noqa: D105 | |
self, | |
other: Union['Angle', float, int], | |
) -> 'Angle': | |
return float.__mul__(self.value, other) | |
@_as_angle | |
def __abs__(self) -> 'Angle': # noqa: D105 | |
return float.__abs__(self.value) | |
@_any_to_angle | |
def __div__( # noqa: D105 | |
self, | |
other: Union['Angle', float, int], | |
) -> 'Angle': | |
return float.__div__(self.value, other) | |
@_any_to_angle | |
def __divmod__( # noqa: D105 | |
self, | |
other: Union['Angle', float, int], | |
) -> 'Angle': | |
return float.__divmod__(self.value, other) | |
@_any_to_bool | |
def __eq__( # noqa: D105 | |
self, | |
other: Union['Angle', float, int], | |
) -> bool: | |
return float.__eq__(self.value, other) | |
@_as_angle | |
def __floor__(self) -> 'Angle': # noqa: D105 | |
return float.__floor__(self.value) | |
@_any_to_angle | |
def __floordiv__( # noqa: D105 | |
self, | |
other: Union['Angle', float, int], | |
) -> 'Angle': | |
return float.__floordiv__(self.value, other) | |
@_any_to_bool | |
def __ge__(self, other: Union['Angle', float, int]) -> bool: # noqa: D105 | |
return float.__ge__(self.value, other) | |
@_any_to_bool | |
def __gt__(self, other: Union['Angle', float, int]) -> bool: # noqa: D105 | |
return float.__gt__(self.value, other) | |
@_any_to_bool | |
def __le__(self, other: Union['Angle', float, int]) -> bool: # noqa: D105 | |
return float.__le__(self.value, other) | |
@_any_to_bool | |
def __lt__(self, other: Union['Angle', float, int]) -> bool: # noqa: D105 | |
return float.__lt__(self.value, other) | |
@_any_to_angle | |
def __mod__( # noqa: D105 | |
self, | |
other: Union['Angle', float, int], | |
) -> 'Angle': | |
return float.__mod__(self.value, other) | |
@_any_to_bool | |
def __ne__(self, other: Union['Angle', float, int]) -> bool: # noqa: D105 | |
return self.value != other | |
@_as_angle | |
def __neg__(self) -> 'Angle': # noqa: D105 | |
return float.__neg__(self.value) | |
@_as_angle | |
def __pos__(self) -> 'Angle': # noqa: D105 | |
return float.__pos__(self.value) | |
@_any_to_angle | |
def __pow__( # noqa: D105 | |
self, | |
other: Union['Angle', float, int], | |
) -> 'Angle': | |
return float.__pow__(self.value, other) | |
@_any_to_angle | |
def __radd__( # noqa: D105 | |
self, | |
other: Union['Angle', float, int], | |
) -> 'Angle': | |
return float.__radd__(self.value, other) | |
@_any_to_angle | |
def __rdivmod__( # noqa: D105 | |
self, | |
other: Union['Angle', float, int], | |
) -> 'Angle': | |
return float.__rdivmod__(self.value, other) | |
@_any_to_angle | |
def __reduce__( # noqa: D105 | |
self, | |
other: Union['Angle', float, int], | |
) -> 'Angle': | |
return float.__reduce__(self.value, other) | |
@_any_to_angle | |
def __reduce_ex__( # noqa: D105 | |
self, | |
other: Union['Angle', float, int], | |
) -> 'Angle': | |
return float.__reduce_ex__(self.value, other) | |
@_any_to_angle | |
def __rfloordiv__( # noqa: D105 | |
self, | |
other: Union['Angle', float, int], | |
) -> 'Angle': | |
return float.__rfloordiv__(self.value, other) | |
@_any_to_angle | |
def __rmod__( # noqa: D105 | |
self, | |
other: Union['Angle', float, int], | |
) -> 'Angle': | |
return float.__rmod__(self.value, other) | |
@_any_to_angle | |
def __rmul__( # noqa: D105 | |
self, | |
other: Union['Angle', float, int], | |
) -> 'Angle': | |
return float.__rmul__(self.value, other) | |
@_as_angle | |
def __round__(self) -> 'Angle': # noqa: D105 | |
return round(self.value) | |
@_any_to_angle | |
def __rpow__( # noqa: D105 | |
self, | |
other: Union['Angle', float, int], | |
) -> 'Angle': | |
return float.__rpow__(self.value, other) | |
@_any_to_angle | |
def __rsub__( # noqa: D105 | |
self, | |
other: Union['Angle', float, int], | |
) -> 'Angle': | |
return float.__rsub__(self.value, other) | |
@_any_to_angle | |
def __rtruediv__( # noqa: D105 | |
self, | |
other: Union['Angle', float, int], | |
) -> 'Angle': | |
return float.__rtruediv__(self.value, other) | |
@_any_to_angle | |
def __sub__( # noqa: D105 | |
self, | |
other: Union['Angle', float, int], | |
) -> 'Angle': | |
return float.__sub__(self.value, other) | |
@_any_to_angle | |
def __truediv__( # noqa: D105 | |
self, | |
other: Union['Angle', float, int], | |
) -> 'Angle': | |
return float.__truediv__(self.value, other) | |
@_as_angle | |
def __trunc__(self) -> 'Angle': # noqa: D105 | |
return float.__trunc__(self.value) | |
def __int__(self) -> int: # noqa: D105 | |
return int(self.value) | |
def __float__(self) -> float: # noqa: D105 | |
return float(self.value) | |
def __repr__(self) -> str: # noqa: D105 | |
return ( | |
f'<{self.__class__.__name__} radians={float(self.radians)}, ' | |
f'degrees={float(self.degrees)}>' | |
) | |
def __str__(self) -> str: # noqa: D105 | |
raise NotImplementedError() | |
class Degree(Angle): | |
"""An angle in degrees. | |
Arguments: | |
degrees: The angle in degrees (decimal or DMS). | |
""" | |
def __init__( # noqa: D107 | |
self, | |
degrees: Union['Angle', float, int, str, tuple], | |
): | |
if isinstance(degrees, Angle): | |
degrees = degrees.degrees | |
elif isinstance(degrees, str): | |
degrees = deg_min_sec_to_degrees(*norm_deg_min_sec(degrees)) | |
elif isinstance(degrees, tuple): | |
degrees = deg_min_sec_to_degrees(*degrees) | |
super().__init__(float(degrees)) | |
@property | |
def degrees(self) -> 'Degree': # noqa: D102 | |
return Degree(self.value) | |
@degrees.setter | |
def degrees( # noqa: D102 | |
self, | |
value_deg: Union[Angle, float, int, str, tuple], | |
) -> None: | |
if isinstance(value_deg, Angle): | |
value_deg = value_deg.degrees | |
elif isinstance(value_deg, str): | |
value_deg = deg_min_sec_to_degrees(*norm_deg_min_sec(value_deg)) | |
elif isinstance(value_deg, tuple): | |
value_deg = deg_min_sec_to_degrees(*value_deg) | |
self.value = float(value_deg) | |
@property | |
def radians(self) -> 'Radian': # noqa: D102 | |
return Radian(math.radians(self.value)) | |
@radians.setter | |
def radians(self, value_rad: Union[float, int]) -> None: # noqa: D102 | |
self.value = math.degrees(value_rad) | |
def __str__(self) -> str: # noqa: D105 | |
return f'{float(self.degrees)}deg' | |
class Radian(Angle): | |
"""An angle in radians. | |
Arguments: | |
radians: The angle in radians. | |
""" | |
def __init__(self, radians: Union['Angle', float, int]): # noqa: D107 | |
if isinstance(radians, Angle): | |
radians = float(radians.radians) | |
super().__init__(radians) | |
@property | |
def degrees(self) -> Degree: # noqa: D102 | |
return Degree(math.degrees(self.value)) | |
@degrees.setter | |
def degrees( # noqa: D102 | |
self, | |
value_deg: Union[Angle, float, int, str, tuple], | |
) -> None: | |
if isinstance(value_deg, (str, tuple)): | |
value_deg = Degree(value_deg).degrees | |
elif isinstance(value_deg, Angle): | |
value_deg = value_deg.degrees | |
self.value = math.radians(value_deg) | |
@property | |
def radians(self) -> 'Radian': # noqa: D102 | |
return self.value | |
@radians.setter | |
def radians( | |
self, | |
value_rad: Union[Angle, float, int], | |
) -> None: # noqa: D102 | |
if isinstance(value_rad, Angle): | |
value_rad = float(value_rad.radians) | |
self.value = value_rad | |
def __str__(self) -> str: # noqa: D105 | |
return f'{float(self.radians)}rad' |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment