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
Coordinate Systems - Basic coordinate transformations
Coordinate Visualization - 3D frame visualizations
High-Precision Ephemeris - Planetary positions