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

Running the Example

python examples/reference_frame_advanced.py

See Also