urania 0.0.1__tar.gz
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-0.0.1/.gitignore +7 -0
- urania-0.0.1/LICENSE +21 -0
- urania-0.0.1/PKG-INFO +188 -0
- urania-0.0.1/README.md +159 -0
- urania-0.0.1/SPEC.md +119 -0
- urania-0.0.1/docs/DECISIONS.md +156 -0
- urania-0.0.1/examples/cubesat_decay.py +33 -0
- urania-0.0.1/examples/quickstart.py +22 -0
- urania-0.0.1/examples/tle_vs_propagator.py +40 -0
- urania-0.0.1/pyproject.toml +49 -0
- urania-0.0.1/src/urania/__init__.py +19 -0
- urania-0.0.1/src/urania/bodies.py +90 -0
- urania-0.0.1/src/urania/constants.py +10 -0
- urania-0.0.1/src/urania/core/__init__.py +36 -0
- urania-0.0.1/src/urania/core/elements.py +127 -0
- urania-0.0.1/src/urania/core/forces.py +105 -0
- urania-0.0.1/src/urania/core/j2.py +43 -0
- urania-0.0.1/src/urania/core/kepler.py +158 -0
- urania-0.0.1/src/urania/core/maneuvers.py +85 -0
- urania-0.0.1/src/urania/core/propagate.py +84 -0
- urania-0.0.1/src/urania/core/twobody.py +54 -0
- urania-0.0.1/src/urania/environment.py +87 -0
- urania-0.0.1/src/urania/explain.py +312 -0
- urania-0.0.1/src/urania/maneuvers.py +227 -0
- urania-0.0.1/src/urania/orbits.py +265 -0
- urania-0.0.1/src/urania/propagation.py +251 -0
- urania-0.0.1/src/urania/time.py +91 -0
- urania-0.0.1/src/urania/tle.py +222 -0
- urania-0.0.1/src/urania/units.py +62 -0
- urania-0.0.1/src/urania/viz.py +176 -0
- urania-0.0.1/tests/conftest.py +4 -0
- urania-0.0.1/tests/core/test_elements.py +109 -0
- urania-0.0.1/tests/core/test_forces.py +95 -0
- urania-0.0.1/tests/core/test_kepler.py +84 -0
- urania-0.0.1/tests/core/test_maneuvers.py +97 -0
- urania-0.0.1/tests/core/test_propagate.py +93 -0
- urania-0.0.1/tests/core/test_twobody_j2.py +74 -0
- urania-0.0.1/tests/fixtures/gmat/j2.txt +171 -0
- urania-0.0.1/tests/fixtures/gmat/j2_drag.txt +75 -0
- urania-0.0.1/tests/fixtures/gmat/twobody.txt +27 -0
- urania-0.0.1/tests/test_bodies.py +26 -0
- urania-0.0.1/tests/test_docs.py +40 -0
- urania-0.0.1/tests/test_environment.py +60 -0
- urania-0.0.1/tests/test_explain_plot.py +145 -0
- urania-0.0.1/tests/test_gmat.py +124 -0
- urania-0.0.1/tests/test_import.py +5 -0
- urania-0.0.1/tests/test_maneuvers.py +165 -0
- urania-0.0.1/tests/test_orbits.py +105 -0
- urania-0.0.1/tests/test_propagation.py +173 -0
- urania-0.0.1/tests/test_time.py +73 -0
- urania-0.0.1/tests/test_tle.py +157 -0
- urania-0.0.1/tests/test_units.py +53 -0
- urania-0.0.1/validation/gmat/README.md +53 -0
- urania-0.0.1/validation/gmat/j2.script +60 -0
- urania-0.0.1/validation/gmat/j2_drag.script +61 -0
- urania-0.0.1/validation/gmat/make_scripts.py +148 -0
- urania-0.0.1/validation/gmat/twobody.script +60 -0
urania-0.0.1/.gitignore
ADDED
urania-0.0.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dandmadp
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
urania-0.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: urania
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: An astrodynamics library that computes, shows and explains
|
|
5
|
+
Project-URL: Homepage, https://github.com/dandmadp/urania
|
|
6
|
+
Project-URL: Source, https://github.com/dandmadp/urania
|
|
7
|
+
Project-URL: Issues, https://github.com/dandmadp/urania/issues
|
|
8
|
+
Author: dandmadp
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: astrodynamics,cubesat,hohmann,orbit,orbital mechanics,satellite,sgp4,tle
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Education
|
|
14
|
+
Classifier: Intended Audience :: Science/Research
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Topic :: Scientific/Engineering :: Astronomy
|
|
19
|
+
Classifier: Topic :: Scientific/Engineering :: Physics
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Requires-Dist: astropy>=5.3
|
|
22
|
+
Requires-Dist: matplotlib>=3.7
|
|
23
|
+
Requires-Dist: numpy>=1.24
|
|
24
|
+
Requires-Dist: scipy>=1.10
|
|
25
|
+
Requires-Dist: sgp4>=2.22
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# urania
|
|
31
|
+
|
|
32
|
+
**An astrodynamics library that computes, shows and explains**
|
|
33
|
+
|
|
34
|
+
urania is a Python library for people who are not professionals but want to compute orbits seriously: CubeSat teams, students, space simulation game players. Every result records the model and assumptions it was computed with, and `.explain()` walks through the formulas and intermediate values step by step.
|
|
35
|
+
|
|
36
|
+
> Status: MVP in development (v0.0.1). Design: [SPEC.md](https://github.com/dandmadp/urania/blob/main/SPEC.md). Design decisions: [docs/DECISIONS.md](https://github.com/dandmadp/urania/blob/main/docs/DECISIONS.md).
|
|
37
|
+
|
|
38
|
+
## Installation
|
|
39
|
+
|
|
40
|
+
Not on PyPI yet, so install from source. Python 3.10 or newer.
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
git clone https://github.com/dandmadp/urania.git
|
|
44
|
+
cd urania
|
|
45
|
+
python -m venv .venv
|
|
46
|
+
.venv\Scripts\activate # macOS/Linux: source .venv/bin/activate
|
|
47
|
+
pip install -e ".[dev]"
|
|
48
|
+
pytest
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Quick start
|
|
52
|
+
|
|
53
|
+
The Δv needed to go from the ISS orbit to geostationary orbit (GEO):
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
import urania as ur
|
|
57
|
+
|
|
58
|
+
transfer = ur.ISS.transfer_to(ur.GEO)
|
|
59
|
+
print(transfer) # Transfer(hohmann, Δv=4.7797 km/s, tof=5.295 h, 2 burns)
|
|
60
|
+
print(transfer.explain()) # formulas, intermediate speeds, Δv per burn, assumptions
|
|
61
|
+
transfer.plot() # 2D plot (matplotlib)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Part of the `explain()` output:
|
|
65
|
+
|
|
66
|
+
```text
|
|
67
|
+
3. Burn 1 (r₁)
|
|
68
|
+
v₁ = √(μ/r₁) = 7.6573 km/s (initial circular orbit)
|
|
69
|
+
v₂ = √(μ(2/r₁ − 1/a_t)) = 10.0492 km/s (transfer ellipse)
|
|
70
|
+
combined with plane change α = 2.8913°
|
|
71
|
+
Δv = √(v₁² + v₂² − 2 v₁ v₂ cos α) = 2.4325 km/s
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Creating orbits
|
|
75
|
+
|
|
76
|
+
Pass values with units (astropy), or plain numbers, which are taken as SI (m, m/s, rad).
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
import astropy.units as u
|
|
80
|
+
import urania as ur
|
|
81
|
+
|
|
82
|
+
leo = ur.Orbit.circular(ur.Earth, 500 * u.km, inc=97.4 * u.deg)
|
|
83
|
+
ell = ur.Orbit.from_elements(ur.Earth, a=8000 * u.km, ecc=0.1, inc=30 * u.deg,
|
|
84
|
+
raan=40 * u.deg, argp=60 * u.deg, nu=0 * u.deg)
|
|
85
|
+
sv = ur.Orbit.from_vectors(ur.Earth, [6524.834, 6862.875, 6448.296] * u.km,
|
|
86
|
+
[4.901327, 5.533756, -1.976341] * u.km / u.s)
|
|
87
|
+
|
|
88
|
+
print(sv.a, sv.ecc, sv.period) # results are SI numbers: m, -, s
|
|
89
|
+
print(sv.explain()) # how the elements follow from the state vector
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Presets: bodies `Sun`, `Earth`, `Moon`, `Mars`; orbits `LEO` (500 km), `ISS` (420 km, 51.64°), `SSO` (700 km sun-synchronous), `GEO`.
|
|
93
|
+
|
|
94
|
+
## Propagation
|
|
95
|
+
|
|
96
|
+
Choose the fidelity: `"twobody"` (0) → `"j2"` (1) → `"j2+drag"` (2). The original object is unchanged; a new `Trajectory` is returned.
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
import astropy.units as u
|
|
100
|
+
import urania as ur
|
|
101
|
+
|
|
102
|
+
cubesat = dict(cd=2.2, area=0.03 * u.m**2, mass=4.0 * u.kg)
|
|
103
|
+
start = ur.Orbit.circular(ur.Earth, 400 * u.km, inc=51.6 * u.deg)
|
|
104
|
+
|
|
105
|
+
tr = start.propagate(days=3, model="j2+drag", **cubesat)
|
|
106
|
+
print(tr.final) # orbit after 3 days
|
|
107
|
+
print(tr.info.assumptions) # model and assumptions used
|
|
108
|
+
tr.plot(kind="altitude") # altitude with an orbit-averaged line
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
You can inject environment data yourself: a constant, a function `f(r, t)`, or an `Environment` object.
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
import urania as ur
|
|
115
|
+
|
|
116
|
+
start = ur.Orbit.circular(ur.Earth, 400e3)
|
|
117
|
+
kw = dict(days=1, model="drag", area=0.03, mass=4.0)
|
|
118
|
+
|
|
119
|
+
start.propagate(density=3e-12, **kw) # constant [kg/m³]
|
|
120
|
+
start.propagate(density=lambda r, t: 2 * ur.Earth.atmosphere(r, t), **kw) # function
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Maneuvers
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
import astropy.units as u
|
|
127
|
+
import urania as ur
|
|
128
|
+
|
|
129
|
+
ur.LEO.transfer_to(ur.GEO) # Hohmann transfer
|
|
130
|
+
far = ur.Orbit.circular(ur.Earth, 150000 * u.km)
|
|
131
|
+
ur.LEO.transfer_to(far, "bielliptic", rb=300000 * u.km) # bi-elliptic transfer
|
|
132
|
+
ur.ISS.transfer_to(ur.GEO, plane_split=0) # whole plane change at apoapsis
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
When the planes differ, burns happen at the line of nodes, and by default a Hohmann transfer splits the plane change optimally between its two burns.
|
|
136
|
+
|
|
137
|
+
## TLE
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
import urania as ur
|
|
141
|
+
|
|
142
|
+
iss = ur.TLEOrbit.from_text("""ISS (ZARYA)
|
|
143
|
+
1 25544U 98067A 19343.69339541 .00001764 00000-0 38792-4 0 9991
|
|
144
|
+
2 25544 51.6439 211.2001 0007417 17.6667 85.6398 15.50103472202482""")
|
|
145
|
+
|
|
146
|
+
sgp4 = iss.propagate(days=1) # SGP4 only
|
|
147
|
+
mine = iss.to_orbit().propagate(days=1, model="j2") # extract the state, propagate with urania
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
TLE elements are SGP4-specific mean elements, so they are kept separate from `Orbit`. The frame is SGP4's TEME, used as is.
|
|
151
|
+
|
|
152
|
+
## Accuracy and validation
|
|
153
|
+
|
|
154
|
+
| What | Reference | Result |
|
|
155
|
+
|---|---|---|
|
|
156
|
+
| Element conversion | Curtis examples 4.3, 4.7 / Vallado example 2-5 | matches to the textbook's printed precision |
|
|
157
|
+
| Kepler's equation and propagation | Vallado examples 2-1, 2-3, 2-4 | within 1e-9 rad / 1 m |
|
|
158
|
+
| Hohmann and bi-elliptic transfers | Vallado examples 6-1, 6-2 | Δv within 1 mm/s |
|
|
159
|
+
| J2 secular drift | analytic nodal rate | within 1% over 10 days |
|
|
160
|
+
| Drag decay | analytic circular decay da/dt = −ρB√(μa) | within 1% |
|
|
161
|
+
| SGP4 wrapper | Vallado SGP4 verification set (satellite 00005) | within 1e-6 km |
|
|
162
|
+
| urania propagator vs SGP4 | real ISS TLE (2019-12-09), J2 model | at most 2.34 km over 24 hours |
|
|
163
|
+
| NASA GMAT R2026a, two-body | 1 day, RK89 | at most 0.009 m |
|
|
164
|
+
| NASA GMAT R2026a, J2 | 7 days, 420 km, 51.64° | at most 3.3 m (144 m without the true pole, see below) |
|
|
165
|
+
| NASA GMAT R2026a, J2 + drag | 3 days, 400 km, exponential atmosphere | at most 3.3 m (73 m without the true pole) |
|
|
166
|
+
|
|
167
|
+
SGP4 itself is accurate to about 1 km near epoch, so the SGP4 comparison is a sanity check rather than precise validation. Precise validation comes from the GMAT comparison ([validation/gmat](https://github.com/dandmadp/urania/blob/main/validation/gmat), [tests/test_gmat.py](https://github.com/dandmadp/urania/blob/main/tests/test_gmat.py)).
|
|
168
|
+
|
|
169
|
+
GMAT evaluates J2 and the atmosphere about Earth's true spin axis, which at the J2000 epoch is 7.69" away from the J2000 z axis. urania uses the frame's z axis as the spin axis. The small figures above compare both in a frame whose z axis is the true pole; the larger figures in parentheses are the direct comparison in the J2000 frame.
|
|
170
|
+
|
|
171
|
+
## Limitations (MVP)
|
|
172
|
+
|
|
173
|
+
- Perturbations: J2 and atmospheric drag. Lunar/solar perturbations, solar radiation pressure and higher-order gravity come later.
|
|
174
|
+
- Atmosphere: Vallado's exponential model (mean solar activity), with height above the reference ellipsoid. Real density varies several-fold with solar activity.
|
|
175
|
+
- Maneuvers: impulsive transfers between circular orbits. Rendezvous (phasing) and interplanetary transfers come later.
|
|
176
|
+
- Time: TDB internally. UTC input goes through `Epoch.from_astropy()`.
|
|
177
|
+
- Frames: fixed inertial axes (precession and nutation ignored). TLEs use TEME as is.
|
|
178
|
+
- Plots: 2D only.
|
|
179
|
+
|
|
180
|
+
## Examples
|
|
181
|
+
|
|
182
|
+
- [examples/quickstart.py](https://github.com/dandmadp/urania/blob/main/examples/quickstart.py): ISS → GEO transfer
|
|
183
|
+
- [examples/cubesat_decay.py](https://github.com/dandmadp/urania/blob/main/examples/cubesat_decay.py): 3U CubeSat decay and sensitivity to atmospheric density
|
|
184
|
+
- [examples/tle_vs_propagator.py](https://github.com/dandmadp/urania/blob/main/examples/tle_vs_propagator.py): comparison with SGP4 using a real ISS TLE
|
|
185
|
+
|
|
186
|
+
## License
|
|
187
|
+
|
|
188
|
+
MIT
|
urania-0.0.1/README.md
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# urania
|
|
2
|
+
|
|
3
|
+
**An astrodynamics library that computes, shows and explains**
|
|
4
|
+
|
|
5
|
+
urania is a Python library for people who are not professionals but want to compute orbits seriously: CubeSat teams, students, space simulation game players. Every result records the model and assumptions it was computed with, and `.explain()` walks through the formulas and intermediate values step by step.
|
|
6
|
+
|
|
7
|
+
> Status: MVP in development (v0.0.1). Design: [SPEC.md](https://github.com/dandmadp/urania/blob/main/SPEC.md). Design decisions: [docs/DECISIONS.md](https://github.com/dandmadp/urania/blob/main/docs/DECISIONS.md).
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
Not on PyPI yet, so install from source. Python 3.10 or newer.
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
git clone https://github.com/dandmadp/urania.git
|
|
15
|
+
cd urania
|
|
16
|
+
python -m venv .venv
|
|
17
|
+
.venv\Scripts\activate # macOS/Linux: source .venv/bin/activate
|
|
18
|
+
pip install -e ".[dev]"
|
|
19
|
+
pytest
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Quick start
|
|
23
|
+
|
|
24
|
+
The Δv needed to go from the ISS orbit to geostationary orbit (GEO):
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
import urania as ur
|
|
28
|
+
|
|
29
|
+
transfer = ur.ISS.transfer_to(ur.GEO)
|
|
30
|
+
print(transfer) # Transfer(hohmann, Δv=4.7797 km/s, tof=5.295 h, 2 burns)
|
|
31
|
+
print(transfer.explain()) # formulas, intermediate speeds, Δv per burn, assumptions
|
|
32
|
+
transfer.plot() # 2D plot (matplotlib)
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Part of the `explain()` output:
|
|
36
|
+
|
|
37
|
+
```text
|
|
38
|
+
3. Burn 1 (r₁)
|
|
39
|
+
v₁ = √(μ/r₁) = 7.6573 km/s (initial circular orbit)
|
|
40
|
+
v₂ = √(μ(2/r₁ − 1/a_t)) = 10.0492 km/s (transfer ellipse)
|
|
41
|
+
combined with plane change α = 2.8913°
|
|
42
|
+
Δv = √(v₁² + v₂² − 2 v₁ v₂ cos α) = 2.4325 km/s
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Creating orbits
|
|
46
|
+
|
|
47
|
+
Pass values with units (astropy), or plain numbers, which are taken as SI (m, m/s, rad).
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
import astropy.units as u
|
|
51
|
+
import urania as ur
|
|
52
|
+
|
|
53
|
+
leo = ur.Orbit.circular(ur.Earth, 500 * u.km, inc=97.4 * u.deg)
|
|
54
|
+
ell = ur.Orbit.from_elements(ur.Earth, a=8000 * u.km, ecc=0.1, inc=30 * u.deg,
|
|
55
|
+
raan=40 * u.deg, argp=60 * u.deg, nu=0 * u.deg)
|
|
56
|
+
sv = ur.Orbit.from_vectors(ur.Earth, [6524.834, 6862.875, 6448.296] * u.km,
|
|
57
|
+
[4.901327, 5.533756, -1.976341] * u.km / u.s)
|
|
58
|
+
|
|
59
|
+
print(sv.a, sv.ecc, sv.period) # results are SI numbers: m, -, s
|
|
60
|
+
print(sv.explain()) # how the elements follow from the state vector
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Presets: bodies `Sun`, `Earth`, `Moon`, `Mars`; orbits `LEO` (500 km), `ISS` (420 km, 51.64°), `SSO` (700 km sun-synchronous), `GEO`.
|
|
64
|
+
|
|
65
|
+
## Propagation
|
|
66
|
+
|
|
67
|
+
Choose the fidelity: `"twobody"` (0) → `"j2"` (1) → `"j2+drag"` (2). The original object is unchanged; a new `Trajectory` is returned.
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
import astropy.units as u
|
|
71
|
+
import urania as ur
|
|
72
|
+
|
|
73
|
+
cubesat = dict(cd=2.2, area=0.03 * u.m**2, mass=4.0 * u.kg)
|
|
74
|
+
start = ur.Orbit.circular(ur.Earth, 400 * u.km, inc=51.6 * u.deg)
|
|
75
|
+
|
|
76
|
+
tr = start.propagate(days=3, model="j2+drag", **cubesat)
|
|
77
|
+
print(tr.final) # orbit after 3 days
|
|
78
|
+
print(tr.info.assumptions) # model and assumptions used
|
|
79
|
+
tr.plot(kind="altitude") # altitude with an orbit-averaged line
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
You can inject environment data yourself: a constant, a function `f(r, t)`, or an `Environment` object.
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
import urania as ur
|
|
86
|
+
|
|
87
|
+
start = ur.Orbit.circular(ur.Earth, 400e3)
|
|
88
|
+
kw = dict(days=1, model="drag", area=0.03, mass=4.0)
|
|
89
|
+
|
|
90
|
+
start.propagate(density=3e-12, **kw) # constant [kg/m³]
|
|
91
|
+
start.propagate(density=lambda r, t: 2 * ur.Earth.atmosphere(r, t), **kw) # function
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Maneuvers
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
import astropy.units as u
|
|
98
|
+
import urania as ur
|
|
99
|
+
|
|
100
|
+
ur.LEO.transfer_to(ur.GEO) # Hohmann transfer
|
|
101
|
+
far = ur.Orbit.circular(ur.Earth, 150000 * u.km)
|
|
102
|
+
ur.LEO.transfer_to(far, "bielliptic", rb=300000 * u.km) # bi-elliptic transfer
|
|
103
|
+
ur.ISS.transfer_to(ur.GEO, plane_split=0) # whole plane change at apoapsis
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
When the planes differ, burns happen at the line of nodes, and by default a Hohmann transfer splits the plane change optimally between its two burns.
|
|
107
|
+
|
|
108
|
+
## TLE
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
import urania as ur
|
|
112
|
+
|
|
113
|
+
iss = ur.TLEOrbit.from_text("""ISS (ZARYA)
|
|
114
|
+
1 25544U 98067A 19343.69339541 .00001764 00000-0 38792-4 0 9991
|
|
115
|
+
2 25544 51.6439 211.2001 0007417 17.6667 85.6398 15.50103472202482""")
|
|
116
|
+
|
|
117
|
+
sgp4 = iss.propagate(days=1) # SGP4 only
|
|
118
|
+
mine = iss.to_orbit().propagate(days=1, model="j2") # extract the state, propagate with urania
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
TLE elements are SGP4-specific mean elements, so they are kept separate from `Orbit`. The frame is SGP4's TEME, used as is.
|
|
122
|
+
|
|
123
|
+
## Accuracy and validation
|
|
124
|
+
|
|
125
|
+
| What | Reference | Result |
|
|
126
|
+
|---|---|---|
|
|
127
|
+
| Element conversion | Curtis examples 4.3, 4.7 / Vallado example 2-5 | matches to the textbook's printed precision |
|
|
128
|
+
| Kepler's equation and propagation | Vallado examples 2-1, 2-3, 2-4 | within 1e-9 rad / 1 m |
|
|
129
|
+
| Hohmann and bi-elliptic transfers | Vallado examples 6-1, 6-2 | Δv within 1 mm/s |
|
|
130
|
+
| J2 secular drift | analytic nodal rate | within 1% over 10 days |
|
|
131
|
+
| Drag decay | analytic circular decay da/dt = −ρB√(μa) | within 1% |
|
|
132
|
+
| SGP4 wrapper | Vallado SGP4 verification set (satellite 00005) | within 1e-6 km |
|
|
133
|
+
| urania propagator vs SGP4 | real ISS TLE (2019-12-09), J2 model | at most 2.34 km over 24 hours |
|
|
134
|
+
| NASA GMAT R2026a, two-body | 1 day, RK89 | at most 0.009 m |
|
|
135
|
+
| NASA GMAT R2026a, J2 | 7 days, 420 km, 51.64° | at most 3.3 m (144 m without the true pole, see below) |
|
|
136
|
+
| NASA GMAT R2026a, J2 + drag | 3 days, 400 km, exponential atmosphere | at most 3.3 m (73 m without the true pole) |
|
|
137
|
+
|
|
138
|
+
SGP4 itself is accurate to about 1 km near epoch, so the SGP4 comparison is a sanity check rather than precise validation. Precise validation comes from the GMAT comparison ([validation/gmat](https://github.com/dandmadp/urania/blob/main/validation/gmat), [tests/test_gmat.py](https://github.com/dandmadp/urania/blob/main/tests/test_gmat.py)).
|
|
139
|
+
|
|
140
|
+
GMAT evaluates J2 and the atmosphere about Earth's true spin axis, which at the J2000 epoch is 7.69" away from the J2000 z axis. urania uses the frame's z axis as the spin axis. The small figures above compare both in a frame whose z axis is the true pole; the larger figures in parentheses are the direct comparison in the J2000 frame.
|
|
141
|
+
|
|
142
|
+
## Limitations (MVP)
|
|
143
|
+
|
|
144
|
+
- Perturbations: J2 and atmospheric drag. Lunar/solar perturbations, solar radiation pressure and higher-order gravity come later.
|
|
145
|
+
- Atmosphere: Vallado's exponential model (mean solar activity), with height above the reference ellipsoid. Real density varies several-fold with solar activity.
|
|
146
|
+
- Maneuvers: impulsive transfers between circular orbits. Rendezvous (phasing) and interplanetary transfers come later.
|
|
147
|
+
- Time: TDB internally. UTC input goes through `Epoch.from_astropy()`.
|
|
148
|
+
- Frames: fixed inertial axes (precession and nutation ignored). TLEs use TEME as is.
|
|
149
|
+
- Plots: 2D only.
|
|
150
|
+
|
|
151
|
+
## Examples
|
|
152
|
+
|
|
153
|
+
- [examples/quickstart.py](https://github.com/dandmadp/urania/blob/main/examples/quickstart.py): ISS → GEO transfer
|
|
154
|
+
- [examples/cubesat_decay.py](https://github.com/dandmadp/urania/blob/main/examples/cubesat_decay.py): 3U CubeSat decay and sensitivity to atmospheric density
|
|
155
|
+
- [examples/tle_vs_propagator.py](https://github.com/dandmadp/urania/blob/main/examples/tle_vs_propagator.py): comparison with SGP4 using a real ISS TLE
|
|
156
|
+
|
|
157
|
+
## License
|
|
158
|
+
|
|
159
|
+
MIT
|
urania-0.0.1/SPEC.md
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# urania — Astrodynamics Library Design Specification (MVP)
|
|
2
|
+
|
|
3
|
+
> **An astrodynamics library that computes, shows and explains**
|
|
4
|
+
> Package name: **urania** (after Urania, the muse of astronomy). Confirmed free on PyPI as of 2026-10-07. Reserve the name with an empty package when work starts.
|
|
5
|
+
|
|
6
|
+
## 1. Purpose and target users
|
|
7
|
+
|
|
8
|
+
- Audience: university clubs (CubeSat teams, etc.), students, space simulation game players, educational content creators, space enthusiasts
|
|
9
|
+
- What they share: not professionals, but serious about understanding and computing things in space
|
|
10
|
+
- Accuracy target: at least textbook level, good enough for a CubeSat team's practical calculations. Space-agency level (high-order gravity fields, etc.) is not a goal
|
|
11
|
+
- Every result must be verifiable, and must record which assumptions and models produced it
|
|
12
|
+
|
|
13
|
+
## 2. Core design principles
|
|
14
|
+
|
|
15
|
+
1. **The object API is the official entry point**
|
|
16
|
+
- Documentation, README and tutorial examples are all written with the object API
|
|
17
|
+
- Example: `ISS.orbit.transfer_to(GEO).plot()`
|
|
18
|
+
2. **A functional core inside**
|
|
19
|
+
- Actual computation is implemented only in pure functions in `urania.core`
|
|
20
|
+
- Object methods only call core functions (one implementation, in one place)
|
|
21
|
+
- Core functions must also work with plain numbers (SI) → for checking against textbook examples
|
|
22
|
+
3. **Immutable objects**
|
|
23
|
+
- All major objects are immutable, e.g. `dataclass(frozen=True)`
|
|
24
|
+
- `orbit.propagate(...)` returns a new object without changing the original
|
|
25
|
+
4. **Units: SI internally, conversion only at the boundary**
|
|
26
|
+
- Internal computation uses only m, kg, s, K, W
|
|
27
|
+
- Input: values with units (choose astropy.units or pint and record why) are converted; plain numbers are taken as SI
|
|
28
|
+
- Output: SI by default, converted on request
|
|
29
|
+
5. **Time: always TDB seconds internally (seconds elapsed since an epoch)**
|
|
30
|
+
- UTC/TAI conversion comes after the MVP, but time is encapsulated in one type so it can be added later
|
|
31
|
+
6. **Environment factors are injected as arguments**
|
|
32
|
+
- The library provides the computational framework; the user supplies environment data (radiation, solar flux, etc.)
|
|
33
|
+
- Three forms are accepted: constant / `f(position, t)` function / `Environment` object
|
|
34
|
+
- Internally a constant is wrapped as "a function that always returns the same value", so there is one form
|
|
35
|
+
- The MVP does not implement environment models themselves, only this injection interface
|
|
36
|
+
7. **Selectable fidelity levels**
|
|
37
|
+
- 0: two-body / 1: +J2 / 2: +atmospheric drag (MVP scope)
|
|
38
|
+
- 3: +lunar/solar perturbations / 4: +solar radiation pressure, high-order gravity (after the MVP)
|
|
39
|
+
- Example: `orbit.propagate(days=30, model="j2+drag")`
|
|
40
|
+
8. **Result metadata**
|
|
41
|
+
- Result objects keep the model, integrator, tolerances and key assumptions used
|
|
42
|
+
9. **Explanation and visualization**
|
|
43
|
+
- `.explain()`: prints the formulas, assumptions and intermediate values step by step
|
|
44
|
+
- `.plot()`: 2D orbit plot (matplotlib)
|
|
45
|
+
|
|
46
|
+
## 3. MVP scope
|
|
47
|
+
|
|
48
|
+
### Included
|
|
49
|
+
- **Objects**
|
|
50
|
+
- `Body`: mass, radius, μ, J2, hook for an atmosphere model
|
|
51
|
+
- `Orbit`: orbital elements ↔ position/velocity (state vector) conversion
|
|
52
|
+
- `Transfer`: maneuver result (total Δv, duration, list of burns)
|
|
53
|
+
- `TLEOrbit`: TLE-based orbit (see TLE below)
|
|
54
|
+
- **Presets**
|
|
55
|
+
- Bodies: Sun, Earth, Moon, Mars (the other planets if time allows)
|
|
56
|
+
- Orbits: representative orbits such as `LEO`, `ISS`, `GEO`, `SSO`
|
|
57
|
+
- **Orbit propagation**: fidelity levels 0 to 2
|
|
58
|
+
- Keep drag simple, around an exponential atmosphere model
|
|
59
|
+
- Integration: adaptive, based on `scipy.integrate.solve_ivp` (e.g. DOP853)
|
|
60
|
+
- **Maneuvers**: Hohmann transfer, bi-elliptic transfer, plane change
|
|
61
|
+
- **TLE support**
|
|
62
|
+
- A thin wrapper around the `sgp4` package
|
|
63
|
+
- TLE orbits are propagated only with SGP4 and never mixed with the in-house propagator (separate `TLEOrbit` object)
|
|
64
|
+
- Provide an explicit path that extracts a state vector and converts it to an `Orbit` when needed
|
|
65
|
+
- **Explanation/visualization**: `.explain()`, `.plot()`
|
|
66
|
+
- **Unit handling, result metadata, environment injection interface**
|
|
67
|
+
|
|
68
|
+
### Excluded (next version)
|
|
69
|
+
- Environment models such as temperature and radiation
|
|
70
|
+
- Lunar/solar perturbations, JPL ephemeris (DE440) integration
|
|
71
|
+
- Interplanetary transfers, launch window (porkchop) calculations
|
|
72
|
+
- 3D visualization, animation
|
|
73
|
+
- UTC/TAI/TDB conversion
|
|
74
|
+
|
|
75
|
+
## 4. Validation
|
|
76
|
+
|
|
77
|
+
- Compare with textbook examples (Curtis, Vallado worked solutions)
|
|
78
|
+
- Compare with NASA GMAT results (reference data stored as test fixtures)
|
|
79
|
+
- Compare SGP4 predictions with the in-house propagator using a real ISS TLE (doubles as an example and a test)
|
|
80
|
+
- Goal: be able to state in the README a figure such as "within ○○ of GMAT", with evidence
|
|
81
|
+
- Test framework: pytest
|
|
82
|
+
|
|
83
|
+
## 5. Proposed module structure
|
|
84
|
+
|
|
85
|
+
```
|
|
86
|
+
urania/
|
|
87
|
+
core/ # pure functions: orbit conversions, Kepler's equation, maneuver formulas
|
|
88
|
+
bodies.py # Body, body presets
|
|
89
|
+
orbits.py # Orbit, orbit presets
|
|
90
|
+
propagation/ # force models per fidelity level, integrators
|
|
91
|
+
maneuvers.py # Transfer, maneuver calculations
|
|
92
|
+
tle.py # TLEOrbit (sgp4 wrapper)
|
|
93
|
+
environment.py # environment injection interface
|
|
94
|
+
units.py # unit conversion boundary
|
|
95
|
+
time.py # time representation (internal TDB seconds)
|
|
96
|
+
viz.py # plot
|
|
97
|
+
explain.py # explain output
|
|
98
|
+
tests/
|
|
99
|
+
examples/
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
The structure is a proposal; adjust it with a reason if a better layout exists.
|
|
103
|
+
|
|
104
|
+
## 6. Development order (recommended)
|
|
105
|
+
|
|
106
|
+
1. core: state vector ↔ orbital elements, Kepler's equation solver + textbook example tests
|
|
107
|
+
2. Unit and time boundary (`units.py`, `time.py`)
|
|
108
|
+
3. `Body`, `Orbit` objects and presets
|
|
109
|
+
4. Propagator: two-body → J2 → drag
|
|
110
|
+
5. Maneuvers (`Transfer`)
|
|
111
|
+
6. `.plot()`, `.explain()`
|
|
112
|
+
7. TLE wrapper
|
|
113
|
+
8. GMAT and TLE comparison tests, README examples
|
|
114
|
+
|
|
115
|
+
## 7. Working requests
|
|
116
|
+
|
|
117
|
+
- Make the tests pass at the end of each step before moving on
|
|
118
|
+
- Record design decisions (such as the unit library choice) with a short rationale
|
|
119
|
+
- Show code changes as code blocks containing only the changed parts
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# Design decisions
|
|
2
|
+
|
|
3
|
+
## D1. Store orbit size as the semi-latus rectum p, not the semi-major axis a (step 1)
|
|
4
|
+
- a is infinite for a parabola and negative for a hyperbola, which is awkward for a single type.
|
|
5
|
+
- p is positive and finite for every conic. a is provided through the `Elements.a` property.
|
|
6
|
+
|
|
7
|
+
## D2. Angle conventions for singular orbits (step 1)
|
|
8
|
+
- Equatorial (i ≈ 0, π): raan = 0, reference axis is inertial x.
|
|
9
|
+
- Circular (e ≈ 0): argp = 0, ν is measured from the node (from x if also equatorial).
|
|
10
|
+
- This packs Vallado's true longitude and argument of latitude into the usual six elements without extra fields.
|
|
11
|
+
Tests guarantee that `coe_to_rv(rv_to_coe(r, v))` is the identity even for singular orbits.
|
|
12
|
+
- Detection tolerances are dimensionless 1e-11 (`CIRCULAR_TOL`, `EQUATORIAL_TOL`).
|
|
13
|
+
|
|
14
|
+
## D3. Core functions are scalar and `math`-based (step 1)
|
|
15
|
+
- Kepler's equation and anomaly conversions take scalar floats. They are called inside propagation loops,
|
|
16
|
+
so this avoids numpy array overhead and keeps the code close to the textbook formulas.
|
|
17
|
+
- If vectorization is needed (e.g. bulk sampling for plots), add separate functions.
|
|
18
|
+
|
|
19
|
+
## D4. The parabolic mean anomaly is Barker's M = D + D³/3 (step 1)
|
|
20
|
+
- So that `mean_to_true`/`true_to_mean` handle all three conic types from the eccentricity alone.
|
|
21
|
+
|
|
22
|
+
## D5. astropy for units (step 2)
|
|
23
|
+
- UTC/TAI/TDB conversion will be needed after the MVP, and `astropy.time` provides it. With pint we would have to implement time scales ourselves.
|
|
24
|
+
- It is the de facto standard in astronomy and space, so target users are likely familiar with it.
|
|
25
|
+
- Its downsides (install size, Quantity overhead) do not affect computation because units are converted only at the boundary (`units.py`).
|
|
26
|
+
|
|
27
|
+
## D6. Plain numbers are SI, including angles (step 2)
|
|
28
|
+
- `to_si(30, ANGLE)` is 30 rad. Degrees must be given explicitly as `30 * u.deg`.
|
|
29
|
+
- Making angles an exception would mean two rules and more mistakes.
|
|
30
|
+
|
|
31
|
+
## D7. Time is one float64 of TDB seconds since J2000 (step 2)
|
|
32
|
+
- Encapsulated in `Epoch(tdb_seconds)`. Over 2000 to 2100 float64 resolves about 1e-7 s, which is under 1 mm
|
|
33
|
+
of position error at LEO speed (7.8 km/s), enough for the MVP accuracy target.
|
|
34
|
+
- Calendar and JD input is read as TDB. The TDB calendar has no leap seconds, so plain date differences are exact.
|
|
35
|
+
- Other time scales go through `Epoch.from_astropy()`, where astropy converts them. No in-house conversion.
|
|
36
|
+
|
|
37
|
+
## D8. Body stores μ (GM) instead of mass (step 3)
|
|
38
|
+
- μ is determined directly from orbit observations and is hundreds of thousands of times more accurate than G or M alone (G itself is uncertain to about 2e-5).
|
|
39
|
+
- Mass is provided as the property `Body.mass = μ/G`. Each preset records the source of its constants in `source`.
|
|
40
|
+
|
|
41
|
+
## D9. An Orbit is defined by its state vector (r, v, epoch) (step 3)
|
|
42
|
+
- The state vector has no singularities for circular, equatorial or parabolic orbits, and matches the propagator's input/output format.
|
|
43
|
+
- Orbital elements are computed once with `cached_property`. The object is immutable, so the cache never goes stale.
|
|
44
|
+
- r and v are copied into read-only arrays. Value comparison of numpy arrays is ambiguous, so `eq=False` (identity).
|
|
45
|
+
|
|
46
|
+
## D10. The ISS preset is an Orbit (step 3)
|
|
47
|
+
- SPEC's example `ISS.orbit.transfer_to(GEO)` implies a spacecraft object, but spacecraft are not in the MVP.
|
|
48
|
+
The preset list (LEO, ISS, GEO, SSO) classifies them as orbits, so the form is `ISS.transfer_to(GEO)`.
|
|
49
|
+
- Presets are representative (ISS: 420 km circular, 51.64°). Use a TLE for the actual position.
|
|
50
|
+
- The GEO radius comes from Earth's rotation rate, the SSO inclination from the J2 nodal rate.
|
|
51
|
+
|
|
52
|
+
## D11. propagate() returns a Trajectory, not an Orbit (step 4)
|
|
53
|
+
- SPEC asks both for "return a new object" and "keep result metadata". Rather than mixing metadata into Orbit,
|
|
54
|
+
it returns a `Trajectory` with the time series (t, r, v) and `PropagationInfo` (model, integrator, tolerances, assumptions, stop reason).
|
|
55
|
+
- The final orbit is `trajectory.final`, intermediate ones `orbit_at(i)`. The step 6 `.plot()` uses the time series directly.
|
|
56
|
+
|
|
57
|
+
## D12. Two-body uses the analytic solution by default, everything else DOP853 (step 4)
|
|
58
|
+
- `method="auto"`: two-body uses the Kepler solution (exact and fast); J2 and drag use `solve_ivp` DOP853.
|
|
59
|
+
`method="DOP853"` integrates two-body numerically too, which is useful for checking the integrator.
|
|
60
|
+
- Default tolerances rtol=1e-12, atol=1e-8. With rtol=1e-10 an eccentric orbit drifted 51 m from the analytic solution over 10 days;
|
|
61
|
+
with 1e-12, 0.5 m. Cost is about 1.7x (30 days of ISS j2+drag takes about 5.5 s). Accuracy was chosen for the GMAT comparison.
|
|
62
|
+
- Integration stops on reaching the body's surface and records it in `info.terminated`.
|
|
63
|
+
|
|
64
|
+
## D13. All environment values become an Environment (step 4)
|
|
65
|
+
- Constant → `Constant`, function f(r, t) → `Function`, `Environment` objects as is. `as_environment()` converts.
|
|
66
|
+
- t is TDB seconds since J2000 (float). It is called every step, so no Epoch object is created.
|
|
67
|
+
- Each Environment has a `description` that goes into the assumptions of the result metadata.
|
|
68
|
+
- Earth's default atmosphere is the Vallado table 8-4 exponential model (`Body.atmosphere`). `density=` overrides it.
|
|
69
|
+
|
|
70
|
+
## D14. A single propagation.py module instead of a package (step 4)
|
|
71
|
+
- SPEC suggested a `propagation/` package, but at the current size (about 200 lines) one module is easier to read.
|
|
72
|
+
Force models live in `core/forces.py` and integration in `core/propagate.py`, so propagation.py only assembles them.
|
|
73
|
+
Split it into a package when fidelity levels 3 and 4 are added.
|
|
74
|
+
- For the same reason atmospheric density has no separate file: the table and density function are in `core/forces.py`,
|
|
75
|
+
and `ExponentialAtmosphere` is in `environment.py`, since an atmosphere model is an Environment.
|
|
76
|
+
- The two-body analytic solution also stops at the surface, like integration does (brentq between samples finds the impact time).
|
|
77
|
+
|
|
78
|
+
## Note (resolved in step 6, D19): J2 oscillation of the osculating semi-major axis
|
|
79
|
+
- With J2 the osculating semi-major axis oscillates strongly with a short period (0 to -12 km at ISS altitude). Drag-only decay over 30 days is 2.65 km,
|
|
80
|
+
but comparing only the endpoints makes it look like 14 km. `Trajectory.explain()` warns and shows the orbit-averaged change; the altitude plot shows a one-orbit moving average.
|
|
81
|
+
|
|
82
|
+
## D15. Transfers are built from state vectors, not just Δv formulas (step 5)
|
|
83
|
+
- `Transfer` holds `Burn`s with position, epoch and Δv vector, plus the orbit right after each burn (`orbits`).
|
|
84
|
+
Separately from the magnitude-only formulas (core/maneuvers.py), it adds Δv to the state vector and links burns with the analytic solution.
|
|
85
|
+
The step 6 plot() uses it directly, and tests cross-check formulas against the vector results.
|
|
86
|
+
- MVP limits: initial and target orbits must be circular (e ≤ 1e-3); phasing within the target orbit (rendezvous) is not matched.
|
|
87
|
+
|
|
88
|
+
## D16. Burns happen at the node when planes differ; Hohmann plane change is split optimally (step 5)
|
|
89
|
+
- A plane change is only possible at the line of nodes. If the start is not at a node, wait for the next node
|
|
90
|
+
(`Transfer.coast`). A Hohmann transfer covers half an orbit, so burn 2 happens at the opposite node.
|
|
91
|
+
- Default `plane_split="optimal"`: the split of the plane change between the two burns minimizes total Δv
|
|
92
|
+
(ISS→GEO: 2.89° at burn 1, about 40 m/s cheaper than doing it all at apoapsis). A number 0 to 1 can be given instead.
|
|
93
|
+
- A bi-elliptic transfer does the whole plane change at the slow intermediate apoapsis.
|
|
94
|
+
|
|
95
|
+
## D17. pytest uses the importlib import mode (step 5)
|
|
96
|
+
- So core and object-layer tests can share file names, such as `tests/core/test_maneuvers.py` and `tests/test_maneuvers.py`,
|
|
97
|
+
`--import-mode=importlib` is enabled (the mode pytest recommends).
|
|
98
|
+
|
|
99
|
+
## D18. matplotlib is imported only when plotting (step 6)
|
|
100
|
+
- urania itself imports faster. Plot labels are in English like the rest of the library (D26).
|
|
101
|
+
|
|
102
|
+
## D19. explain() returns an Explanation object and cross-checks values with core (step 6)
|
|
103
|
+
- No print side effect. `__repr__` is the text, so it reads directly in a REPL or Jupyter, and print() works too.
|
|
104
|
+
- Intermediate values (circular speed, transfer ellipse speeds, ...) are computed with core functions (`vis_viva`, `combined_dv`, ...).
|
|
105
|
+
If a Δv recomputed from formulas disagrees with the state-vector Burn, a warning is added (`warnings`).
|
|
106
|
+
|
|
107
|
+
## D20. Transfer plots unfold each orbit into its own plane (step 6)
|
|
108
|
+
- Projecting onto one plane distorts a target orbit in another plane into an ellipse. All burns lie on the node axis,
|
|
109
|
+
so with x along the node and (ĥ × x̂) as each orbit's y axis, the unfolded orbits connect.
|
|
110
|
+
- Trajectory plots project onto the initial orbit plane (J2 precession shows in the projection). Altitude plots overlay a one-orbit moving average.
|
|
111
|
+
|
|
112
|
+
## D21. TLEs are a separate TLEOrbit propagated only with SGP4 (step 7)
|
|
113
|
+
- TLE elements are SGP4-specific mean elements; using them as classical elements is wrong. `TLEOrbit` does not inherit from `Orbit`,
|
|
114
|
+
and its `propagate()` uses only SGP4. The result is the same `Trajectory`, so plot and explain work as is.
|
|
115
|
+
- The only way to the urania propagator is the explicit `to_orbit(epoch)`, which extracts a state vector.
|
|
116
|
+
- The checksum and the satellite numbers of both lines are checked on construction. On an SGP4 error (decay, ...) the output stops
|
|
117
|
+
before it and `info.terminated` records it.
|
|
118
|
+
|
|
119
|
+
## D22. The TLE frame TEME is used as the inertial frame (step 7)
|
|
120
|
+
- TEME → GCRF needs precession and nutation models, outside the MVP scope (no time or frame transformations).
|
|
121
|
+
- The urania propagator also assumes "fixed inertial axes, spin axis = z". TEME's z axis is the true spin axis at that time,
|
|
122
|
+
so comparing SGP4 with urania is actually consistent. The assumptions list states that the frame is TEME.
|
|
123
|
+
|
|
124
|
+
## D23. TLE epochs (UTC) ↔ Epoch (TDB) go through astropy (step 7)
|
|
125
|
+
- D7 ruled out in-house time scale conversion. Only the TLE epoch and SGP4 call times go through astropy Time.
|
|
126
|
+
- Gravity constants are WGS72, the TLE convention. An Orbit from `to_orbit()` uses the Earth (WGS 84) constants.
|
|
127
|
+
|
|
128
|
+
## D24. GMAT comparison uses a script generator and fixed fixtures (step 8)
|
|
129
|
+
- GMAT output cannot be produced in this repository, so it is never made up. `validation/gmat/make_scripts.py` writes GMAT scripts
|
|
130
|
+
from urania scenarios, and the user commits the results of running them in GMAT (`tests/fixtures/gmat/*.txt`).
|
|
131
|
+
`tests/test_gmat.py` skips if the fixtures are missing. Tolerances were finalized from the GMAT R2026a results (D27).
|
|
132
|
+
- Conditions are matched to reduce differences: J2000 epoch (zero precession), a test body with GMAT's EGM96 constants, RK89 at 1e-13.
|
|
133
|
+
|
|
134
|
+
## D25. README code is executed by the tests (step 8)
|
|
135
|
+
- `tests/test_docs.py` runs every ```python block in the README and checks that the Transfer repr written in the README matches.
|
|
136
|
+
It also runs the example scripts (except cubesat_decay.py, which is slow with two 60-day runs).
|
|
137
|
+
- The urania-vs-SGP4 figure is the maximum measured at 1-minute steps (2.34 km). Looking at only 4 times almost gave 0.98 km.
|
|
138
|
+
|
|
139
|
+
## D26. Everything in English
|
|
140
|
+
- Code, docstrings, error messages, `explain()` output, tests and documents are all in English, for an international open-source audience.
|
|
141
|
+
One language avoids keeping two versions in sync. This replaces the earlier Korean-docstring rule and the
|
|
142
|
+
"explain() text is Korean" part of D18.
|
|
143
|
+
|
|
144
|
+
## D27. Atmospheric density uses the height above the reference ellipsoid (step 8, GMAT comparison)
|
|
145
|
+
- GMAT's exponential atmosphere uses the same Vallado table but the height above the reference ellipsoid. With a spherical
|
|
146
|
+
height urania differed from GMAT by 28.6 km after 3 days at 400 km and 51.64°: at the same |r| the ellipsoid height is up to
|
|
147
|
+
about 21 km larger at high latitude, so the density is 20 to 25% lower.
|
|
148
|
+
- `Body.flattening` was added (Earth WGS 84 1/298.257223563, Moon 0.0012, Mars 0.00589), and `core.forces.geodetic_altitude`
|
|
149
|
+
computes the exact height (iterated geodetic latitude). `ExponentialAtmosphere(radius, flattening)` uses it.
|
|
150
|
+
With the same spin axis as GMAT the difference is now 3.3 m.
|
|
151
|
+
- The surface-impact event and `Trajectory.altitude` stay spherical (|r| - R).
|
|
152
|
+
|
|
153
|
+
## Note: the frame's z axis is taken as the spin axis
|
|
154
|
+
- J2, atmospheric rotation and the ellipsoid all use the inertial z axis. GMAT uses the true pole of date, 7.69" from the J2000 z axis
|
|
155
|
+
at the J2000 epoch, which gives a 144 m difference after 7 days in the J2 scenario (3.3 m once both use the same axis).
|
|
156
|
+
- Not changed in this step; recorded here because it matters for state vectors given in the J2000 frame at epochs far from J2000.
|