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() and lambert_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