Advanced Reference Frames

This example demonstrates advanced reference frame transformations including PEF (Pseudo-Earth Fixed) and SEZ (South-East-Zenith) frames for satellite tracking and Earth observation.

Overview

Reference frame transformations are essential for:

  • Satellite tracking: Ground station to satellite geometry

  • Radar observations: Azimuth and elevation calculations

  • Navigation: Inertial to Earth-fixed conversions

  • Astrometry: Precise position measurements

Transformation Chain

The complete transformation from inertial to Earth-fixed coordinates:

GCRF (inertial)
    |
    v (precession)
MOD (Mean of Date)
    |
    v (nutation)
TOD (True of Date)
    |
    v (Earth rotation)
PEF (Pseudo-Earth Fixed)
    |
    v (polar motion)
ITRF (International Terrestrial Reference Frame)

GCRF: Geocentric Celestial Reference Frame (inertial)

PEF: Excludes polar motion, useful for intermediate calculations

ITRF: Standard Earth-fixed frame for geodetic coordinates

Rotation Axes: Each step in the transformation chain involves rotations about specific axes.

SEZ Frame

The South-East-Zenith frame is horizon-relative:

South (S): Points toward geographic south East (E): Points toward geographic east Zenith (Z): Points away from Earth center (up)

Applications:

  • Radar and antenna azimuth/elevation

  • Line-of-sight observations

  • Ground station to satellite geometry

  • Horizon crossing calculations

Spherical Coordinates: The SEZ frame uses spherical coordinates (range, azimuth, elevation) for tracking applications.

Examples Demonstrated

PEF Intermediate Frame
  • GCRF to PEF transformation

  • Polar motion effects (PEF vs ITRF)

  • Roundtrip verification

SEZ Radar Observations
  • Ground station coordinates

  • Satellite position in SEZ

  • Range, azimuth, elevation computation

  • Visibility determination

LEO Satellite Tracking
  • Satellite pass over ground station

  • Time evolution of azimuth/elevation

  • Polar plot of satellite track

  • Maximum elevation and range

Earth Observation Geometry
  • Multiple ground stations

  • Satellite visibility analysis

  • Observation feasibility

Satellite Tracking: LEO satellite passes show time-varying azimuth and elevation as seen from a ground station.

Code Highlights

The example demonstrates:

  • GCRF to ITRF with gcrf_to_itrf()

  • GCRF to PEF with gcrf_to_pef()

  • Geodetic to SEZ with geodetic2sez()

  • Julian date computation with cal_to_jd()

  • Polar motion corrections

Source Code

  1"""
  2Advanced Reference Frame Transformations
  3
  4This example demonstrates advanced reference frame transformations including:
  5- PEF (Pseudo-Earth Fixed) frame for intermediate processing
  6- SEZ (South-East-Zenith) horizon-relative frame for observations
  7- Earth observation and antenna pointing applications
  8
  9The reference frame transformation chain:
 10  GCRF (inertial) -> MOD (precession) -> TOD (nutation) -> PEF (rotation)
 11                                                              |
 12                                                              v
 13                                                           ITRF (Earth-fixed)
 14                                                              ^
 15                                                              |
 16                                                        (+ polar motion)
 17
 18SEZ is useful for:
 19- Radar and antenna azimuth/elevation calculations
 20- Line-of-sight observations
 21- Sensor target tracking from ground stations
 22"""
 23
 24import os
 25
 26import numpy as np
 27import plotly.graph_objects as go
 28import plotly.subplots as sp
 29
 30from pytcl.astronomical.reference_frames import (
 31    gcrf_to_itrf,
 32    gcrf_to_pef,
 33    itrf_to_gcrf,
 34    pef_to_gcrf,
 35)
 36from pytcl.astronomical.time_systems import JD_J2000, cal_to_jd
 37from pytcl.coordinate_systems.conversions.geodetic import (
 38    ecef2geodetic,
 39    geodetic2ecef,
 40    geodetic2sez,
 41    sez2geodetic,
 42)
 43
 44
 45def example_pef_intermediate_frame():
 46    """
 47    Demonstrate PEF as an intermediate frame between GCRF and ITRF.
 48
 49    PEF excludes polar motion compared to ITRF, making it useful for:
 50    - Intermediate calculations
 51    - Separating Earth rotation from polar motion effects
 52    - Legacy systems and comparisons
 53    """
 54    print("=" * 80)
 55    print("Example 1: PEF Intermediate Frame")
 56    print("=" * 80)
 57
 58    # Sample position in GCRF (e.g., geostationary satellite)
 59    r_gcrf = np.array([42164.0, 0.0, 0.0])  # GEO orbit at Earth equator, km
 60
 61    # Date: 2024-01-01 12:00:00 UTC
 62    year, month, day, hour, minute, second = 2024, 1, 1, 12, 0, 0
 63    jd_utc = cal_to_jd(year, month, day, hour, minute, second)
 64    jd_ut1 = jd_utc  # Simplified (should account for UT1 - UTC offset)
 65    jd_tt = jd_ut1 + 32.184 / 86400  # TT = UT1 + 32.184 seconds
 66
 67    # Polar motion parameters (for example)
 68    xp = 0.0001  # ~0.01 arcseconds
 69    yp = -0.0001
 70
 71    # Transform GCRF -> PEF (no polar motion)
 72    r_pef = gcrf_to_pef(r_gcrf, jd_ut1, jd_tt)
 73
 74    # Transform GCRF -> ITRF (includes polar motion)
 75    r_itrf = gcrf_to_itrf(r_gcrf, jd_ut1, jd_tt, xp, yp)
 76
 77    print(f"Position in GCRF:  {r_gcrf}")
 78    print(f"Position in PEF:   {r_pef}")
 79    print(f"Position in ITRF:  {r_itrf}")
 80
 81    # Polar motion effect
 82    polar_motion_effect = np.linalg.norm(r_itrf - r_pef)
 83    print(
 84        f"\nPolar motion effect (PEF vs ITRF difference): {polar_motion_effect:.4f} km"
 85    )
 86    print(f"Relative effect: {100 * polar_motion_effect / np.linalg.norm(r_gcrf):.6f}%")
 87
 88    # Verify roundtrip
 89    r_gcrf_back = pef_to_gcrf(r_pef, jd_ut1, jd_tt)
 90    roundtrip_error = np.linalg.norm(r_gcrf_back - r_gcrf)
 91    print(f"\nRoundtrip error (GCRF -> PEF -> GCRF): {roundtrip_error:.2e} km")
 92
 93
 94def example_sez_radar_observations():
 95    """
 96    Demonstrate SEZ frame for radar observations and antenna targeting.
 97
 98    Application: Ground-based radar observing a satellite
 99    """
100    print("\n" + "=" * 80)
101    print("Example 2: SEZ Frame for Radar Observations")
102    print("=" * 80)
103
104    # Ground station (Longitude, Latitude, Altitude)
105    station_name = "Tracking Station"
106    lat_station = np.radians(40.0)  # 40° N
107    lon_station = np.radians(-105.0)  # 105° W (Colorado)
108    alt_station = 1655.0  # meters (Denver area)
109
110    print(f"\n{station_name}:")
111    print(f"  Latitude:  {np.degrees(lat_station):.2f}°")
112    print(f"  Longitude: {np.degrees(lon_station):.2f}°")
113    print(f"  Altitude:  {alt_station:.0f} m")
114
115    # Satellite position (example: LEO satellite)
116    # Position in geodetic coordinates
117    lat_satellite = np.radians(42.5)
118    lon_satellite = np.radians(-103.5)
119    alt_satellite = 400_000  # 400 km altitude
120
121    # Convert satellite position to SEZ relative to station
122    sez_position = geodetic2sez(
123        lat_satellite,
124        lon_satellite,
125        alt_satellite,
126        lat_station,
127        lon_station,
128        alt_station,
129    )
130
131    print(f"\nSatellite position in SEZ:")
132    print(f"  South component:  {sez_position[0]/1000:8.2f} km")
133    print(f"  East component:   {sez_position[1]/1000:8.2f} km")
134    print(f"  Zenith component: {sez_position[2]/1000:8.2f} km")
135
136    # Compute range, azimuth, elevation
137    range_km = np.linalg.norm(sez_position) / 1000
138
139    # Azimuth: 0° = South, 90° = East, 180° = North, 270° = West
140    azimuth = np.degrees(np.arctan2(sez_position[1], sez_position[0]))
141    if azimuth < 0:
142        azimuth += 360
143
144    # Elevation: angle above horizon
145    horizontal_distance = np.sqrt(sez_position[0] ** 2 + sez_position[1] ** 2)
146    elevation = np.degrees(np.arctan2(sez_position[2], horizontal_distance))
147
148    print(f"\nRadar observation parameters:")
149    print(f"  Range:     {range_km:8.2f} km")
150    print(f"  Azimuth:   {azimuth:8.2f}°")
151    print(f"  Elevation: {elevation:8.2f}°")
152
153    # Check if satellite is above horizon (elevation > 0)
154    is_visible = elevation > 0
155    print(
156        f"  Visible:   {'Yes' if is_visible else 'No'} (elevation {'above' if is_visible else 'below'} horizon)"
157    )
158
159
160def example_leo_satellite_tracking():
161    """
162    Demonstrate tracking a LEO satellite through multiple observation passes.
163
164    Shows how azimuth/elevation evolve as the satellite passes overhead.
165    """
166    print("\n" + "=" * 80)
167    print("Example 3: LEO Satellite Pass Over Tracking Station")
168    print("=" * 80)
169
170    # Tracking station (Denver)
171    lat_station = np.radians(39.74)  # Denver, CO
172    lon_station = np.radians(-104.99)
173    alt_station = 1609.0  # meters
174
175    # LEO satellite orbital parameters (example)
176    # ISS-like orbit: 51.6° inclination, ~400 km altitude
177    inclination = 51.6  # degrees
178    altitude = 408_000  # meters
179
180    # Simulate satellite positions along its ground track
181    # (This is simplified; real implementation would propagate orbit)
182    orbital_period_minutes = 90  # approximately
183
184    # Create a pass: satellite starting from horizon, reaching max elevation, to horizon
185    # Use a parametric representation along the pass
186
187    num_points = 50
188    t_pass = np.linspace(0, orbital_period_minutes, num_points)
189
190    # Simplified ground track: satellite moves from SW to NE
191    lat_pass = np.degrees(lat_station) + (np.linspace(-5, 5, num_points))
192    lon_pass = np.degrees(lon_station) + (np.linspace(-5, 5, num_points))
193
194    azimuth_pass = []
195    elevation_pass = []
196    range_pass = []
197
198    for lat_sat, lon_sat in zip(lat_pass, lon_pass):
199        lat_sat_rad = np.radians(lat_sat)
200        lon_sat_rad = np.radians(lon_sat)
201
202        sez = geodetic2sez(
203            lat_sat_rad, lon_sat_rad, altitude, lat_station, lon_station, alt_station
204        )
205
206        # Range
207        rng = np.linalg.norm(sez) / 1000
208        range_pass.append(rng)
209
210        # Azimuth
211        az = np.degrees(np.arctan2(sez[1], sez[0]))
212        if az < 0:
213            az += 360
214        azimuth_pass.append(az)
215
216        # Elevation
217        horiz = np.sqrt(sez[0] ** 2 + sez[1] ** 2)
218        el = np.degrees(np.arctan2(sez[2], horiz))
219        elevation_pass.append(el)
220
221    # Print pass summary
222    max_el_idx = np.argmax(elevation_pass)
223    max_elevation = elevation_pass[max_el_idx]
224    azimuth_at_max = azimuth_pass[max_el_idx]
225    min_range = range_pass[max_el_idx]
226
227    print(
228        f"\nSatellite pass over {np.degrees(lat_station):.2f}°, {np.degrees(lon_station):.2f}°:"
229    )
230    print(f"  Maximum elevation: {max_elevation:.2f}°")
231    print(f"  Azimuth at max el: {azimuth_at_max:.2f}°")
232    print(f"  Minimum range:     {min_range:.2f} km")
233
234    # Determine horizon crossings
235    horizon_points = [
236        (el, az, rng)
237        for el, az, rng in zip(elevation_pass, azimuth_pass, range_pass)
238        if abs(el) < 1
239    ]
240    if horizon_points:
241        print(f"  Horizon crossing points: {len(horizon_points)}")
242
243    # Plot the pass using Plotly
244    fig = sp.make_subplots(
245        rows=2,
246        cols=2,
247        subplot_titles=(
248            "Elevation Angle",
249            "Azimuth Angle",
250            "Slant Range",
251            "Ground Station View",
252        ),
253        specs=[
254            [{"type": "scatter"}, {"type": "scatter"}],
255            [{"type": "scatter"}, {"type": "scatterpolar"}],
256        ],
257    )
258
259    # Elevation vs time
260    fig.add_trace(
261        go.Scatter(
262            x=t_pass,
263            y=elevation_pass,
264            mode="lines",
265            name="Elevation",
266            line=dict(color="blue", width=2),
267        ),
268        row=1,
269        col=1,
270    )
271    fig.add_hline(y=0, line_dash="dash", line_color="gray", row=1, col=1)
272
273    # Azimuth vs time
274    fig.add_trace(
275        go.Scatter(
276            x=t_pass,
277            y=azimuth_pass,
278            mode="lines",
279            name="Azimuth",
280            line=dict(color="red", width=2),
281        ),
282        row=1,
283        col=2,
284    )
285
286    # Range vs time
287    fig.add_trace(
288        go.Scatter(
289            x=t_pass,
290            y=range_pass,
291            mode="lines",
292            name="Range",
293            line=dict(color="green", width=2),
294        ),
295        row=2,
296        col=1,
297    )
298
299    # Azimuth/Elevation polar plot
300    fig.add_trace(
301        go.Scatterpolar(
302            r=90 - np.array(elevation_pass),
303            theta=azimuth_pass,
304            mode="lines",
305            name="Satellite Pass",
306            line=dict(color="blue", width=2),
307            fill="toself",
308            fillcolor="rgba(0, 100, 200, 0.1)",
309        ),
310        row=2,
311        col=2,
312    )
313
314    # Mark start, peak, and end on polar plot
315    fig.add_trace(
316        go.Scatterpolar(
317            r=[90 - elevation_pass[0]],
318            theta=[azimuth_pass[0]],
319            mode="markers",
320            name="Start",
321            marker=dict(size=10, color="green"),
322            showlegend=False,
323        ),
324        row=2,
325        col=2,
326    )
327
328    fig.add_trace(
329        go.Scatterpolar(
330            r=[90 - elevation_pass[max_el_idx]],
331            theta=[azimuth_pass[max_el_idx]],
332            mode="markers",
333            name="Max Elevation",
334            marker=dict(size=15, color="red", symbol="star"),
335            showlegend=False,
336        ),
337        row=2,
338        col=2,
339    )
340
341    fig.add_trace(
342        go.Scatterpolar(
343            r=[90 - elevation_pass[-1]],
344            theta=[azimuth_pass[-1]],
345            mode="markers",
346            name="End",
347            marker=dict(size=10, color="red", symbol="x"),
348            showlegend=False,
349        ),
350        row=2,
351        col=2,
352    )
353
354    # Update axes labels
355    fig.update_xaxes(title_text="Time in Pass (min)", row=1, col=1)
356    fig.update_yaxes(title_text="Elevation (deg)", row=1, col=1)
357
358    fig.update_xaxes(title_text="Time in Pass (min)", row=1, col=2)
359    fig.update_yaxes(title_text="Azimuth (deg)", row=1, col=2)
360
361    fig.update_xaxes(title_text="Time in Pass (min)", row=2, col=1)
362    fig.update_yaxes(title_text="Range (km)", row=2, col=1)
363
364    # Update polar plot
365    fig.update_polars(
366        radialaxis=dict(range=[0, 90], ticksuffix="°"),
367        angularaxis=dict(tickprefix="", ticksuffix="°"),
368        row=2,
369        col=2,
370    )
371
372    fig.update_layout(
373        title_text="LEO Satellite Pass Tracking",
374        height=800,
375        width=1200,
376        hovermode="closest",
377    )
378
379    # Save output to examples/output directory instead of root
380    output_dir = os.path.join(os.path.dirname(__file__), "output")
381    os.makedirs(output_dir, exist_ok=True)
382    output_path = os.path.join(output_dir, "leo_satellite_pass.html")
383    fig.write_html(output_path)
384    print(f"\nPlot saved to '{output_path}'")
385
386    return fig
387
388
389def example_earth_observation():
390    """
391    Demonstrate Earth observation planning using SEZ frame.
392
393    Application: Planning satellite imagery collection from different ground stations
394    """
395    print("\n" + "=" * 80)
396    print("Example 4: Earth Observation Geometry")
397    print("=" * 80)
398
399    # Ground stations (different locations)
400    stations = [
401        ("Hawaii", np.radians(20.8), np.radians(-156.5), 3000),
402        ("Colorado", np.radians(40.0), np.radians(-105.0), 1500),
403        ("Florida", np.radians(28.5), np.radians(-80.5), 0),
404    ]
405
406    # Target on Earth surface (e.g., geographic point of interest)
407    target_lat = np.radians(35.0)  # 35° N
408    target_lon = np.radians(-95.0)  # 95° W
409    target_alt = 300.0  # meters (ground elevation)
410
411    # Observer in space (satellite)
412    observer_lat = np.radians(35.5)
413    observer_lon = np.radians(-94.5)
414    observer_alt = 800_000  # 800 km altitude
415
416    print(f"\nObserver satellite:")
417    print(
418        f"  Position: {np.degrees(observer_lat):.2f}°N, {np.degrees(observer_lon):.2f}°W"
419    )
420    print(f"  Altitude: {observer_alt/1000:.0f} km")
421
422    print(
423        f"\nTarget location: {np.degrees(target_lat):.2f}°N, {np.degrees(target_lon):.2f}°W"
424    )
425
426    print(f"\nObservation feasibility from different ground stations:")
427    print(f"{'Station':<12} {'Lat':<8} {'Lon':<8} {'El to Sat':<12} {'Visible?':<10}")
428    print("-" * 52)
429
430    for station_name, station_lat, station_lon, station_alt in stations:
431        # Find elevation angle from station to satellite
432        sez_to_sat = geodetic2sez(
433            observer_lat,
434            observer_lon,
435            observer_alt,
436            station_lat,
437            station_lon,
438            station_alt,
439        )
440
441        horiz = np.sqrt(sez_to_sat[0] ** 2 + sez_to_sat[1] ** 2)
442        elevation_to_sat = np.degrees(np.arctan2(sez_to_sat[2], horiz))
443
444        is_visible = elevation_to_sat > 0
445
446        print(
447            f"{station_name:<12} {np.degrees(station_lat):>7.2f}° {np.degrees(station_lon):>7.2f}° "
448            + f"{elevation_to_sat:>11.2f}° {('Yes' if is_visible else 'No'):<10}"
449        )
450
451    print("\nConclusion: Ground stations can track satellite if elevation > 0°")
452
453
454def main():
455    """Run all examples."""
456    print("\n")
457    print("█" * 80)
458    print("█ Advanced Reference Frame Transformations - pytcl Examples")
459    print("█" * 80)
460
461    example_pef_intermediate_frame()
462    example_sez_radar_observations()
463    example_leo_satellite_tracking()
464    example_earth_observation()
465
466    print("\n" + "=" * 80)
467    print("All examples completed successfully!")
468    print("=" * 80 + "\n")
469
470
471if __name__ == "__main__":
472    main()

Running the Example

python examples/reference_frame_advanced.py

See Also

  • coordinate_systems - Basic coordinate transformations

  • coordinate_visualization - 3D frame visualizations

  • High-Precision Ephemeris - Planetary positions