astrotrack 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
astrotrack/__init__.py ADDED
@@ -0,0 +1,22 @@
1
+ from .doppler_analysis import ( # noqa: F401
2
+ check_doppler_resolution,
3
+ plot_doppler_shifts,
4
+ )
5
+ from .preprocess import load_satellite_data # noqa: F401
6
+ from .psd_analysis import ( # noqa: F401
7
+ load_hdf5,
8
+ plot_psd_satellite_time_series,
9
+ plot_psd_with_satellite_metric,
10
+ )
11
+ from .satcon_animate import animate_trajectories # noqa: F401
12
+ from .satcon_properties import ( # noqa: F401
13
+ filter_by_norads,
14
+ filter_by_time,
15
+ filter_custom,
16
+ filter_nth,
17
+ get_norads,
18
+ plot_flyover_histogram_by_norad,
19
+ plot_max_elevation_histogram,
20
+ plot_satellite_metric,
21
+ plot_satellite_trajectory,
22
+ )
@@ -0,0 +1,111 @@
1
+ import matplotlib.pyplot as plt
2
+ import numpy as np
3
+ import pandas as pd
4
+ from tabulate import tabulate
5
+
6
+ default_font = "Times New Roman"
7
+
8
+
9
+ def plot_doppler_shifts(
10
+ all_satellite_data,
11
+ f0,
12
+ font_family=default_font,
13
+ figsize=(8, 5),
14
+ marker_color="deeppink",
15
+ time_window=10,
16
+ ):
17
+ """
18
+ Plot the Doppler shifts for each satellite in separate subplots,
19
+ centered around visibility midpoint.
20
+
21
+ Parameters:
22
+ ----------
23
+ all_satellite_data : list[dict]
24
+ Satellite data dictionaries.
25
+ f0 : float
26
+ Transmission frequency [Hz].
27
+ font_family : str
28
+ Font family for plot text.
29
+ figsize : tuple
30
+ Figure size for each subplot.
31
+ marker_color : str
32
+ Color of scatter plot markers.
33
+ time_window : float
34
+ Half-width of x-axis around visibility [min].
35
+ """
36
+ c = 3.0e5 # speed of light in km/s
37
+ for satellite_data in all_satellite_data:
38
+ plt.rcParams["font.family"] = font_family
39
+ plt.figure(figsize=figsize)
40
+
41
+ epochs = satellite_data["Epochs"]
42
+ velocity_r = np.array(satellite_data["Velocities"]) # km/s
43
+ elevations = satellite_data["Elevations"]
44
+ tle_line_2 = satellite_data["TLE"][1]
45
+ norad_id = tle_line_2.split()[1]
46
+ theta_max = max(elevations)
47
+ epochs_np = np.array([np.datetime64(e) for e in epochs])
48
+ center_time = epochs_np[len(epochs_np) // 2]
49
+ relative_time = (epochs_np - center_time) / np.timedelta64(1, "m") # mins
50
+ doppler_shift = (velocity_r / c) * f0
51
+
52
+ plt.scatter(
53
+ relative_time,
54
+ doppler_shift / 1e3,
55
+ s=3,
56
+ color=marker_color,
57
+ label=rf"$\theta_{{max}} \approx {theta_max:.0f}^\circ$",
58
+ )
59
+
60
+ print_time = (
61
+ center_time.astype("datetime64[s]").tolist().strftime("%Y-%m-%d %H:%M:%S")
62
+ )
63
+ plt.xlabel("Visibility Time (min)")
64
+ plt.ylabel("Doppler Shift (kHz)")
65
+ plt.title(f"NORAD {norad_id} Doppler Shift (Centered at {print_time}):")
66
+ plt.axhline(0, color="black", linestyle="--")
67
+ plt.xlim(-time_window, time_window)
68
+ plt.legend(loc="upper right", fontsize=10)
69
+ plt.grid(True)
70
+ plt.show()
71
+
72
+
73
+ def check_doppler_resolution(
74
+ all_satellite_data, f0_array, resolution=12_000, experiment="REACH", return_df=False
75
+ ):
76
+ """
77
+ Check if Doppler shift can be resolved,
78
+ given experiment's frequency resolution.
79
+
80
+ Parameters:
81
+ ----------
82
+ all_satellite_data : list[dict]
83
+ Satellite data dictionaries.
84
+ f0_array : array
85
+ Frequencies to test [Hz].
86
+ resolution : float
87
+ Frequency resolution of experiment [Hz].
88
+ experiment_name : str
89
+ Name of experiment.
90
+ """
91
+ c = 3.0e5 # speed of light in km/s
92
+ rows = []
93
+
94
+ for satellite_data in all_satellite_data:
95
+ norad_id = satellite_data["TLE"][1].split()[1]
96
+ velocity_r = np.array(satellite_data["Velocities"])
97
+ f_max_needed = (resolution * c) / (np.max(np.abs(velocity_r)) * 1e6)
98
+
99
+ row = {"Satellite NORAD ID": norad_id}
100
+ for f0 in f0_array:
101
+ max_doppler_khz = np.max(np.abs((velocity_r / c) * f0)) / 1e3
102
+ row[f"{f0/1e6:.1f} MHz"] = f"""{max_doppler_khz:.2f} kHz | {
103
+ 'YES' if max_doppler_khz > resolution/1e3 else 'NO'
104
+ }"""
105
+ row[f"Freq. to Resolve ({experiment})"] = f"{f_max_needed:.0f} MHz"
106
+ rows.append(row)
107
+
108
+ df = pd.DataFrame(rows)
109
+ print(tabulate(df, headers="keys", tablefmt="grid", showindex=False))
110
+ if return_df:
111
+ return df
@@ -0,0 +1,319 @@
1
+ # SkyField
2
+ # Numerical
3
+ from __future__ import annotations
4
+
5
+ from datetime import timedelta
6
+
7
+ import numpy as np
8
+ import pandas as pd
9
+ from astropy import units as u
10
+
11
+ # Astropy
12
+ from astropy.coordinates import ITRS, AltAz, CartesianRepresentation, EarthLocation
13
+ from astropy.time import Time
14
+ from skyfield.api import EarthSatellite, load
15
+ from skyfield.timelib import Time
16
+ from tqdm import tqdm
17
+
18
+ ts = load.timescale()
19
+
20
+
21
+ def parse_tle_file(tle_file: str, satcon_name: str | None = None):
22
+ """
23
+ Parse a TLE file and return a list of (line1, line2) tuples,
24
+ supporting 3LEs, TLEs, mixed formats, or single TLE entries.
25
+
26
+ Parameters:
27
+ ----------
28
+ tle_file : str
29
+ Path to TLE text file
30
+ satcon_name : str
31
+ Optional constellation name to filter for
32
+
33
+ Output:
34
+ ------
35
+ list[tuple[str, str]]
36
+ Parsed TLE pairs ready for processing
37
+ """
38
+ with open(tle_file, "r") as f:
39
+ lines = [line.strip() for line in f if line.strip()]
40
+
41
+ tles = []
42
+ i = 0
43
+ while i < len(lines):
44
+ line = lines[i]
45
+ if line.startswith("0 "):
46
+ if i + 2 < len(lines):
47
+ name = line[2:].strip().upper()
48
+ line1 = lines[i + 1]
49
+ line2 = lines[i + 2]
50
+ if (satcon_name is None) or (satcon_name.upper() in name):
51
+ tles.append((line1, line2))
52
+ i += 3
53
+ else:
54
+ break
55
+ elif line.startswith("1 ") and i + 1 < len(lines):
56
+ line1 = lines[i]
57
+ line2 = lines[i + 1]
58
+ tles.append((line1, line2))
59
+ i += 2
60
+ else:
61
+ i += 1
62
+
63
+ if not tles and len(lines) == 2 and lines[0].startswith("1 "):
64
+ tles.append((lines[0], lines[1]))
65
+
66
+ return tles
67
+
68
+
69
+ def filter_tles_by_date(satcon_tles, target_date):
70
+ """
71
+ Filter TLEs within plus/mins 2 weeks of target_date.
72
+
73
+ Parameters:
74
+ ----------
75
+ satcon_tles : list[tuple])
76
+ List of (line1, line2) TLE strings
77
+ target_date : datetime)
78
+ Observation date
79
+
80
+ Output:
81
+ ------
82
+ list[tuple]
83
+ Filtered TLEs valid within p/m 2 weeks of target_date
84
+ """
85
+ start_date = target_date - timedelta(weeks=2)
86
+ end_date = target_date + timedelta(weeks=2)
87
+ filtered_tles = []
88
+ for tle in satcon_tles:
89
+ sat = EarthSatellite(tle[0], tle[1], ts=ts)
90
+ tle_epoch = sat.epoch.utc_datetime().replace(tzinfo=None)
91
+ if start_date <= tle_epoch <= end_date:
92
+ filtered_tles.append((tle[0], tle[1]))
93
+ return filtered_tles
94
+
95
+
96
+ def compute_sat_properties(sat, location, epoch):
97
+ """
98
+ Calculates properties of satellite trajectory
99
+ relative to observer at epoch.
100
+
101
+ Parameters:
102
+ ----------
103
+ sat : EarthSatellite
104
+ Skyfield satellite object
105
+ location : EarthLocation
106
+ Observers ITRS position
107
+ epoch : Time
108
+ Skyfield observation time
109
+
110
+ Outputs:
111
+ -------
112
+ tuple
113
+ azimuth [deg], elevation [deg], distance of
114
+ satellite w.r.t observer [km], line-of-sight velocity [km/s]
115
+ """
116
+ sat_at_epoch = sat.at(epoch)
117
+ sat_xyz = sat_at_epoch.position.km
118
+ astropy_time = Time(epoch.utc_iso())
119
+
120
+ observer_xyz = np.array([location.x.value, location.y.value, location.z.value])
121
+ displacement = np.array(sat_xyz) - observer_xyz
122
+ distance = np.linalg.norm(displacement)
123
+ unit_displacement = displacement / distance
124
+ relative_velocity = np.array(sat_at_epoch.velocity.km_per_s)
125
+ los_velocity = -np.dot(relative_velocity, unit_displacement)
126
+
127
+ altaz_frame = AltAz(obstime=astropy_time, location=location)
128
+ sat_itrs = ITRS(CartesianRepresentation(sat_xyz * u.km), obstime=astropy_time)
129
+ sat_altaz = sat_itrs.transform_to(altaz_frame)
130
+ azimuth = sat_altaz.az.value
131
+ elevation = sat_altaz.alt.value
132
+
133
+ return azimuth, elevation, distance, los_velocity
134
+
135
+
136
+ def load_horizon_profile(data):
137
+ """
138
+ Loads horizon profile at observer's location from .csv file or lists.
139
+
140
+ Parameters:
141
+ ----------
142
+ data : tuple
143
+ Path to .csv file (azimuth, elevation columns), or
144
+ tuple of two lists (azimuths, elevations)
145
+
146
+ Outputs:
147
+ -------
148
+ tuple[np.ndarray, np.ndarray]
149
+ Arrays of azimuthal [deg] and corresponding
150
+ elevation [deg] angles
151
+ """
152
+ if isinstance(data, tuple):
153
+ return np.array(data[0]), np.array(data[1])
154
+
155
+ csv = pd.read_csv(data)
156
+ azi_label = ["Azi", "Azimuth", "azi", "azimuth"]
157
+ elev_label = ["Prof", "Elevation", "prof", "elevation"]
158
+ azi_col = next((c for c in csv.columns if c in azi_label), None)
159
+ elev_col = next((c for c in csv.columns if c in elev_label), None)
160
+
161
+ if azi_col is None or elev_col is None:
162
+ raise ValueError(
163
+ f" Azimuth & Elevation columns in {data} not detected. "
164
+ "Ensure CSV file contains azimuthal and elevation angles [deg]."
165
+ )
166
+
167
+ return csv[azi_col].values, csv[elev_col].values
168
+
169
+
170
+ def find_target_sats(
171
+ filtered_tles,
172
+ epochs,
173
+ obs_lat,
174
+ obs_lon,
175
+ azi_list,
176
+ prof_list,
177
+ R,
178
+ obs_height=0,
179
+ start_index=0,
180
+ ):
181
+ """
182
+ Compute satellites visible above horizon
183
+ within radial constraint at given epochs.
184
+
185
+ Parameters:
186
+ ----------
187
+ filtered_tles : list[tuple]
188
+ Filtered TLEs
189
+ epochs : Skyfield
190
+ Array of observation times
191
+ obs_lat : float
192
+ Observer latitude [deg]
193
+ obs_lon : float
194
+ Observer longitude [deg]
195
+ azi_list : list
196
+ Horizon azimuths [deg]
197
+ prof_list : list
198
+ Corresponding Horizon elevations [deg]
199
+ R : float
200
+ Radial constraint [km]
201
+ obs_height : float
202
+ Observer height [km]
203
+ start_index : int
204
+ Starting index of satellite numbering
205
+
206
+ Output:
207
+ list [dict]
208
+ Satellite dictionary with TLEs and
209
+ flyover trajectory properties
210
+ """
211
+ sat_data_list = []
212
+ obs_loc = EarthLocation.from_geodetic(obs_lon, obs_lat, height=obs_height * u.m)
213
+ obs_location = obs_loc.to("km")
214
+
215
+ for index, tle in enumerate(
216
+ tqdm(filtered_tles, desc="Processing satellites", unit="satellite")
217
+ ):
218
+ sat_trajectory = EarthSatellite(tle[0], tle[1], ts=ts)
219
+ sat_dict = {
220
+ "TLE": (tle[0], tle[1]),
221
+ "Epochs": [],
222
+ "Azimuths": [],
223
+ "Elevations": [],
224
+ "Distances": [],
225
+ "Velocities": [],
226
+ }
227
+
228
+ for epoch in epochs:
229
+ satellite_position = sat_trajectory.at(epoch)
230
+ sat_x, sat_y, sat_z = satellite_position.position.km
231
+ distance_squared = (
232
+ (sat_x - obs_location.x.value) ** 2
233
+ + (sat_y - obs_location.y.value) ** 2
234
+ + (sat_z - obs_location.z.value) ** 2
235
+ )
236
+ if distance_squared > R**2:
237
+ continue
238
+
239
+ azimuth, elevation, distance, vel = compute_sat_properties(
240
+ sat_trajectory, obs_location, epoch
241
+ )
242
+ azi_index = np.abs(np.array(azi_list) - azimuth).argmin()
243
+ required_prof = prof_list[azi_index]
244
+ if elevation >= required_prof:
245
+ sat_dict["Epochs"].append(epoch.utc_iso())
246
+ sat_dict["Azimuths"].append(azimuth)
247
+ sat_dict["Elevations"].append(elevation)
248
+ sat_dict["Distances"].append(distance)
249
+ sat_dict["Velocities"].append(vel)
250
+
251
+ if sat_dict["Epochs"]:
252
+ sat_data_list.append(sat_dict)
253
+
254
+ return sat_data_list
255
+
256
+
257
+ def load_satellite_data(
258
+ tle_file,
259
+ target_date,
260
+ obs_len: float,
261
+ traj_res: float,
262
+ obs_lat,
263
+ obs_lon,
264
+ R: float,
265
+ horizon_data,
266
+ satcon: str | None = None,
267
+ start_index=0,
268
+ ):
269
+ """
270
+ Main pipeline for processing data about chosen satellite constellation.
271
+
272
+ Parameters:
273
+ ----------
274
+ tle_file : str
275
+ Path to TLE plaintext file
276
+ target_date : datetime
277
+ Observation start date
278
+ obs_len : float
279
+ Length of observation [seconds]
280
+ traj_res : float
281
+ Resolution of trajectory samples [seconds]
282
+ obs_lat : float
283
+ Observer latitude [deg]
284
+ obs_lon : float
285
+ Observer longitude [deg]
286
+ R : float
287
+ Radial constraint [km]
288
+ horizon_data : tuple
289
+ Horizon profile of observer
290
+ satcon : str
291
+ Satellite constellation name
292
+ start_index : int
293
+ Starting satellite index, default = 0
294
+
295
+ Output:
296
+ ------
297
+ list[dict]
298
+ List of satellite flyover data
299
+ """
300
+ satcon_name = satcon.upper() if satcon else None
301
+ satcon_tles = parse_tle_file(tle_file, satcon_name)
302
+
303
+ filtered_tles = filter_tles_by_date(satcon_tles, target_date)
304
+ orbit_duration = np.arange(0, (obs_len + 1), traj_res)
305
+ epochs = ts.utc(
306
+ target_date.year,
307
+ target_date.month,
308
+ target_date.day,
309
+ target_date.hour,
310
+ target_date.minute,
311
+ target_date.second + orbit_duration,
312
+ )
313
+
314
+ azi_list, prof_list = load_horizon_profile(horizon_data)
315
+ sat_data = find_target_sats(
316
+ filtered_tles, epochs, obs_lat, obs_lon, azi_list, prof_list, R
317
+ )
318
+
319
+ return sat_data