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
SGP4/SDP4: TLE-based propagation
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
Perturbations: J2, drag, solar radiation pressure
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
- SGP4/SDP4
NORAD propagator for TLEs
Includes perturbations
Standard for satellite tracking
Code Highlights
The example demonstrates:
State vector to orbital elements conversion
Kepler equation solving with
solve_kepler()Two-body propagation with
propagate_twobody()Lambert solver with
solve_lambert()TLE parsing and SGP4 propagation
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"), include_plotlyjs="cdn"
319 )
320 print("\n [Plot saved to orbital_propagation.html]")
321
322
323def demo_lambert_problem():
324 """Demonstrate Lambert's problem for orbit determination."""
325 print("\n" + "=" * 70)
326 print("Lambert's Problem Demo")
327 print("=" * 70)
328
329 # Earth to Mars transfer (simplified)
330 # Initial position: Earth at 1 AU
331 r1 = np.array([1.0, 0.0, 0.0]) * 149597870.7 # km (1 AU)
332
333 # Final position: Mars at 1.52 AU (simplified circular orbit)
334 theta_mars = np.radians(135) # 135 deg ahead
335 r2 = np.array([np.cos(theta_mars), np.sin(theta_mars), 0.0]) * 1.52 * 149597870.7
336
337 # Transfer time: approximately 259 days (Hohmann-like)
338 tof = 259 * 86400 # seconds
339
340 print("\nEarth-Mars transfer scenario:")
341 print(
342 f" Departure: Earth at ({r1[0]/149597870.7:.2f}, "
343 f"{r1[1]/149597870.7:.2f}, 0) AU"
344 )
345 print(
346 f" Arrival: Mars at ({r2[0]/149597870.7:.2f}, "
347 f"{r2[1]/149597870.7:.2f}, 0) AU"
348 )
349 print(f" Time of flight: {tof/86400:.0f} days")
350
351 # Solve Lambert's problem
352 solution = lambert_universal(r1, r2, tof, GM_SUN)
353
354 print("\nLambert solution:")
355 print(
356 f" Departure velocity: ({solution.v1[0]:.3f}, {solution.v1[1]:.3f}, "
357 f"{solution.v1[2]:.3f}) km/s"
358 )
359 print(
360 f" Arrival velocity: ({solution.v2[0]:.3f}, {solution.v2[1]:.3f}, "
361 f"{solution.v2[2]:.3f}) km/s"
362 )
363 print(f" Transfer orbit semi-major axis: {solution.a/149597870.7:.3f} AU")
364 print(f" Transfer orbit eccentricity: {solution.e:.4f}")
365
366 # Delta-v calculations (simplified)
367 # Earth's orbital velocity
368 v_earth = np.array([0, 29.78, 0]) # km/s (approximately)
369 dv_departure = np.linalg.norm(solution.v1 - v_earth)
370
371 print(f"\n Departure delta-v: {dv_departure:.2f} km/s")
372
373
374def demo_hohmann_transfer():
375 """Demonstrate Hohmann transfer orbit."""
376 print("\n" + "=" * 70)
377 print("Hohmann Transfer Demo")
378 print("=" * 70)
379
380 # LEO to GEO transfer
381 r_leo = 6678.0 # km (300 km altitude)
382 r_geo = 42164.0 # km (GEO radius)
383
384 print("\nLEO to GEO Hohmann transfer:")
385 print(f" Initial orbit (LEO): r = {r_leo:.0f} km (alt = {r_leo-6378:.0f} km)")
386 print(f" Final orbit (GEO): r = {r_geo:.0f} km (alt = {r_geo-6378:.0f} km)")
387
388 # Velocities in circular orbits
389 v_leo = circular_velocity(r_leo, GM_EARTH)
390 v_geo = circular_velocity(r_geo, GM_EARTH)
391
392 print(f"\n LEO circular velocity: {v_leo:.3f} km/s")
393 print(f" GEO circular velocity: {v_geo:.3f} km/s")
394
395 # Hohmann transfer orbit
396 a_transfer = (r_leo + r_geo) / 2
397
398 # Velocity at periapsis of transfer orbit (leaving LEO)
399 v_transfer_peri = vis_viva(r_leo, a_transfer, GM_EARTH)
400
401 # Velocity at apoapsis of transfer orbit (arriving at GEO)
402 v_transfer_apo = vis_viva(r_geo, a_transfer, GM_EARTH)
403
404 # Delta-v's
405 dv1 = v_transfer_peri - v_leo # Burn at LEO
406 dv2 = v_geo - v_transfer_apo # Burn at GEO
407
408 # Transfer time (half the period)
409 T_transfer = orbital_period(a_transfer, GM_EARTH)
410 tof = T_transfer / 2
411
412 print("\nTransfer orbit:")
413 print(f" Semi-major axis: {a_transfer:.0f} km")
414 print(f" Transfer time: {tof/3600:.2f} hours")
415
416 print("\nDelta-v budget:")
417 print(f" dv1 (LEO departure): {dv1:.3f} km/s")
418 print(f" dv2 (GEO insertion): {dv2:.3f} km/s")
419 print(f" Total dv: {dv1 + dv2:.3f} km/s")
420
421
422def demo_time_systems():
423 """Demonstrate time system conversions."""
424 print("\n" + "=" * 70)
425 print("Time Systems Demo")
426 print("=" * 70)
427
428 # Current epoch (approximate)
429 year, month, day = 2025, 1, 1
430 hour, minute, second = 12, 0, 0.0
431
432 # Convert to Julian Date
433 jd = cal_to_jd(year, month, day, hour, minute, second)
434
435 print(
436 f"\nDate: {year}-{month:02d}-{day:02d} {hour:02d}:{minute:02d}:{second:05.2f} UTC"
437 )
438 print(f"Julian Date: {jd:.6f}")
439
440 # Convert back
441 y, mo, d, h, mi, s = jd_to_cal(jd)
442 print(
443 f"Roundtrip: {int(y)}-{int(mo):02d}-{int(d):02d} "
444 f"{int(h):02d}:{int(mi):02d}:{s:05.2f}"
445 )
446
447 # Time scales - use calendar date directly
448 tai = utc_to_tai(year, month, day, hour, minute, int(second))
449 gps = utc_to_gps(year, month, day, hour, minute, int(second))
450
451 print(f"\nTime scales (as JD):")
452 print(f" UTC: {jd:.6f}")
453 print(f" TAI: {tai:.6f} (UTC + leap seconds)")
454 print(f" GPS: {gps:.6f} (TAI - 19s)")
455
456 # Sidereal time
457 gst = gmst(jd)
458 print(
459 f"\nGreenwich Mean Sidereal Time: {np.degrees(gst):.4f} deg = "
460 f"{np.degrees(gst)/15:.4f} hours"
461 )
462
463
464def demo_reference_frames():
465 """Demonstrate reference frame transformations."""
466 print("\n" + "=" * 70)
467 print("Reference Frame Transformations Demo")
468 print("=" * 70)
469
470 # J2000 epoch - gcrf_to_itrf needs jd_ut1 and jd_tt
471 jd_ut1 = 2451545.0 # J2000.0
472 jd_tt = jd_ut1 + 64.184 / 86400 # TT is ~64s ahead of UT1 at J2000
473
474 # Position in GCRF (inertial)
475 r_gcrf = np.array([6778.0, 0.0, 0.0]) # km, along x-axis
476
477 print(f"\nPosition in GCRF (inertial): {r_gcrf} km")
478
479 # Transform to ITRF (Earth-fixed)
480 r_itrf = gcrf_to_itrf(r_gcrf, jd_ut1, jd_tt)
481 print(
482 f"Position in ITRF (Earth-fixed): ({r_itrf[0]:.3f}, "
483 f"{r_itrf[1]:.3f}, {r_itrf[2]:.3f}) km"
484 )
485
486 # Transform back
487 r_gcrf_back = itrf_to_gcrf(r_itrf, jd_ut1, jd_tt)
488 print(
489 f"Back to GCRF: ({r_gcrf_back[0]:.3f}, {r_gcrf_back[1]:.3f}, "
490 f"{r_gcrf_back[2]:.3f}) km"
491 )
492
493 # Show precession effect
494 print("\n--- Precession Effect ---")
495 jd_now = 2460676.5 # ~2025
496 centuries = (jd_now - 2451545.0) / 36525
497
498 P = precession_matrix_iau76(jd_now)
499
500 # Apply to vernal equinox direction
501 equinox_j2000 = np.array([1.0, 0.0, 0.0])
502 equinox_now = P @ equinox_j2000
503
504 angle = np.degrees(np.arccos(np.dot(equinox_j2000, equinox_now)))
505 print(f"Precession since J2000.0: {angle:.4f} deg")
506 print(f" ({centuries:.2f} Julian centuries)")
507
508
509def demo_orbit_determination():
510 """Demonstrate using orbital mechanics for orbit determination."""
511 print("\n" + "=" * 70)
512 print("Orbit Determination Application Demo")
513 print("=" * 70)
514
515 np.random.seed(42)
516
517 # Simulated radar observations of a satellite
518 # Two observations at known times
519 jd1 = 2460676.5 # First observation
520 jd2 = jd1 + 0.01 # Second observation (~14 minutes later)
521
522 # True orbit
523 a_true = 7000.0
524 e_true = 0.001
525 elements_true = OrbitalElements(
526 a=a_true,
527 e=e_true,
528 i=np.radians(45),
529 raan=np.radians(30),
530 omega=np.radians(0),
531 nu=np.radians(0),
532 )
533
534 state1_true = orbital_elements_to_state(elements_true, GM_EARTH)
535
536 # Propagate to second observation
537 dt = (jd2 - jd1) * 86400 # seconds
538 state2_true = kepler_propagate_state(state1_true, dt, GM_EARTH)
539
540 # Add measurement noise
541 noise_pos = 0.05 # km
542 r1 = state1_true.r + np.random.randn(3) * noise_pos
543 r2 = state2_true.r + np.random.randn(3) * noise_pos
544
545 print(f"\nTwo position observations separated by {dt:.0f} seconds:")
546 print(f" r1 = ({r1[0]:.3f}, {r1[1]:.3f}, {r1[2]:.3f}) km")
547 print(f" r2 = ({r2[0]:.3f}, {r2[1]:.3f}, {r2[2]:.3f}) km")
548
549 # Solve Lambert's problem to determine orbit
550 solution = lambert_universal(r1, r2, dt, GM_EARTH)
551
552 print("\nLambert solution (initial orbit determination):")
553 print(
554 f" v1 = ({solution.v1[0]:.4f}, {solution.v1[1]:.4f}, "
555 f"{solution.v1[2]:.4f}) km/s"
556 )
557 print(f" Semi-major axis: {solution.a:.1f} km (true: {a_true:.1f} km)")
558 print(f" Eccentricity: {solution.e:.4f} (true: {e_true:.4f})")
559
560 # Compare with true velocity
561 v1_error = np.linalg.norm(solution.v1 - state1_true.v)
562 print(f"\n Velocity error: {v1_error*1000:.1f} m/s")
563
564
565def main():
566 """Run all demonstrations."""
567 print("\n" + "#" * 70)
568 print("# PyTCL Orbital Mechanics Example")
569 print("#" * 70)
570
571 demo_orbital_elements()
572 demo_kepler_equation()
573 demo_orbit_propagation()
574 demo_lambert_problem()
575 demo_hohmann_transfer()
576 demo_time_systems()
577 demo_reference_frames()
578 demo_orbit_determination()
579
580 print("\n" + "=" * 70)
581 print("Example complete!")
582 if SHOW_PLOTS:
583 print("Plots saved: orbital_propagation.html")
584 print("=" * 70)
585
586
587if __name__ == "__main__":
588 main()
Running the Example
python examples/orbital_mechanics.py
See Also
High-Precision Ephemeris - Planetary ephemeris
Relativistic Effects - Relativistic corrections