Orbital Mechanics
This example demonstrates orbit propagation, Kepler’s equation, and Lambert’s problem.
Overview
Orbital mechanics for satellite tracking and space applications:
Two-body problem: Keplerian orbits
Orbit propagation: State evolution over time
Reference frames: GCRF/ITRF conversions
Orbital maneuvers: Hohmann, Lambert transfers
Key Concepts
Orbital elements: Semi-major axis, eccentricity, inclination
Kepler’s equation: Mean anomaly to eccentric anomaly
State vectors: Position and velocity in inertial frame
Time systems: UTC/TAI/GPS conversions and Julian dates
Algorithms
- Kepler’s Equation
Iterative solution (Newton-Raphson)
Universal variable formulation
Handles all orbit types
- Lambert’s Problem
Find orbit connecting two points
Given transfer time
Used for rendezvous planning
- Transfer Orbits
Hohmann two-impulse transfer
Minimum-energy transfer
Delta-v budgeting
Code Highlights
The example demonstrates:
State vector to orbital elements conversion with
state_to_orbital_elements()Kepler equation solving with
mean_to_eccentric_anomaly()Two-body propagation with
kepler_propagate()Lambert solvers
lambert_universal()andlambert_izzo()GCRF/ITRF frame conversions and Hohmann transfer design
Source Code
1"""
2Orbital Mechanics Example
3=========================
4
5This example demonstrates the orbital mechanics and astronomical
6algorithms in PyTCL:
7
8Kepler's Problem:
9- Mean, eccentric, and true anomaly conversions
10- Orbit propagation
11- Orbital elements and state vector conversions
12
13Orbital Quantities:
14- Period, mean motion, vis-viva equation
15- Specific angular momentum and energy
16- Periapsis and apoapsis radii
17
18Lambert's Problem:
19- Two-point boundary value orbit determination
20- Transfer orbit design
21- Hohmann and bi-elliptic transfers
22
23Time Systems:
24- Julian date conversions
25- UTC, TAI, GPS time conversions
26- Sidereal time
27
28Reference Frames:
29- GCRF/ITRF transformations
30- Precession and nutation
31
32These algorithms are essential for spacecraft trajectory design,
33orbit determination, and space situational awareness.
34"""
35
36from pathlib import Path
37
38import numpy as np
39import plotly.graph_objects as go
40
41# Output directory for generated plots
42OUTPUT_DIR = Path(__file__).parent.parent / "docs" / "_static" / "images" / "examples"
43OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
44
45# Global flag to control plotting
46SHOW_PLOTS = True
47
48
49from pytcl.astronomical import ( # Orbital elements; Kepler's equation
50 GM_EARTH,
51 GM_SUN,
52 OrbitalElements,
53 StateVector,
54 apoapsis_radius,
55 cal_to_jd,
56 circular_velocity,
57 eccentric_to_true_anomaly,
58 escape_velocity,
59 flight_path_angle,
60 gcrf_to_itrf,
61 gmst,
62 hohmann_transfer,
63 itrf_to_gcrf,
64 jd_to_cal,
65 kepler_propagate,
66 kepler_propagate_state,
67 lambert_izzo,
68 lambert_universal,
69 mean_motion,
70 mean_to_eccentric_anomaly,
71 mean_to_true_anomaly,
72 minimum_energy_transfer,
73 nutation_matrix,
74 orbit_radius,
75 orbital_elements_to_state,
76 orbital_period,
77 periapsis_radius,
78 precession_matrix_iau76,
79 specific_angular_momentum,
80 specific_orbital_energy,
81 state_to_orbital_elements,
82 true_to_eccentric_anomaly,
83 utc_to_gps,
84 utc_to_tai,
85 vis_viva,
86)
87
88
89def demo_orbital_elements():
90 """Demonstrate orbital elements and conversions."""
91 print("=" * 70)
92 print("Orbital Elements Demo")
93 print("=" * 70)
94
95 # Define an orbit using classical orbital elements
96 # ISS-like orbit
97 a = 6778.0 # Semi-major axis (km) - ~400 km altitude
98 e = 0.0001 # Eccentricity (nearly circular)
99 i = np.radians(51.6) # Inclination
100 raan = np.radians(0.0) # Right ascension of ascending node
101 omega = np.radians(0.0) # Argument of periapsis
102 nu = np.radians(0.0) # True anomaly
103
104 elements = OrbitalElements(a=a, e=e, i=i, raan=raan, omega=omega, nu=nu)
105
106 print("\nISS-like orbit (orbital elements):")
107 print(f" Semi-major axis: {a:.1f} km")
108 print(f" Eccentricity: {e:.4f}")
109 print(f" Inclination: {np.degrees(i):.1f} deg")
110 print(f" RAAN: {np.degrees(raan):.1f} deg")
111 print(f" Arg. of periapsis: {np.degrees(omega):.1f} deg")
112 print(f" True anomaly: {np.degrees(nu):.1f} deg")
113
114 # Convert to state vector
115 state = orbital_elements_to_state(elements, GM_EARTH)
116
117 print("\nState vector (ECI frame):")
118 print(f" Position: ({state.r[0]:.3f}, {state.r[1]:.3f}, {state.r[2]:.3f}) km")
119 print(f" Velocity: ({state.v[0]:.3f}, {state.v[1]:.3f}, {state.v[2]:.3f}) km/s")
120
121 # Compute orbital quantities
122 T = orbital_period(a, GM_EARTH)
123 n = mean_motion(a, GM_EARTH)
124 v_circ = circular_velocity(a, GM_EARTH)
125 v_esc = escape_velocity(a, GM_EARTH)
126
127 print("\nOrbital quantities:")
128 print(f" Period: {T:.1f} s ({T / 60:.1f} min)")
129 print(f" Mean motion: {n * 86400 / (2 * np.pi):.2f} rev/day")
130 print(f" Circular velocity: {v_circ:.3f} km/s")
131 print(f" Escape velocity: {v_esc:.3f} km/s")
132
133 # Convert back and verify
134 elements_back = state_to_orbital_elements(state, GM_EARTH)
135 print(f"\nRoundtrip conversion check:")
136 print(f" a difference: {abs(elements_back.a - a):.6f} km")
137 print(f" e difference: {abs(elements_back.e - e):.9f}")
138
139
140def demo_kepler_equation():
141 """Demonstrate Kepler's equation and anomaly conversions."""
142 print("\n" + "=" * 70)
143 print("Kepler's Equation Demo")
144 print("=" * 70)
145
146 # Elliptical orbit
147 e = 0.5 # Moderate eccentricity
148
149 print(f"\nAnomaly conversions for e = {e}:")
150 print("-" * 50)
151 print(f"{'M (deg)':>10} {'E (deg)':>10} {'nu (deg)':>10}")
152 print("-" * 50)
153
154 for M_deg in [0, 30, 60, 90, 120, 150, 180]:
155 M = np.radians(M_deg)
156 E = mean_to_eccentric_anomaly(M, e)
157 nu = eccentric_to_true_anomaly(E, e)
158 print(f"{M_deg:>10.0f} {np.degrees(E):>10.2f} {np.degrees(nu):>10.2f}")
159
160 # Show the relationship
161 print("\nNote: For elliptical orbits:")
162 print(" - True anomaly (nu) leads mean anomaly (M) near periapsis")
163 print(" - They are equal only at periapsis and apoapsis")
164
165 # Hyperbolic orbit example
166 print("\n--- Hyperbolic Orbit ---")
167 e_hyp = 1.5 # Hyperbolic
168
169 print(f"Eccentricity: {e_hyp} (hyperbolic trajectory)")
170 print("For hyperbolic orbits, only a range of true anomalies is valid:")
171 nu_max = np.arccos(-1 / e_hyp)
172 print(
173 f" Valid range: -{np.degrees(nu_max):.1f} deg < nu < {np.degrees(nu_max):.1f} deg"
174 )
175
176
177def demo_orbit_propagation():
178 """Demonstrate orbit propagation."""
179 print("\n" + "=" * 70)
180 print("Orbit Propagation Demo")
181 print("=" * 70)
182
183 # Initial orbit (GPS satellite-like)
184 a = 26560.0 # Semi-major axis (km)
185 e = 0.02 # Slight eccentricity
186 i = np.radians(55.0) # Inclination
187 raan = np.radians(120.0)
188 omega = np.radians(45.0)
189 nu0 = np.radians(0.0)
190
191 elements0 = OrbitalElements(a=a, e=e, i=i, raan=raan, omega=omega, nu=nu0)
192 state0 = orbital_elements_to_state(elements0, GM_EARTH)
193
194 # Orbital period
195 T = orbital_period(a, GM_EARTH)
196
197 print(f"\nGPS satellite orbit:")
198 print(f" Semi-major axis: {a:.0f} km")
199 print(f" Period: {T / 3600:.2f} hours (~12 hours)")
200
201 # Propagate for one orbit
202 print("\nPropagation around one orbit:")
203 print("-" * 60)
204 print(f"{'Time (hr)':>10} {'r (km)':>12} {'v (km/s)':>10} {'nu (deg)':>10}")
205 print("-" * 60)
206
207 for frac in [0, 0.125, 0.25, 0.375, 0.5, 0.625, 0.75, 0.875, 1.0]:
208 dt = frac * T
209 state = kepler_propagate_state(state0, dt, GM_EARTH)
210 elements = state_to_orbital_elements(state, GM_EARTH)
211
212 r_mag = np.linalg.norm(state.r)
213 v_mag = np.linalg.norm(state.v)
214
215 print(
216 f"{dt / 3600:>10.2f} {r_mag:>12.1f} {v_mag:>10.4f} "
217 f"{np.degrees(elements.nu):>10.1f}"
218 )
219
220 # Verify vis-viva equation
221 print("\n--- Vis-Viva Equation Check ---")
222 for frac in [0, 0.25, 0.5]:
223 dt = frac * T
224 state = kepler_propagate_state(state0, dt, GM_EARTH)
225 r = np.linalg.norm(state.r)
226 v_actual = np.linalg.norm(state.v)
227 v_visviva = vis_viva(r, a, GM_EARTH)
228 print(
229 f" t={frac * T / 3600:.1f}h: v_actual={v_actual:.4f}, "
230 f"v_visviva={v_visviva:.4f} km/s"
231 )
232
233 # Plot orbit
234 if SHOW_PLOTS:
235 # Propagate full orbit for plotting
236 n_points = 100
237 positions = []
238 for idx in range(n_points + 1):
239 dt = idx * T / n_points
240 state = kepler_propagate_state(state0, dt, GM_EARTH)
241 positions.append(state.r)
242 positions = np.array(positions)
243
244 fig = go.Figure()
245
246 # Plot orbit
247 fig.add_trace(
248 go.Scatter3d(
249 x=positions[:, 0],
250 y=positions[:, 1],
251 z=positions[:, 2],
252 mode="lines",
253 line=dict(color="blue", width=4),
254 name="Orbit",
255 )
256 )
257
258 # Plot Earth (scaled for visibility)
259 u = np.linspace(0, 2 * np.pi, 30)
260 v = np.linspace(0, np.pi, 20)
261 earth_r = 6371 # km
262 x = earth_r * np.outer(np.cos(u), np.sin(v))
263 y = earth_r * np.outer(np.sin(u), np.sin(v))
264 z = earth_r * np.outer(np.ones(np.size(u)), np.cos(v))
265
266 fig.add_trace(
267 go.Surface(
268 x=x,
269 y=y,
270 z=z,
271 colorscale=[[0, "blue"], [1, "blue"]],
272 opacity=0.3,
273 showscale=False,
274 name="Earth",
275 )
276 )
277
278 # Mark periapsis and apoapsis
279 fig.add_trace(
280 go.Scatter3d(
281 x=[positions[0, 0]],
282 y=[positions[0, 1]],
283 z=[positions[0, 2]],
284 mode="markers",
285 marker=dict(color="green", size=10, symbol="circle"),
286 name="Periapsis",
287 )
288 )
289
290 fig.add_trace(
291 go.Scatter3d(
292 x=[positions[n_points // 2, 0]],
293 y=[positions[n_points // 2, 1]],
294 z=[positions[n_points // 2, 2]],
295 mode="markers",
296 marker=dict(color="red", size=10, symbol="square"),
297 name="Apoapsis",
298 )
299 )
300
301 # Equal aspect ratio
302 max_range = np.max(np.abs(positions)) * 1.1
303
304 fig.update_layout(
305 title="GPS Satellite Orbit",
306 scene=dict(
307 xaxis=dict(title="X (km)", range=[-max_range, max_range]),
308 yaxis=dict(title="Y (km)", range=[-max_range, max_range]),
309 zaxis=dict(title="Z (km)", range=[-max_range, max_range]),
310 aspectmode="cube",
311 ),
312 height=700,
313 width=800,
314 showlegend=True,
315 )
316 # Use external CDN for Plotly to reduce file size from 4.5MB to ~50KB
317 fig.write_html(
318 str(OUTPUT_DIR / "orbital_propagation.html"),
319 include_plotlyjs="cdn",
320 div_id="orbital_propagation",
321 )
322 print("\n [Plot saved to orbital_propagation.html]")
323
324
325def demo_lambert_problem():
326 """Demonstrate Lambert's problem for orbit determination."""
327 print("\n" + "=" * 70)
328 print("Lambert's Problem Demo")
329 print("=" * 70)
330
331 # Earth to Mars transfer (simplified)
332 # Initial position: Earth at 1 AU
333 r1 = np.array([1.0, 0.0, 0.0]) * 149597870.7 # km (1 AU)
334
335 # Final position: Mars at 1.52 AU (simplified circular orbit)
336 theta_mars = np.radians(135) # 135 deg ahead
337 r2 = np.array([np.cos(theta_mars), np.sin(theta_mars), 0.0]) * 1.52 * 149597870.7
338
339 # Transfer time: approximately 259 days (Hohmann-like)
340 tof = 259 * 86400 # seconds
341
342 print("\nEarth-Mars transfer scenario:")
343 print(
344 f" Departure: Earth at ({r1[0] / 149597870.7:.2f}, "
345 f"{r1[1] / 149597870.7:.2f}, 0) AU"
346 )
347 print(
348 f" Arrival: Mars at ({r2[0] / 149597870.7:.2f}, "
349 f"{r2[1] / 149597870.7:.2f}, 0) AU"
350 )
351 print(f" Time of flight: {tof / 86400:.0f} days")
352
353 # Solve Lambert's problem
354 solution = lambert_universal(r1, r2, tof, GM_SUN)
355
356 print("\nLambert solution:")
357 print(
358 f" Departure velocity: ({solution.v1[0]:.3f}, {solution.v1[1]:.3f}, "
359 f"{solution.v1[2]:.3f}) km/s"
360 )
361 print(
362 f" Arrival velocity: ({solution.v2[0]:.3f}, {solution.v2[1]:.3f}, "
363 f"{solution.v2[2]:.3f}) km/s"
364 )
365 print(f" Transfer orbit semi-major axis: {solution.a / 149597870.7:.3f} AU")
366 print(f" Transfer orbit eccentricity: {solution.e:.4f}")
367
368 # Delta-v calculations (simplified)
369 # Earth's orbital velocity
370 v_earth = np.array([0, 29.78, 0]) # km/s (approximately)
371 dv_departure = np.linalg.norm(solution.v1 - v_earth)
372
373 print(f"\n Departure delta-v: {dv_departure:.2f} km/s")
374
375
376def demo_hohmann_transfer():
377 """Demonstrate Hohmann transfer orbit."""
378 print("\n" + "=" * 70)
379 print("Hohmann Transfer Demo")
380 print("=" * 70)
381
382 # LEO to GEO transfer
383 r_leo = 6678.0 # km (300 km altitude)
384 r_geo = 42164.0 # km (GEO radius)
385
386 print("\nLEO to GEO Hohmann transfer:")
387 print(f" Initial orbit (LEO): r = {r_leo:.0f} km (alt = {r_leo - 6378:.0f} km)")
388 print(f" Final orbit (GEO): r = {r_geo:.0f} km (alt = {r_geo - 6378:.0f} km)")
389
390 # Velocities in circular orbits
391 v_leo = circular_velocity(r_leo, GM_EARTH)
392 v_geo = circular_velocity(r_geo, GM_EARTH)
393
394 print(f"\n LEO circular velocity: {v_leo:.3f} km/s")
395 print(f" GEO circular velocity: {v_geo:.3f} km/s")
396
397 # Hohmann transfer orbit
398 a_transfer = (r_leo + r_geo) / 2
399
400 # Velocity at periapsis of transfer orbit (leaving LEO)
401 v_transfer_peri = vis_viva(r_leo, a_transfer, GM_EARTH)
402
403 # Velocity at apoapsis of transfer orbit (arriving at GEO)
404 v_transfer_apo = vis_viva(r_geo, a_transfer, GM_EARTH)
405
406 # Delta-v's
407 dv1 = v_transfer_peri - v_leo # Burn at LEO
408 dv2 = v_geo - v_transfer_apo # Burn at GEO
409
410 # Transfer time (half the period)
411 T_transfer = orbital_period(a_transfer, GM_EARTH)
412 tof = T_transfer / 2
413
414 print("\nTransfer orbit:")
415 print(f" Semi-major axis: {a_transfer:.0f} km")
416 print(f" Transfer time: {tof / 3600:.2f} hours")
417
418 print("\nDelta-v budget:")
419 print(f" dv1 (LEO departure): {dv1:.3f} km/s")
420 print(f" dv2 (GEO insertion): {dv2:.3f} km/s")
421 print(f" Total dv: {dv1 + dv2:.3f} km/s")
422
423
424def demo_time_systems():
425 """Demonstrate time system conversions."""
426 print("\n" + "=" * 70)
427 print("Time Systems Demo")
428 print("=" * 70)
429
430 # Current epoch (approximate)
431 year, month, day = 2025, 1, 1
432 hour, minute, second = 12, 0, 0.0
433
434 # Convert to Julian Date
435 jd = cal_to_jd(year, month, day, hour, minute, second)
436
437 print(
438 f"\nDate: {year}-{month:02d}-{day:02d} {hour:02d}:{minute:02d}:{second:05.2f} UTC"
439 )
440 print(f"Julian Date: {jd:.6f}")
441
442 # Convert back
443 y, mo, d, h, mi, s = jd_to_cal(jd)
444 print(
445 f"Roundtrip: {int(y)}-{int(mo):02d}-{int(d):02d} "
446 f"{int(h):02d}:{int(mi):02d}:{s:05.2f}"
447 )
448
449 # Time scales - use calendar date directly
450 tai = utc_to_tai(year, month, day, hour, minute, int(second))
451 gps = utc_to_gps(year, month, day, hour, minute, int(second))
452
453 print(f"\nTime scales (as JD):")
454 print(f" UTC: {jd:.6f}")
455 print(f" TAI: {tai:.6f} (UTC + leap seconds)")
456 print(f" GPS: {gps:.6f} (TAI - 19s)")
457
458 # Sidereal time
459 gst = gmst(jd)
460 print(
461 f"\nGreenwich Mean Sidereal Time: {np.degrees(gst):.4f} deg = "
462 f"{np.degrees(gst) / 15:.4f} hours"
463 )
464
465
466def demo_reference_frames():
467 """Demonstrate reference frame transformations."""
468 print("\n" + "=" * 70)
469 print("Reference Frame Transformations Demo")
470 print("=" * 70)
471
472 # J2000 epoch - gcrf_to_itrf needs jd_ut1 and jd_tt
473 jd_ut1 = 2451545.0 # J2000.0
474 jd_tt = jd_ut1 + 64.184 / 86400 # TT is ~64s ahead of UT1 at J2000
475
476 # Position in GCRF (inertial)
477 r_gcrf = np.array([6778.0, 0.0, 0.0]) # km, along x-axis
478
479 print(f"\nPosition in GCRF (inertial): {r_gcrf} km")
480
481 # Transform to ITRF (Earth-fixed)
482 r_itrf = gcrf_to_itrf(r_gcrf, jd_ut1, jd_tt)
483 print(
484 f"Position in ITRF (Earth-fixed): ({r_itrf[0]:.3f}, "
485 f"{r_itrf[1]:.3f}, {r_itrf[2]:.3f}) km"
486 )
487
488 # Transform back
489 r_gcrf_back = itrf_to_gcrf(r_itrf, jd_ut1, jd_tt)
490 print(
491 f"Back to GCRF: ({r_gcrf_back[0]:.3f}, {r_gcrf_back[1]:.3f}, "
492 f"{r_gcrf_back[2]:.3f}) km"
493 )
494
495 # Show precession effect
496 print("\n--- Precession Effect ---")
497 jd_now = 2460676.5 # ~2025
498 centuries = (jd_now - 2451545.0) / 36525
499
500 P = precession_matrix_iau76(jd_now)
501
502 # Apply to vernal equinox direction
503 equinox_j2000 = np.array([1.0, 0.0, 0.0])
504 equinox_now = P @ equinox_j2000
505
506 angle = np.degrees(np.arccos(np.dot(equinox_j2000, equinox_now)))
507 print(f"Precession since J2000.0: {angle:.4f} deg")
508 print(f" ({centuries:.2f} Julian centuries)")
509
510
511def demo_orbit_determination():
512 """Demonstrate using orbital mechanics for orbit determination."""
513 print("\n" + "=" * 70)
514 print("Orbit Determination Application Demo")
515 print("=" * 70)
516
517 np.random.seed(42)
518
519 # Simulated radar observations of a satellite
520 # Two observations at known times
521 jd1 = 2460676.5 # First observation
522 jd2 = jd1 + 0.01 # Second observation (~14 minutes later)
523
524 # True orbit
525 a_true = 7000.0
526 e_true = 0.001
527 elements_true = OrbitalElements(
528 a=a_true,
529 e=e_true,
530 i=np.radians(45),
531 raan=np.radians(30),
532 omega=np.radians(0),
533 nu=np.radians(0),
534 )
535
536 state1_true = orbital_elements_to_state(elements_true, GM_EARTH)
537
538 # Propagate to second observation
539 dt = (jd2 - jd1) * 86400 # seconds
540 state2_true = kepler_propagate_state(state1_true, dt, GM_EARTH)
541
542 # Add measurement noise
543 noise_pos = 0.05 # km
544 r1 = state1_true.r + np.random.randn(3) * noise_pos
545 r2 = state2_true.r + np.random.randn(3) * noise_pos
546
547 print(f"\nTwo position observations separated by {dt:.0f} seconds:")
548 print(f" r1 = ({r1[0]:.3f}, {r1[1]:.3f}, {r1[2]:.3f}) km")
549 print(f" r2 = ({r2[0]:.3f}, {r2[1]:.3f}, {r2[2]:.3f}) km")
550
551 # Solve Lambert's problem to determine orbit
552 solution = lambert_universal(r1, r2, dt, GM_EARTH)
553
554 print("\nLambert solution (initial orbit determination):")
555 print(
556 f" v1 = ({solution.v1[0]:.4f}, {solution.v1[1]:.4f}, "
557 f"{solution.v1[2]:.4f}) km/s"
558 )
559 print(f" Semi-major axis: {solution.a:.1f} km (true: {a_true:.1f} km)")
560 print(f" Eccentricity: {solution.e:.4f} (true: {e_true:.4f})")
561
562 # Compare with true velocity
563 v1_error = np.linalg.norm(solution.v1 - state1_true.v)
564 print(f"\n Velocity error: {v1_error * 1000:.1f} m/s")
565
566
567def main():
568 """Run all demonstrations."""
569 print("\n" + "#" * 70)
570 print("# PyTCL Orbital Mechanics Example")
571 print("#" * 70)
572
573 demo_orbital_elements()
574 demo_kepler_equation()
575 demo_orbit_propagation()
576 demo_lambert_problem()
577 demo_hohmann_transfer()
578 demo_time_systems()
579 demo_reference_frames()
580 demo_orbit_determination()
581
582 print("\n" + "=" * 70)
583 print("Example complete!")
584 if SHOW_PLOTS:
585 print("Plots saved: orbital_propagation.html")
586 print("=" * 70)
587
588
589if __name__ == "__main__":
590 main()
Running the Example
python examples/orbital_mechanics.py
See Also
High-Precision Ephemeris - Planetary ephemeris
Relativistic Effects - Relativistic corrections