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 +22 -0
- astrotrack/doppler_analysis.py +111 -0
- astrotrack/preprocess.py +319 -0
- astrotrack/psd_analysis.py +493 -0
- astrotrack/satcon_animate.py +171 -0
- astrotrack/satcon_properties.py +420 -0
- astrotrack-0.1.0.dist-info/METADATA +157 -0
- astrotrack-0.1.0.dist-info/RECORD +11 -0
- astrotrack-0.1.0.dist-info/WHEEL +5 -0
- astrotrack-0.1.0.dist-info/licenses/LICENSE +28 -0
- astrotrack-0.1.0.dist-info/top_level.txt +1 -0
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
|
astrotrack/preprocess.py
ADDED
|
@@ -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
|