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