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