urania 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- urania/__init__.py +19 -0
- urania/bodies.py +90 -0
- urania/constants.py +10 -0
- urania/core/__init__.py +36 -0
- urania/core/elements.py +127 -0
- urania/core/forces.py +105 -0
- urania/core/j2.py +43 -0
- urania/core/kepler.py +158 -0
- urania/core/maneuvers.py +85 -0
- urania/core/propagate.py +84 -0
- urania/core/twobody.py +54 -0
- urania/environment.py +87 -0
- urania/explain.py +312 -0
- urania/maneuvers.py +227 -0
- urania/orbits.py +265 -0
- urania/propagation.py +251 -0
- urania/time.py +91 -0
- urania/tle.py +222 -0
- urania/units.py +62 -0
- urania/viz.py +176 -0
- urania-0.0.1.dist-info/METADATA +188 -0
- urania-0.0.1.dist-info/RECORD +24 -0
- urania-0.0.1.dist-info/WHEEL +4 -0
- urania-0.0.1.dist-info/licenses/LICENSE +21 -0
urania/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""urania — an astrodynamics library that computes, shows and explains."""
|
|
2
|
+
|
|
3
|
+
from .bodies import Body, Earth, Mars, Moon, Sun
|
|
4
|
+
from .environment import Environment
|
|
5
|
+
from .maneuvers import Burn, Transfer
|
|
6
|
+
from .orbits import GEO, ISS, LEO, SSO, Orbit, sun_synchronous
|
|
7
|
+
from .propagation import PropagationInfo, Trajectory
|
|
8
|
+
from .time import J2000, Epoch
|
|
9
|
+
from .tle import TLEOrbit
|
|
10
|
+
|
|
11
|
+
__version__ = "0.0.1"
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"Body", "Earth", "Mars", "Moon", "Sun",
|
|
15
|
+
"Orbit", "sun_synchronous", "LEO", "ISS", "SSO", "GEO",
|
|
16
|
+
"Epoch", "J2000",
|
|
17
|
+
"Environment", "Trajectory", "PropagationInfo", "Transfer", "Burn",
|
|
18
|
+
"TLEOrbit",
|
|
19
|
+
]
|
urania/bodies.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Celestial body objects and presets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from . import units
|
|
9
|
+
from .environment import ExponentialAtmosphere
|
|
10
|
+
from .constants import G
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class Body:
|
|
15
|
+
"""Central body. All values are SI.
|
|
16
|
+
|
|
17
|
+
Stores μ (GM) as the primary value: it is measured directly and is far more accurate than the mass.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
name: str
|
|
21
|
+
mu: float # gravitational parameter GM [m³/s²]
|
|
22
|
+
radius: float # equatorial radius [m]
|
|
23
|
+
J2: float = 0.0 # second zonal harmonic [-]
|
|
24
|
+
rotation_rate: float = 0.0 # rotation rate [rad/s] (sidereal)
|
|
25
|
+
flattening: float = 0.0 # reference ellipsoid flattening f = (a - b)/a [-]
|
|
26
|
+
atmosphere: Any = field(default=None, compare=False) # atmospheric density Environment
|
|
27
|
+
source: str = field(default="", compare=False) # source of the constants
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def create(cls, name: str, mu, radius, J2: float = 0.0, rotation_rate=0.0,
|
|
31
|
+
flattening: float = 0.0, atmosphere=None, source: str = "") -> Body:
|
|
32
|
+
"""Create from values with units. Plain numbers are taken as SI."""
|
|
33
|
+
return cls(
|
|
34
|
+
name=name,
|
|
35
|
+
mu=units.to_si(mu, units.MU),
|
|
36
|
+
radius=units.to_si(radius, units.LENGTH),
|
|
37
|
+
J2=J2,
|
|
38
|
+
rotation_rate=units.to_si(rotation_rate, units.ANGULAR_VELOCITY),
|
|
39
|
+
flattening=flattening,
|
|
40
|
+
atmosphere=atmosphere,
|
|
41
|
+
source=source,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def mass(self) -> float:
|
|
46
|
+
"""Mass [kg] = μ / G."""
|
|
47
|
+
return self.mu / G
|
|
48
|
+
|
|
49
|
+
def __repr__(self) -> str:
|
|
50
|
+
return f"Body({self.name!r}, R={self.radius / 1e3:.1f} km)"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
Sun = Body(
|
|
54
|
+
name="Sun",
|
|
55
|
+
mu=1.32712440041e20,
|
|
56
|
+
radius=6.957e8,
|
|
57
|
+
rotation_rate=2.8653e-6, # Carrington rotation period 25.38 days
|
|
58
|
+
source="μ: DE440; R: IAU 2015 B3 nominal",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
Earth = Body(
|
|
62
|
+
name="Earth",
|
|
63
|
+
mu=3.986004418e14,
|
|
64
|
+
radius=6378137.0,
|
|
65
|
+
J2=1.08262668e-3,
|
|
66
|
+
rotation_rate=7.292115e-5,
|
|
67
|
+
flattening=1 / 298.257223563,
|
|
68
|
+
atmosphere=ExponentialAtmosphere(radius=6378137.0, flattening=1 / 298.257223563),
|
|
69
|
+
source="μ, R, f: WGS 84; J2: EGM-08 (Vallado table D-1)",
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
Moon = Body(
|
|
73
|
+
name="Moon",
|
|
74
|
+
mu=4.902800118e12,
|
|
75
|
+
radius=1.7374e6,
|
|
76
|
+
J2=2.033e-4,
|
|
77
|
+
rotation_rate=2.6617e-6, # sidereal month 27.3217 days
|
|
78
|
+
flattening=0.0012,
|
|
79
|
+
source="μ: DE440; J2: GRAIL GRGM1200A; f: Moon Fact Sheet (NASA GSFC)",
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
Mars = Body(
|
|
83
|
+
name="Mars",
|
|
84
|
+
mu=4.282837e13,
|
|
85
|
+
radius=3.3962e6,
|
|
86
|
+
J2=1.96045e-3,
|
|
87
|
+
rotation_rate=7.088218e-5, # sidereal day 24.6229 hours
|
|
88
|
+
flattening=0.00589,
|
|
89
|
+
source="μ: DE440; R, J2, f: Mars Fact Sheet (NASA GSFC)",
|
|
90
|
+
)
|
urania/constants.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Physical constants (SI)."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
|
|
5
|
+
G = 6.67430e-11 # gravitational constant [m³/(kg·s²)], CODATA 2018
|
|
6
|
+
DAY = 86400.0 # [s]
|
|
7
|
+
TROPICAL_YEAR = 365.24219 * DAY # tropical year [s]
|
|
8
|
+
|
|
9
|
+
# Target nodal rate of a sun-synchronous orbit: 360° per tropical year [rad/s]
|
|
10
|
+
SSO_RAAN_RATE = 2.0 * math.pi / TROPICAL_YEAR
|
urania/core/__init__.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Pure-function core. All inputs and outputs are plain SI numbers."""
|
|
2
|
+
|
|
3
|
+
from .elements import Elements, coe_to_rv, elements_to_rv, rv_to_coe
|
|
4
|
+
from .kepler import (
|
|
5
|
+
eccentric_to_mean,
|
|
6
|
+
eccentric_to_true,
|
|
7
|
+
hyperbolic_to_mean,
|
|
8
|
+
hyperbolic_to_true,
|
|
9
|
+
mean_to_eccentric,
|
|
10
|
+
mean_to_hyperbolic,
|
|
11
|
+
mean_to_parabolic,
|
|
12
|
+
mean_to_true,
|
|
13
|
+
parabolic_to_mean,
|
|
14
|
+
true_to_eccentric,
|
|
15
|
+
true_to_hyperbolic,
|
|
16
|
+
true_to_mean,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"Elements",
|
|
21
|
+
"coe_to_rv",
|
|
22
|
+
"elements_to_rv",
|
|
23
|
+
"rv_to_coe",
|
|
24
|
+
"eccentric_to_mean",
|
|
25
|
+
"eccentric_to_true",
|
|
26
|
+
"hyperbolic_to_mean",
|
|
27
|
+
"hyperbolic_to_true",
|
|
28
|
+
"mean_to_eccentric",
|
|
29
|
+
"mean_to_hyperbolic",
|
|
30
|
+
"mean_to_parabolic",
|
|
31
|
+
"mean_to_true",
|
|
32
|
+
"parabolic_to_mean",
|
|
33
|
+
"true_to_eccentric",
|
|
34
|
+
"true_to_hyperbolic",
|
|
35
|
+
"true_to_mean",
|
|
36
|
+
]
|
urania/core/elements.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""State vector (r, v) ↔ classical orbital elements.
|
|
2
|
+
|
|
3
|
+
All inputs and outputs are SI (m, m/s, m³/s², rad).
|
|
4
|
+
|
|
5
|
+
Orbit size is stored as the semi-latus rectum p instead of the semi-major axis a,
|
|
6
|
+
because p is finite for every conic, including the parabola (a = ∞).
|
|
7
|
+
|
|
8
|
+
Angle conventions for singular orbits (Vallado):
|
|
9
|
+
- Equatorial (i ≈ 0 or π): the node is undefined, so raan = 0 and the reference axis is x
|
|
10
|
+
- Circular (e ≈ 0): periapsis is undefined, so argp = 0 and ν is measured from the node
|
|
11
|
+
(from the x axis if also equatorial)
|
|
12
|
+
With these conventions coe_to_rv(rv_to_coe(r, v)) returns the original state even for singular orbits.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import math
|
|
16
|
+
from typing import NamedTuple
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
|
|
20
|
+
from .kepler import PARABOLIC_TOL, _wrap_2pi
|
|
21
|
+
|
|
22
|
+
# Dimensionless tolerances for treating an orbit as circular / equatorial
|
|
23
|
+
CIRCULAR_TOL = 1e-11
|
|
24
|
+
EQUATORIAL_TOL = 1e-11
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Elements(NamedTuple):
|
|
28
|
+
"""Classical orbital elements (SI, rad)."""
|
|
29
|
+
|
|
30
|
+
p: float # semi-latus rectum [m]
|
|
31
|
+
ecc: float # eccentricity [-]
|
|
32
|
+
inc: float # inclination [rad], 0 to π
|
|
33
|
+
raan: float # right ascension of the ascending node [rad], 0 to 2π
|
|
34
|
+
argp: float # argument of periapsis [rad], 0 to 2π
|
|
35
|
+
nu: float # true anomaly [rad]
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def a(self) -> float:
|
|
39
|
+
"""Semi-major axis [m]. inf for a parabola, negative for a hyperbola."""
|
|
40
|
+
if abs(self.ecc - 1.0) < PARABOLIC_TOL:
|
|
41
|
+
return math.inf
|
|
42
|
+
return self.p / (1.0 - self.ecc**2)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _angle_between(ref: np.ndarray, vec: np.ndarray, normal: np.ndarray) -> float:
|
|
46
|
+
"""Counterclockwise angle from ref to vec about normal, in [0, 2π)."""
|
|
47
|
+
y = np.dot(np.cross(ref, vec), normal)
|
|
48
|
+
x = np.dot(ref, vec)
|
|
49
|
+
return _wrap_2pi(math.atan2(y, x))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def rv_to_coe(r, v, mu: float) -> Elements:
|
|
53
|
+
"""Position and velocity vectors → classical orbital elements.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
r: position vector [m], length 3
|
|
57
|
+
v: velocity vector [m/s], length 3
|
|
58
|
+
mu: gravitational parameter of the central body [m³/s²]
|
|
59
|
+
"""
|
|
60
|
+
r = np.asarray(r, dtype=float)
|
|
61
|
+
v = np.asarray(v, dtype=float)
|
|
62
|
+
r_norm = np.linalg.norm(r)
|
|
63
|
+
v_norm = np.linalg.norm(v)
|
|
64
|
+
|
|
65
|
+
h_vec = np.cross(r, v)
|
|
66
|
+
h_norm = np.linalg.norm(h_vec)
|
|
67
|
+
if h_norm == 0.0:
|
|
68
|
+
raise ValueError("Angular momentum is zero (rectilinear orbits are not supported)")
|
|
69
|
+
h_hat = h_vec / h_norm
|
|
70
|
+
|
|
71
|
+
n_vec = np.cross([0.0, 0.0, 1.0], h_vec) # ascending node direction
|
|
72
|
+
n_norm = np.linalg.norm(n_vec)
|
|
73
|
+
|
|
74
|
+
e_vec = ((v_norm**2 - mu / r_norm) * r - np.dot(r, v) * v) / mu
|
|
75
|
+
ecc = float(np.linalg.norm(e_vec))
|
|
76
|
+
|
|
77
|
+
p = h_norm**2 / mu
|
|
78
|
+
inc = math.acos(np.clip(h_vec[2] / h_norm, -1.0, 1.0))
|
|
79
|
+
|
|
80
|
+
equatorial = n_norm < EQUATORIAL_TOL * h_norm
|
|
81
|
+
circular = ecc < CIRCULAR_TOL
|
|
82
|
+
|
|
83
|
+
n_hat = np.array([1.0, 0.0, 0.0]) if equatorial else n_vec / n_norm
|
|
84
|
+
raan = 0.0 if equatorial else _wrap_2pi(math.atan2(n_hat[1], n_hat[0]))
|
|
85
|
+
|
|
86
|
+
if circular:
|
|
87
|
+
argp = 0.0
|
|
88
|
+
nu = _angle_between(n_hat, r, h_hat)
|
|
89
|
+
else:
|
|
90
|
+
argp = _angle_between(n_hat, e_vec, h_hat)
|
|
91
|
+
nu = _angle_between(e_vec, r, h_hat)
|
|
92
|
+
|
|
93
|
+
return Elements(p=p, ecc=ecc, inc=inc, raan=raan, argp=argp, nu=nu)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _rot_perifocal_to_inertial(inc: float, raan: float, argp: float) -> np.ndarray:
|
|
97
|
+
"""Rotation matrix from the perifocal frame (PQW) to inertial: R3(Ω)·R1(i)·R3(ω)."""
|
|
98
|
+
cO, sO = math.cos(raan), math.sin(raan)
|
|
99
|
+
ci, si = math.cos(inc), math.sin(inc)
|
|
100
|
+
cw, sw = math.cos(argp), math.sin(argp)
|
|
101
|
+
return np.array([
|
|
102
|
+
[cO * cw - sO * sw * ci, -cO * sw - sO * cw * ci, sO * si],
|
|
103
|
+
[sO * cw + cO * sw * ci, -sO * sw + cO * cw * ci, -cO * si],
|
|
104
|
+
[sw * si, cw * si, ci],
|
|
105
|
+
])
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def coe_to_rv(p: float, ecc: float, inc: float, raan: float, argp: float,
|
|
109
|
+
nu: float, mu: float) -> tuple[np.ndarray, np.ndarray]:
|
|
110
|
+
"""Classical orbital elements → position and velocity vectors (r [m], v [m/s])."""
|
|
111
|
+
if p <= 0.0:
|
|
112
|
+
raise ValueError(f"Semi-latus rectum must be positive: p={p}")
|
|
113
|
+
denom = 1.0 + ecc * math.cos(nu)
|
|
114
|
+
if denom <= 0.0:
|
|
115
|
+
raise ValueError(f"ν={nu} rad is not reachable on this orbit (e={ecc})")
|
|
116
|
+
|
|
117
|
+
cnu, snu = math.cos(nu), math.sin(nu)
|
|
118
|
+
r_pqw = (p / denom) * np.array([cnu, snu, 0.0])
|
|
119
|
+
v_pqw = math.sqrt(mu / p) * np.array([-snu, ecc + cnu, 0.0])
|
|
120
|
+
|
|
121
|
+
Q = _rot_perifocal_to_inertial(inc, raan, argp)
|
|
122
|
+
return Q @ r_pqw, Q @ v_pqw
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def elements_to_rv(el: Elements, mu: float) -> tuple[np.ndarray, np.ndarray]:
|
|
126
|
+
"""Elements tuple → position and velocity vectors."""
|
|
127
|
+
return coe_to_rv(*el, mu=mu)
|
urania/core/forces.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Perturbation models: accelerations and atmospheric density. SI units, inertial frame."""
|
|
2
|
+
|
|
3
|
+
import bisect
|
|
4
|
+
import math
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def accel_twobody(r: np.ndarray, mu: float) -> np.ndarray:
|
|
10
|
+
"""Point-mass gravity of the central body: a = -μ r / |r|³."""
|
|
11
|
+
r_norm = np.sqrt(r @ r)
|
|
12
|
+
return -mu * r / r_norm**3
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def accel_j2(r: np.ndarray, mu: float, R: float, J2: float) -> np.ndarray:
|
|
16
|
+
"""J2 oblateness acceleration (Curtis eq. 12.30, Vallado eq. 8-30).
|
|
17
|
+
|
|
18
|
+
Assumes the body's spin axis is the inertial z axis.
|
|
19
|
+
"""
|
|
20
|
+
x, y, z = r
|
|
21
|
+
r2 = r @ r
|
|
22
|
+
r_norm = np.sqrt(r2)
|
|
23
|
+
factor = -1.5 * J2 * mu * R**2 / r_norm**5
|
|
24
|
+
k = 5.0 * z * z / r2
|
|
25
|
+
return factor * np.array([x * (1.0 - k), y * (1.0 - k), z * (3.0 - k)])
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def accel_drag(r: np.ndarray, v: np.ndarray, rho: float, ballistic: float,
|
|
29
|
+
rotation_rate: float) -> np.ndarray:
|
|
30
|
+
"""Atmospheric drag acceleration a = -½ ρ (C_D A / m) |v_rel| v_rel (Vallado eq. 8-28).
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
rho: atmospheric density [kg/m³]
|
|
34
|
+
ballistic: C_D·A/m [m²/kg]
|
|
35
|
+
rotation_rate: body rotation rate [rad/s]. The atmosphere co-rotates rigidly about z.
|
|
36
|
+
"""
|
|
37
|
+
v_rel = v - rotation_rate * np.array([-r[1], r[0], 0.0]) # v - ω×r
|
|
38
|
+
return -0.5 * rho * ballistic * np.sqrt(v_rel @ v_rel) * v_rel
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ---------------------------------------------------------------- atmospheric density
|
|
42
|
+
# Exponential atmosphere (Vallado 4th ed. table 8-4): ρ = ρ₀ exp(-(h - h₀)/H) per altitude band.
|
|
43
|
+
# A static model for mean solar activity; real density varies several-fold with solar activity.
|
|
44
|
+
# (base altitude h₀ [km], base density ρ₀ [kg/m³], scale height H [km])
|
|
45
|
+
VALLADO_TABLE = (
|
|
46
|
+
(0, 1.225, 7.249),
|
|
47
|
+
(25, 3.899e-2, 6.349),
|
|
48
|
+
(30, 1.774e-2, 6.682),
|
|
49
|
+
(40, 3.972e-3, 7.554),
|
|
50
|
+
(50, 1.057e-3, 8.382),
|
|
51
|
+
(60, 3.206e-4, 7.714),
|
|
52
|
+
(70, 8.770e-5, 6.549),
|
|
53
|
+
(80, 1.905e-5, 5.799),
|
|
54
|
+
(90, 3.396e-6, 5.382),
|
|
55
|
+
(100, 5.297e-7, 5.877),
|
|
56
|
+
(110, 9.661e-8, 7.263),
|
|
57
|
+
(120, 2.438e-8, 9.473),
|
|
58
|
+
(130, 8.484e-9, 12.636),
|
|
59
|
+
(140, 3.845e-9, 16.149),
|
|
60
|
+
(150, 2.070e-9, 22.523),
|
|
61
|
+
(180, 5.464e-10, 29.740),
|
|
62
|
+
(200, 2.789e-10, 37.105),
|
|
63
|
+
(250, 7.248e-11, 45.546),
|
|
64
|
+
(300, 2.418e-11, 53.628),
|
|
65
|
+
(350, 9.518e-12, 53.298),
|
|
66
|
+
(400, 3.725e-12, 58.515),
|
|
67
|
+
(450, 1.585e-12, 60.828),
|
|
68
|
+
(500, 6.967e-13, 63.822),
|
|
69
|
+
(600, 1.454e-13, 71.835),
|
|
70
|
+
(700, 3.614e-14, 88.667),
|
|
71
|
+
(800, 1.170e-14, 124.64),
|
|
72
|
+
(900, 5.245e-15, 181.05),
|
|
73
|
+
(1000, 3.019e-15, 268.00),
|
|
74
|
+
)
|
|
75
|
+
_BASE_ALTS = [row[0] * 1e3 for row in VALLADO_TABLE]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def geodetic_altitude(r: np.ndarray, R: float, f: float) -> float:
|
|
79
|
+
"""Height [m] above a reference ellipsoid (equatorial radius R, flattening f).
|
|
80
|
+
|
|
81
|
+
The ellipsoid is symmetric about the spin axis (inertial z), so no Earth rotation is needed.
|
|
82
|
+
Iterates the geodetic latitude (Vallado algorithm 12); the height formula stays well
|
|
83
|
+
conditioned at the poles. With f = 0 this is the spherical height |r| - R.
|
|
84
|
+
"""
|
|
85
|
+
x, y, z = r
|
|
86
|
+
p = math.hypot(x, y)
|
|
87
|
+
if f == 0.0:
|
|
88
|
+
return math.hypot(p, z) - R
|
|
89
|
+
e2 = f * (2.0 - f)
|
|
90
|
+
lat = math.atan2(z, p * (1.0 - e2))
|
|
91
|
+
for _ in range(6):
|
|
92
|
+
sin_lat = math.sin(lat)
|
|
93
|
+
N = R / math.sqrt(1.0 - e2 * sin_lat * sin_lat)
|
|
94
|
+
lat = math.atan2(z + N * e2 * sin_lat, p)
|
|
95
|
+
sin_lat, cos_lat = math.sin(lat), math.cos(lat)
|
|
96
|
+
return p * cos_lat + z * sin_lat - R * math.sqrt(1.0 - e2 * sin_lat * sin_lat)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def exponential_density(altitude: float) -> float:
|
|
100
|
+
"""Altitude [m] → atmospheric density [kg/m³]. Above 1000 km the last band is extended."""
|
|
101
|
+
if altitude < 0.0:
|
|
102
|
+
altitude = 0.0
|
|
103
|
+
i = bisect.bisect_right(_BASE_ALTS, altitude) - 1
|
|
104
|
+
h0_km, rho0, H_km = VALLADO_TABLE[i]
|
|
105
|
+
return rho0 * math.exp(-(altitude - h0_km * 1e3) / (H_km * 1e3))
|
urania/core/j2.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Secular rates of orbital elements due to J2 (oblateness). Curtis sec. 4.7, Vallado sec. 9.6.
|
|
2
|
+
|
|
3
|
+
Secular rates are orbit averages, so they are defined only for elliptic orbits (0 <= e < 1, a > 0).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import math
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _mean_motion_and_p(a: float, ecc: float, mu: float) -> tuple[float, float]:
|
|
10
|
+
if a <= 0.0 or not 0.0 <= ecc < 1.0:
|
|
11
|
+
raise ValueError(f"J2 secular rates are defined only for elliptic orbits: a={a}, e={ecc}")
|
|
12
|
+
return math.sqrt(mu / a**3), a * (1.0 - ecc**2)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def raan_rate(a: float, ecc: float, inc: float, mu: float, R: float, J2: float) -> float:
|
|
16
|
+
"""Mean rate of the right ascension of the ascending node dΩ/dt [rad/s].
|
|
17
|
+
|
|
18
|
+
dΩ/dt = -(3/2) n J2 (R/p)² cos i
|
|
19
|
+
"""
|
|
20
|
+
n, p = _mean_motion_and_p(a, ecc, mu)
|
|
21
|
+
return -1.5 * n * J2 * (R / p) ** 2 * math.cos(inc)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def argp_rate(a: float, ecc: float, inc: float, mu: float, R: float, J2: float) -> float:
|
|
25
|
+
"""Mean rate of the argument of periapsis dω/dt [rad/s].
|
|
26
|
+
|
|
27
|
+
dω/dt = (3/4) n J2 (R/p)² (5cos²i - 1)
|
|
28
|
+
"""
|
|
29
|
+
n, p = _mean_motion_and_p(a, ecc, mu)
|
|
30
|
+
return 0.75 * n * J2 * (R / p) ** 2 * (5.0 * math.cos(inc) ** 2 - 1.0)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def sso_inclination(a: float, ecc: float, mu: float, R: float, J2: float,
|
|
34
|
+
target_rate: float) -> float:
|
|
35
|
+
"""Inclination [rad] that gives a nodal rate of target_rate [rad/s] (for sun-synchronous orbits).
|
|
36
|
+
|
|
37
|
+
The raan_rate formula solved for cos i.
|
|
38
|
+
"""
|
|
39
|
+
n, p = _mean_motion_and_p(a, ecc, mu)
|
|
40
|
+
cos_i = -target_rate / (1.5 * n * J2 * (R / p) ** 2)
|
|
41
|
+
if abs(cos_i) > 1.0:
|
|
42
|
+
raise ValueError(f"The requested nodal rate is not achievable at a={a} m")
|
|
43
|
+
return math.acos(cos_i)
|
urania/core/kepler.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Kepler's equation and anomaly conversions.
|
|
2
|
+
|
|
3
|
+
All angles are in rad. The eccentricity e selects the conic type:
|
|
4
|
+
ellipse 0 <= e < 1, parabola e == 1, hyperbola e > 1.
|
|
5
|
+
|
|
6
|
+
For parabolas, the "mean anomaly" is the M in Barker's equation M = D + D³/3 (D = tan(ν/2)).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
|
|
11
|
+
TWO_PI = 2.0 * math.pi
|
|
12
|
+
|
|
13
|
+
# Eccentricity tolerance for treating an orbit as parabolic
|
|
14
|
+
PARABOLIC_TOL = 1e-12
|
|
15
|
+
|
|
16
|
+
_NEWTON_TOL = 1e-14
|
|
17
|
+
_NEWTON_MAXITER = 50
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _wrap_2pi(angle: float) -> float:
|
|
21
|
+
"""Normalize an angle to [0, 2π)."""
|
|
22
|
+
wrapped = angle % TWO_PI
|
|
23
|
+
# A tiny negative angle can round to exactly 2π
|
|
24
|
+
return 0.0 if wrapped == TWO_PI else wrapped
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _wrap_pi(angle: float) -> float:
|
|
28
|
+
"""Normalize an angle to [-π, π)."""
|
|
29
|
+
return (angle + math.pi) % TWO_PI - math.pi
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _check_ecc(e: float) -> None:
|
|
33
|
+
if e < 0.0:
|
|
34
|
+
raise ValueError(f"Eccentricity must be non-negative: e={e}")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ---------------------------------------------------------------- ellipse (e < 1)
|
|
38
|
+
|
|
39
|
+
def eccentric_to_mean(E: float, e: float) -> float:
|
|
40
|
+
"""Eccentric anomaly E → mean anomaly M (Kepler's equation M = E - e sin E)."""
|
|
41
|
+
return E - e * math.sin(E)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def mean_to_eccentric(M: float, e: float) -> float:
|
|
45
|
+
"""Mean anomaly M → eccentric anomaly E, solving Kepler's equation by Newton's method.
|
|
46
|
+
|
|
47
|
+
The result is in [0, 2π).
|
|
48
|
+
"""
|
|
49
|
+
_check_ecc(e)
|
|
50
|
+
if e >= 1.0:
|
|
51
|
+
raise ValueError(f"Only elliptic orbits (e < 1) are allowed: e={e}")
|
|
52
|
+
M = _wrap_pi(M)
|
|
53
|
+
# For large e a start near M can diverge, so start from π (Vallado algorithm 2)
|
|
54
|
+
E = M + e if M >= 0.0 else M - e
|
|
55
|
+
if e > 0.8:
|
|
56
|
+
E = math.pi if M >= 0.0 else -math.pi
|
|
57
|
+
for _ in range(_NEWTON_MAXITER):
|
|
58
|
+
dE = (E - e * math.sin(E) - M) / (1.0 - e * math.cos(E))
|
|
59
|
+
E -= dE
|
|
60
|
+
if abs(dE) < _NEWTON_TOL:
|
|
61
|
+
return _wrap_2pi(E)
|
|
62
|
+
raise RuntimeError(f"Kepler's equation did not converge: M={M}, e={e}")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def eccentric_to_true(E: float, e: float) -> float:
|
|
66
|
+
"""Eccentric anomaly E → true anomaly ν. The result is in [0, 2π)."""
|
|
67
|
+
nu = 2.0 * math.atan2(math.sqrt(1.0 + e) * math.sin(E / 2.0),
|
|
68
|
+
math.sqrt(1.0 - e) * math.cos(E / 2.0))
|
|
69
|
+
return _wrap_2pi(nu)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def true_to_eccentric(nu: float, e: float) -> float:
|
|
73
|
+
"""True anomaly ν → eccentric anomaly E. The result is in [0, 2π)."""
|
|
74
|
+
E = 2.0 * math.atan2(math.sqrt(1.0 - e) * math.sin(nu / 2.0),
|
|
75
|
+
math.sqrt(1.0 + e) * math.cos(nu / 2.0))
|
|
76
|
+
return _wrap_2pi(E)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# -------------------------------------------------------------- hyperbola (e > 1)
|
|
80
|
+
|
|
81
|
+
def hyperbolic_to_mean(H: float, e: float) -> float:
|
|
82
|
+
"""Hyperbolic anomaly H → mean anomaly M (M = e sinh H - H)."""
|
|
83
|
+
return e * math.sinh(H) - H
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def mean_to_hyperbolic(M: float, e: float) -> float:
|
|
87
|
+
"""Mean anomaly M → hyperbolic anomaly H, by Newton's method."""
|
|
88
|
+
if e <= 1.0:
|
|
89
|
+
raise ValueError(f"Only hyperbolic orbits (e > 1) are allowed: e={e}")
|
|
90
|
+
# For large |M|, e sinh H ≈ M, so start from a logarithmic estimate
|
|
91
|
+
H = math.copysign(math.log(2.0 * abs(M) / e + 1.8), M) if M != 0.0 else 0.0
|
|
92
|
+
for _ in range(_NEWTON_MAXITER):
|
|
93
|
+
dH = (e * math.sinh(H) - H - M) / (e * math.cosh(H) - 1.0)
|
|
94
|
+
H -= dH
|
|
95
|
+
if abs(dH) < _NEWTON_TOL * max(1.0, abs(H)):
|
|
96
|
+
return H
|
|
97
|
+
raise RuntimeError(f"Hyperbolic Kepler equation did not converge: M={M}, e={e}")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def hyperbolic_to_true(H: float, e: float) -> float:
|
|
101
|
+
"""Hyperbolic anomaly H → true anomaly ν. The result is in (-π, π)."""
|
|
102
|
+
return 2.0 * math.atan(math.sqrt((e + 1.0) / (e - 1.0)) * math.tanh(H / 2.0))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def true_to_hyperbolic(nu: float, e: float) -> float:
|
|
106
|
+
"""True anomaly ν → hyperbolic anomaly H.
|
|
107
|
+
|
|
108
|
+
ν must lie inside the asymptotes ±arccos(-1/e).
|
|
109
|
+
"""
|
|
110
|
+
nu = _wrap_pi(nu)
|
|
111
|
+
nu_inf = math.acos(-1.0 / e)
|
|
112
|
+
if abs(nu) >= nu_inf:
|
|
113
|
+
raise ValueError(f"ν={nu} rad is outside the hyperbola's asymptotes (±{nu_inf} rad)")
|
|
114
|
+
return 2.0 * math.atanh(math.sqrt((e - 1.0) / (e + 1.0)) * math.tan(nu / 2.0))
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ------------------------------------------------------------- parabola (e == 1)
|
|
118
|
+
|
|
119
|
+
def parabolic_to_mean(D: float) -> float:
|
|
120
|
+
"""Parabolic anomaly D = tan(ν/2) → Barker mean anomaly M = D + D³/3."""
|
|
121
|
+
return D + D**3 / 3.0
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def mean_to_parabolic(M: float) -> float:
|
|
125
|
+
"""Solve Barker's equation D + D³/3 = M in closed form."""
|
|
126
|
+
# Unique real root of D³ + 3D - 3M = 0 (Cardano): D = ∛(w+s) + ∛(w-s).
|
|
127
|
+
# Since (w+s)(w-s) = -1, ∛(w-s) = -1/∛(w+s). Computing w-s directly cancels badly for large |w|.
|
|
128
|
+
w = 1.5 * abs(M)
|
|
129
|
+
c = math.cbrt(w + math.sqrt(w * w + 1.0))
|
|
130
|
+
return math.copysign(c - 1.0 / c, M)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# ------------------------------------------------------------- any conic
|
|
134
|
+
|
|
135
|
+
def mean_to_true(M: float, e: float) -> float:
|
|
136
|
+
"""Mean anomaly M → true anomaly ν for an ellipse, parabola or hyperbola.
|
|
137
|
+
|
|
138
|
+
Ellipses return [0, 2π); parabolas and hyperbolas return (-π, π).
|
|
139
|
+
"""
|
|
140
|
+
_check_ecc(e)
|
|
141
|
+
if abs(e - 1.0) < PARABOLIC_TOL:
|
|
142
|
+
return 2.0 * math.atan(mean_to_parabolic(M))
|
|
143
|
+
if e < 1.0:
|
|
144
|
+
return eccentric_to_true(mean_to_eccentric(M, e), e)
|
|
145
|
+
return hyperbolic_to_true(mean_to_hyperbolic(M, e), e)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def true_to_mean(nu: float, e: float) -> float:
|
|
149
|
+
"""True anomaly ν → mean anomaly M.
|
|
150
|
+
|
|
151
|
+
Ellipses return [0, 2π); parabolas and hyperbolas return a signed value.
|
|
152
|
+
"""
|
|
153
|
+
_check_ecc(e)
|
|
154
|
+
if abs(e - 1.0) < PARABOLIC_TOL:
|
|
155
|
+
return parabolic_to_mean(math.tan(_wrap_pi(nu) / 2.0))
|
|
156
|
+
if e < 1.0:
|
|
157
|
+
return _wrap_2pi(eccentric_to_mean(true_to_eccentric(nu, e), e))
|
|
158
|
+
return hyperbolic_to_mean(true_to_hyperbolic(nu, e), e)
|
urania/core/maneuvers.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Impulsive maneuver formulas. SI units. Curtis ch. 6, Vallado ch. 6.
|
|
2
|
+
|
|
3
|
+
Transfers between circular orbits. Δv values are signed tangential components (+ is prograde).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import math
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
from scipy.optimize import minimize_scalar
|
|
10
|
+
|
|
11
|
+
from .twobody import vis_viva as _vis_viva
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def hohmann(r1: float, r2: float, mu: float) -> tuple[float, float, float]:
|
|
15
|
+
"""Hohmann transfer between circular orbits r1 → r2 (Vallado algorithm 36).
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
(dv1, dv2, tof): tangential Δv of both burns [m/s] (negative when descending), transfer time [s]
|
|
19
|
+
"""
|
|
20
|
+
a_t = 0.5 * (r1 + r2)
|
|
21
|
+
dv1 = _vis_viva(r1, a_t, mu) - math.sqrt(mu / r1)
|
|
22
|
+
dv2 = math.sqrt(mu / r2) - _vis_viva(r2, a_t, mu)
|
|
23
|
+
tof = math.pi * math.sqrt(a_t**3 / mu)
|
|
24
|
+
return dv1, dv2, tof
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def bielliptic(r1: float, rb: float, r2: float, mu: float) -> tuple[float, float, float, float]:
|
|
28
|
+
"""Bi-elliptic transfer r1 → rb (intermediate apoapsis) → r2 between circular orbits (Vallado algorithm 37).
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
(dv1, dv2, dv3, tof): tangential Δv of the three burns [m/s], transfer time [s]
|
|
32
|
+
"""
|
|
33
|
+
if rb < max(r1, r2):
|
|
34
|
+
raise ValueError(f"Intermediate apoapsis rb={rb} must be larger than r1 and r2")
|
|
35
|
+
a1 = 0.5 * (r1 + rb)
|
|
36
|
+
a2 = 0.5 * (rb + r2)
|
|
37
|
+
dv1 = _vis_viva(r1, a1, mu) - math.sqrt(mu / r1)
|
|
38
|
+
dv2 = _vis_viva(rb, a2, mu) - _vis_viva(rb, a1, mu)
|
|
39
|
+
dv3 = math.sqrt(mu / r2) - _vis_viva(r2, a2, mu)
|
|
40
|
+
tof = math.pi * (math.sqrt(a1**3 / mu) + math.sqrt(a2**3 / mu))
|
|
41
|
+
return dv1, dv2, dv3, tof
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def plane_change_dv(v: float, dtheta: float) -> float:
|
|
45
|
+
"""Δv to rotate the orbit plane by dtheta [rad] at constant speed: 2v sin(Δθ/2)."""
|
|
46
|
+
return 2.0 * v * math.sin(0.5 * abs(dtheta))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def combined_dv(v1: float, v2: float, dtheta: float) -> float:
|
|
50
|
+
"""Δv for a combined speed change and plane change (law of cosines)."""
|
|
51
|
+
return math.sqrt(max(v1 * v1 + v2 * v2 - 2.0 * v1 * v2 * math.cos(dtheta), 0.0))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def hohmann_plane_change(r1: float, r2: float, dtheta: float, mu: float,
|
|
55
|
+
split: float) -> tuple[float, float, float]:
|
|
56
|
+
"""Hohmann transfer with a plane change.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
dtheta: angle between the two orbit planes [rad]
|
|
60
|
+
split: fraction of the plane change done at the first burn (0 to 1); the rest at the second.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
(dv1, dv2, tof): Δv magnitudes of both burns [m/s], transfer time [s]
|
|
64
|
+
"""
|
|
65
|
+
a_t = 0.5 * (r1 + r2)
|
|
66
|
+
dv1 = combined_dv(math.sqrt(mu / r1), _vis_viva(r1, a_t, mu), split * dtheta)
|
|
67
|
+
dv2 = combined_dv(_vis_viva(r2, a_t, mu), math.sqrt(mu / r2), (1.0 - split) * dtheta)
|
|
68
|
+
return dv1, dv2, math.pi * math.sqrt(a_t**3 / mu)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def optimal_plane_split(r1: float, r2: float, dtheta: float, mu: float) -> float:
|
|
72
|
+
"""Plane change split (0 to 1) that minimizes the total Δv."""
|
|
73
|
+
if dtheta == 0.0:
|
|
74
|
+
return 0.0
|
|
75
|
+
res = minimize_scalar(lambda s: sum(hohmann_plane_change(r1, r2, dtheta, mu, s)[:2]),
|
|
76
|
+
bounds=(0.0, 1.0), method="bounded", options={"xatol": 1e-10})
|
|
77
|
+
return float(res.x)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def rotate(vec, axis, angle: float) -> np.ndarray:
|
|
81
|
+
"""Rotate a vector by angle [rad] about the unit axis (Rodrigues' formula)."""
|
|
82
|
+
vec = np.asarray(vec, dtype=float)
|
|
83
|
+
k = np.asarray(axis, dtype=float)
|
|
84
|
+
c, s = math.cos(angle), math.sin(angle)
|
|
85
|
+
return vec * c + np.cross(k, vec) * s + k * (k @ vec) * (1.0 - c)
|