tabsim 0.0.1__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.
tabsim/tle.py ADDED
@@ -0,0 +1,753 @@
1
+ from astropy.time import Time
2
+ from astropy.coordinates import EarthLocation
3
+ from datetime import datetime
4
+ from skyfield.api import load, wgs84, EarthSatellite
5
+ from skyfield.positionlib import position_of_radec
6
+ import numpy as np
7
+ import pandas as pd
8
+ from numpy.typing import ArrayLike
9
+
10
+ from spacetrack import SpaceTrackClient
11
+ import spacetrack.operators as op
12
+
13
+ from tqdm import tqdm
14
+
15
+ import os
16
+ import ast
17
+ import string
18
+ import random
19
+
20
+ from glob import glob
21
+
22
+ from importlib.resources import files
23
+
24
+
25
+ def make_tle_dir(tle_dir: str | None):
26
+
27
+ if tle_dir:
28
+ tle_dir = os.path.abspath(tle_dir)
29
+ else:
30
+ tle_dir = files("tabsim.data").joinpath("aux_data/tles").__str__()
31
+
32
+ os.makedirs(tle_dir, exist_ok=True)
33
+
34
+ return tle_dir
35
+
36
+
37
+ def get_space_track_client(username, password):
38
+ return SpaceTrackClient(identity=username, password=password)
39
+
40
+
41
+ def fetch_tle_data(
42
+ st_client: SpaceTrackClient,
43
+ norad_ids: list[int],
44
+ epoch_jd: float,
45
+ window_days: float = 1.0,
46
+ limit: int = 2000,
47
+ ):
48
+ """
49
+ Fetch TLE data for given NORAD IDs around a specific epoch.
50
+
51
+ Parameters
52
+ ----------
53
+ st_client : SpaceTrackClient
54
+ SpaceTrackClient instance
55
+ norad_ids : list[int]
56
+ List of NORAD IDs
57
+ epoch_jd : float
58
+ Julian date for the epoch
59
+ window_days : int
60
+ Window size in days around the epoch
61
+ limit : int
62
+ Maximum number of results to return
63
+
64
+ Returns :
65
+ pandas.DataFrame containing TLE data
66
+ """
67
+ start_time = Time(epoch_jd - window_days, format="jd", scale="ut1").datetime
68
+ end_time = Time(epoch_jd + window_days, format="jd", scale="ut1").datetime
69
+ date_range = op.inclusive_range(start_time, end_time)
70
+
71
+ try:
72
+ raw_data = st_client.tle(
73
+ norad_cat_id=norad_ids, epoch=date_range, limit=limit, format="json"
74
+ )
75
+ return pd.DataFrame(ast.literal_eval(raw_data))
76
+ except Exception as e:
77
+ print(f"Error fetching TLE data: {str(e)}")
78
+ raise
79
+
80
+
81
+ def id_generator(size=6, chars=string.ascii_uppercase + string.digits):
82
+ return "".join(random.choice(chars) for _ in range(size))
83
+
84
+
85
+ def get_tles_by_id(
86
+ username: str,
87
+ password: str,
88
+ norad_ids: list[int],
89
+ epoch_jd: float,
90
+ window_days: float = 1.0,
91
+ limit: int = 2000,
92
+ tle_dir: str | None = None,
93
+ ) -> pd.DataFrame:
94
+
95
+ tle_dir = make_tle_dir(tle_dir)
96
+
97
+ norad_ids = list(np.array(list(set(norad_ids))).astype(int))
98
+ n_ids_start = len(norad_ids)
99
+ epoch_str = Time(epoch_jd, format="jd", scale="ut1").strftime("%Y-%m-%d")
100
+
101
+ tles_local = pd.DataFrame()
102
+ if tle_dir:
103
+ tle_dir = os.path.abspath(tle_dir)
104
+ tle_paths = glob(os.path.join(tle_dir, f"{epoch_str}-*.json"))
105
+ local_ids = []
106
+ if len(tle_paths) > 0:
107
+ tles_local = pd.concat([pd.read_json(tle_path) for tle_path in tle_paths])
108
+ tles_local = tles_local[tles_local["NORAD_CAT_ID"].isin(norad_ids)]
109
+ local_ids = tles_local["NORAD_CAT_ID"].unique()
110
+ norad_ids = list(set(norad_ids) - set(local_ids))
111
+ print(f"Local TLEs loaded : {len(local_ids)}")
112
+ else:
113
+ local_ids = []
114
+
115
+ max_ids = 500
116
+ n_ids = len(norad_ids)
117
+
118
+ n_req = n_ids // max_ids + 1 if n_ids % max_ids > 0 else n_ids // max_ids
119
+
120
+ remote_ids = []
121
+ tles = pd.DataFrame()
122
+ if len(norad_ids) > 0:
123
+ client = get_space_track_client(username, password)
124
+ tles = [0] * n_req
125
+ for i in range(n_req):
126
+ tles[i] = fetch_tle_data(client, norad_ids, epoch_jd, window_days, limit)
127
+ if sum([len(tle) for tle in tles]) > 0:
128
+ tles = pd.concat(tles)
129
+ tles["Fetch_Timestamp"] = Time.now().fits
130
+ remote_ids = tles["NORAD_CAT_ID"].unique()
131
+ else:
132
+ tles = pd.DataFrame()
133
+
134
+ print(f"Remote TLEs loaded : {len(remote_ids)}")
135
+ print(f"TLEs not found : {n_ids_start - len(remote_ids) - len(local_ids)}")
136
+
137
+ save_name = id_generator()
138
+
139
+ if tle_dir and len(tles) > 0:
140
+ save_path = os.path.join(tle_dir, f"{epoch_str}-{save_name}.json")
141
+ tles.to_json(save_path)
142
+ print(f"Saving remotely obtained TLEs to {save_path}")
143
+ elif len(tles) > 0:
144
+ save_path = os.path.join("./", f"{epoch_str}-{save_name}.json")
145
+ tles.to_json(save_path)
146
+ print(f"Saving remotely obtained TLEs to {save_path}")
147
+
148
+ if tle_dir:
149
+ tles = pd.concat([tles_local, tles])
150
+
151
+ if len(tles) > 0:
152
+ tles.reset_index(drop=True, inplace=True)
153
+ tles["EPOCH_JD"] = tles["EPOCH"].apply(
154
+ lambda x: Time(spacetrack_time_to_isot(x)).jd
155
+ )
156
+ tles = type_cast_tles(tles)
157
+ tles = get_closest_times(tles, epoch_jd)
158
+
159
+ return tles
160
+
161
+
162
+ def get_tles_by_name(
163
+ username: str,
164
+ password: str,
165
+ names: list[str],
166
+ epoch_jd: float,
167
+ window_days: float = 1.0,
168
+ limit: int = 10000,
169
+ tle_dir: str | None = None,
170
+ ) -> pd.DataFrame:
171
+
172
+ tle_dir = make_tle_dir(tle_dir)
173
+
174
+ # Calculate the date threshold
175
+ epoch_str = Time(epoch_jd, format="jd", scale="ut1").strftime("%Y-%m-%d")
176
+ start_time = Time(epoch_jd - window_days, format="jd", scale="ut1").datetime
177
+ end_time = Time(epoch_jd + window_days, format="jd", scale="ut1").datetime
178
+ drange = op.inclusive_range(start_time, end_time)
179
+
180
+ names_op = [op.like(name.upper()) for name in names]
181
+
182
+ st = SpaceTrackClient(identity=username, password=password)
183
+
184
+ local_ids = 0
185
+ remote_ids = 0
186
+ tles = [0] * len(names)
187
+ for i, name in enumerate(names):
188
+ tle_path = os.path.join(tle_dir, f"{epoch_str}-{name}.json")
189
+ if os.path.isfile(tle_path):
190
+ tle = pd.read_json(tle_path)
191
+ tles[i] = tle
192
+ local_ids += len(tle["NORAD_CAT_ID"].unique())
193
+ else:
194
+ tle = pd.DataFrame(
195
+ ast.literal_eval(
196
+ st.tle(
197
+ object_name=names_op[i],
198
+ epoch=drange,
199
+ limit=limit,
200
+ format="json",
201
+ )
202
+ )
203
+ )
204
+ tle["Fetch_Timestamp"] = Time.now().strftime("%Y-%m-%d %H:%M:%S")
205
+ tles[i] = tle
206
+ if len(tle) > 0:
207
+ remote_ids += len(tle["NORAD_CAT_ID"].unique())
208
+ tles[i].to_json(tle_path)
209
+
210
+ print(f"Local TLEs loaded : {local_ids}")
211
+ print(f"Remote TLEs loaded : {remote_ids}")
212
+
213
+ tles = pd.concat(tles)
214
+ if len(tles) > 0:
215
+ tles.reset_index(drop=True, inplace=True)
216
+ tles["EPOCH_JD"] = tles["EPOCH"].apply(
217
+ lambda x: Time(spacetrack_time_to_isot(x)).jd
218
+ )
219
+ tles = type_cast_tles(tles)
220
+ tles = get_closest_times(tles, epoch_jd)
221
+
222
+ return tles
223
+
224
+
225
+ def spacetrack_time_to_isot(spacetrack_time: str) -> str:
226
+ """Convert times returned by a SpaceTrack API call to ISOT.
227
+
228
+ Parameters
229
+ ----------
230
+ spacetrack_time : str
231
+ SpaceTrack formatted time.
232
+
233
+ Returns
234
+ -------
235
+ str
236
+ ISOT formatted time.
237
+ """
238
+
239
+ dt = datetime.strptime(spacetrack_time, "%Y-%m-%d %H:%M:%S")
240
+ isot = dt.strftime("%Y-%m-%dT%H:%M:%S.000")
241
+
242
+ return isot
243
+
244
+
245
+ def get_closest_times(
246
+ df: pd.DataFrame,
247
+ target_time_jd: float,
248
+ id_col: str = "NORAD_CAT_ID",
249
+ time_jd_col: str = "EPOCH_JD",
250
+ ) -> pd.DataFrame:
251
+ """
252
+ For each unique item in the DataFrame, find the instance with time closest to target_time.
253
+
254
+ Parameters:
255
+ -----------
256
+ df : pandas.DataFrame
257
+ DataFrame containing items and their time instances
258
+ target_time : datetime or timestamp
259
+ The reference time to compare against
260
+ id_col : str, default="NORAD_CAT_ID"
261
+ Name of the column containing norad_ids.
262
+ time_jd_col : str, default="EPOCH_JD"
263
+ Name of the column containing time values in Julian date.
264
+
265
+ Returns:
266
+ --------
267
+ pandas.DataFrame
268
+ DataFrame containing one row per unique item, with the instance closest to target_time
269
+ """
270
+ # Calculate absolute time difference for each row
271
+ df = df.copy()
272
+ df["time_diff"] = df[time_jd_col] - target_time_jd
273
+ df["time_diff_abs"] = np.abs(df[time_jd_col] - target_time_jd)
274
+
275
+ # Group by item and get the row with minimum time difference
276
+ closest_instances = df.loc[df.groupby(id_col)["time_diff_abs"].idxmin()]
277
+
278
+ return closest_instances
279
+
280
+
281
+ def get_visible_satellite_tles(
282
+ username: str,
283
+ password: str,
284
+ times: ArrayLike,
285
+ observer_lat: float,
286
+ observer_lon: float,
287
+ observer_elevation: float,
288
+ target_ra: float,
289
+ target_dec: float,
290
+ max_angular_separation: float,
291
+ min_elevation: float,
292
+ names: ArrayLike = [],
293
+ norad_ids: ArrayLike = [],
294
+ tle_dir: str | None = None,
295
+ ) -> tuple:
296
+ """Get the TLEs corresponding to satellites that satisfy the conditions given.
297
+
298
+ Parameters
299
+ ----------
300
+ username : str
301
+ SpaceTrack username
302
+ password : str
303
+ SpaceTrack password.
304
+ times : ArrayLike
305
+ Times to condsider in Astropy.time.Time format.
306
+ observer_lat : float
307
+ Observer latitude in degrees.
308
+ observer_lon : float
309
+ Observer longitude in degrees.
310
+ observer_elevation : float
311
+ Observer elevation in metres above sea level.
312
+ target_ra : float
313
+ Right ascension of the target direction.
314
+ target_dec : float
315
+ Declination of the target direction.
316
+ max_angular_separation : float
317
+ Maximum angular separation, in degrees, to accept a satellite pass.
318
+ min_elevation : float
319
+ Minimum elevation, in degrees, above the horizon to accept the satellite pass.
320
+ norad_ids : ArrayLike
321
+ NORAD IDs to consider.
322
+ names: list
323
+ Satellite names to consider. An approximate search is done.
324
+ tle_dir: str
325
+ Directory path where TLEs should be / are cached.
326
+
327
+ Returns
328
+ -------
329
+ tuple
330
+ - NORAD IDs that pass the criteria.
331
+ - TLEs for the satellites corresponding to the returned NORAD IDs.
332
+ """
333
+
334
+ tle_dir = make_tle_dir(tle_dir)
335
+
336
+ tles = pd.DataFrame()
337
+ if len(norad_ids) > 0:
338
+ tles = get_tles_by_id(
339
+ username, password, norad_ids, np.mean(times.jd), tle_dir=tle_dir
340
+ )
341
+ if len(names) > 0:
342
+ tles = pd.concat(
343
+ [
344
+ tles,
345
+ get_tles_by_name(
346
+ username, password, names, np.mean(times.jd), tle_dir=tle_dir
347
+ ),
348
+ ]
349
+ )
350
+
351
+ if len(tles) > 0:
352
+ windows = check_satellite_visibilibities(
353
+ tles["NORAD_CAT_ID"].values,
354
+ tles["TLE_LINE1"].values,
355
+ tles["TLE_LINE2"].values,
356
+ times,
357
+ observer_lat,
358
+ observer_lon,
359
+ observer_elevation,
360
+ target_ra,
361
+ target_dec,
362
+ max_angular_separation,
363
+ min_elevation,
364
+ )
365
+
366
+ if len(windows) > 0:
367
+ tles_ = tles[tles["NORAD_CAT_ID"].isin(windows["norad_id"])][
368
+ ["NORAD_CAT_ID", "TLE_LINE1", "TLE_LINE2"]
369
+ ].values
370
+ return tles_[:, 0], tles_[:, 1:]
371
+ else:
372
+ return [], None
373
+ else:
374
+ return [], None
375
+
376
+
377
+ def type_cast_tles(tles: pd.DataFrame) -> pd.DataFrame:
378
+
379
+ numeric_cols = [
380
+ "NORAD_CAT_ID",
381
+ "EPOCH_MICROSECONDS",
382
+ "MEAN_MOTION",
383
+ "ECCENTRICITY",
384
+ "INCLINATION",
385
+ "RA_OF_ASC_NODE",
386
+ "ARG_OF_PERICENTER",
387
+ "MEAN_ANOMALY",
388
+ "EPHEMERIS_TYPE",
389
+ "ELEMENT_SET_NO",
390
+ "REV_AT_EPOCH",
391
+ "BSTAR",
392
+ "MEAN_MOTION_DOT",
393
+ "MEAN_MOTION_DDOT",
394
+ "FILE",
395
+ "OBJECT_NUMBER",
396
+ "SEMIMAJOR_AXIS",
397
+ "PERIOD",
398
+ "APOGEE",
399
+ "PERIGEE",
400
+ ]
401
+
402
+ for col in numeric_cols:
403
+ tles[col] = pd.to_numeric(tles[col])
404
+ tles["DECAYED"] = pd.to_numeric(tles["DECAYED"]).astype(bool)
405
+
406
+ return tles
407
+
408
+
409
+ def make_window(
410
+ times: ArrayLike, alt: ArrayLike, angular_sep: ArrayLike, idx: ArrayLike
411
+ ) -> dict:
412
+ """Make a dictionary containing the start and end times of a satellite pass including some stats.
413
+
414
+ Parameters
415
+ ----------
416
+ times : ArrayLike[Time]
417
+ Times of the satellite pass.
418
+ alt : ArrayLike
419
+ Altitude of the satellite during pass.
420
+ angular_sep : ArrayLike
421
+ Angular separation of the satellite during pass from target.
422
+ idx: ArrayLike
423
+ Index locations of the window.
424
+ Returns
425
+ -------
426
+ dict
427
+ Dictionary of stats.
428
+ """
429
+
430
+ window = {
431
+ "start_time": times[idx][0].datetime.strftime(
432
+ f"%Y-%m-%d-%H:%M:%S.%f {times.scale.upper()}"
433
+ ),
434
+ "end_time": times[idx][-1].datetime.strftime(
435
+ f"%Y-%m-%d-%H:%M:%S.%f {times.scale.upper()}"
436
+ ),
437
+ "visible_period": (times[idx][-1] - times[idx][0]).sec,
438
+ "min_ang_sep": np.min(angular_sep[idx]),
439
+ "max_elevation": np.max(alt[idx]),
440
+ }
441
+
442
+ return window
443
+
444
+
445
+ def check_visibility(
446
+ tle_line1: str,
447
+ tle_line2: str,
448
+ times: list[Time],
449
+ observer_lat: float,
450
+ observer_lon: float,
451
+ observer_elevation: float,
452
+ target_ra: float,
453
+ target_dec: float,
454
+ max_ang_sep: float,
455
+ min_elev: float,
456
+ ) -> list:
457
+ """Calculate visibility windows for a satellite when observing a celestial target.
458
+
459
+ This function determines time windows when a satellite will pass a celestial
460
+ target based on the satellite's orbital parameters (TLE), observer location,
461
+ target coordinates, and visibility constraints.
462
+
463
+ Parameters
464
+ ----------
465
+ tle_line1 : str
466
+ First line of the satellite's Two-Line Element set (TLE).
467
+ tle_line2 : str
468
+ Second line of the satellite's Two-Line Element set (TLE).
469
+ times : list[Time]
470
+ Array of observation times as Astropy Time objects.
471
+ observer_lat : float
472
+ Observer's latitude in degrees.
473
+ observer_lon : float
474
+ Observer's longitude in degrees.
475
+ observer_elevation : float
476
+ Observer's elevation above sea level in meters.
477
+ target_ra : float
478
+ Right Ascension of the target in degrees.
479
+ target_dec : float
480
+ Declination of the target in degrees.
481
+ max_ang_sep : float
482
+ Maximum allowed angular separation between satellite and target in degrees.
483
+ min_elev : float
484
+ Minimum required elevation of the satellite above horizon in degrees.
485
+
486
+ Returns
487
+ -------
488
+ list
489
+ List of visibility windows, where each window is a dictionary containing:
490
+ - 'start_time': Start time of the visibility window
491
+ - 'end_time': End time of the visibility window
492
+ - 'max_elevation': Maximum elevation during the window
493
+ - 'min_angular_separation': Minimum angular separation during the window
494
+
495
+ Notes
496
+ -----
497
+ The function uses the WGS84 Earth model and converts the satellite's position
498
+ to topocentric coordinates for elevation calculations. Visibility windows are
499
+ determined based on both elevation constraints and angular separation from the
500
+ target.
501
+
502
+ The function requires the Skyfield library for satellite calculations and
503
+ assumes the existence of a `make_window` helper function to format the output
504
+ windows.
505
+ """
506
+
507
+ ts = load.timescale()
508
+ sf_times = ts.ut1_jd(times.jd)
509
+
510
+ # Set up observer location
511
+ observer_location = wgs84.latlon(observer_lat, observer_lon, observer_elevation)
512
+
513
+ # Create satellite object
514
+ satellite = EarthSatellite(tle_line1, tle_line2, ts=ts)
515
+
516
+ # Create celestial target position
517
+ target = position_of_radec(
518
+ ra_hours=target_ra / 15, dec_degrees=target_dec
519
+ ) # Convert RA to hours
520
+
521
+ satellite_position = satellite.at(sf_times)
522
+
523
+ topocentric = satellite_position - observer_location.at(sf_times)
524
+ alt, az, distance = topocentric.altaz()
525
+
526
+ angular_sep = topocentric.separation_from(target).degrees
527
+
528
+ vis_idx = np.where((alt.degrees > min_elev) & (angular_sep < max_ang_sep))[0]
529
+ break_idx = np.where(np.diff(vis_idx) > 1)[0]
530
+ if len(break_idx) > 0 or len(vis_idx) > 0:
531
+ break_idx = np.concatenate([[0], break_idx, [len(times)]])
532
+ windows = [
533
+ make_window(
534
+ times,
535
+ alt.degrees,
536
+ angular_sep,
537
+ vis_idx[break_idx[i] : break_idx[i + 1]],
538
+ )
539
+ for i in range(len(break_idx) - 1)
540
+ ]
541
+ else:
542
+ windows = []
543
+
544
+ # print(windows)
545
+
546
+ return windows
547
+
548
+
549
+ def check_satellite_visibilibities(
550
+ norad_ids: list[int],
551
+ tles_line1: list[str],
552
+ tles_line2: list[str],
553
+ times: list[Time],
554
+ observer_lat: float,
555
+ observer_lon: float,
556
+ observer_elevation: float,
557
+ target_ra: float,
558
+ target_dec: float,
559
+ max_ang_sep: float,
560
+ min_elev: float,
561
+ ) -> dict:
562
+ """Calculate visibility windows for a satellite when observing a celestial target.
563
+
564
+ This function determines time windows when a satellite will pass a celestial
565
+ target based on the satellite's orbital parameters (TLE), observer location,
566
+ target coordinates, and visibility constraints.
567
+
568
+ Parameters
569
+ ----------
570
+ norad_ids: list[int]
571
+ NORAD IDs to calculate for.
572
+ tles_line1 : list[str]
573
+ First line of the satellites' Two-Line Element set (TLE).
574
+ tles_line2 : list[str]
575
+ Second line of the satellites' Two-Line Element set (TLE).
576
+ times : list[Time]
577
+ Array of observation times as Astropy Time objects.
578
+ observer_lat : float
579
+ Observer's latitude in degrees.
580
+ observer_lon : float
581
+ Observer's longitude in degrees.
582
+ observer_elevation : float
583
+ Observer's elevation above sea level in meters.
584
+ target_ra : float
585
+ Right Ascension of the target in degrees.
586
+ target_dec : float
587
+ Declination of the target in degrees.
588
+ max_ang_sep : float
589
+ Maximum allowed angular separation between satellite and target in degrees.
590
+ min_elev : float
591
+ Minimum required elevation of the satellite above horizon in degrees.
592
+
593
+ Returns
594
+ -------
595
+ dict
596
+ Dict of list of visibility windows for each NORAD ID, where each window is a dictionary containing:
597
+ - 'start_time': Start time of the visibility window
598
+ - 'end_time': End time of the visibility window
599
+ - 'max_elevation': Maximum elevation during the window
600
+ - 'min_angular_separation': Minimum angular separation during the window
601
+
602
+ Notes
603
+ -----
604
+ The function uses the WGS84 Earth model and converts the satellite's position
605
+ to topocentric coordinates for elevation calculations. Visibility windows are
606
+ determined based on both elevation constraints and angular separation from the
607
+ target.
608
+
609
+ The function requires the Skyfield library for satellite calculations and
610
+ assumes the existence of a `make_window` helper function to format the output
611
+ windows.
612
+ """
613
+
614
+ print()
615
+ print(
616
+ f"Searching which satellites satisfy max_ang_sep: {max_ang_sep:.0f} and min_elev: {min_elev:.0f}"
617
+ )
618
+ all_windows = []
619
+ for i in tqdm(range(len(norad_ids))):
620
+ windows = check_visibility(
621
+ tles_line1[i],
622
+ tles_line2[i],
623
+ times,
624
+ observer_lat,
625
+ observer_lon,
626
+ observer_elevation,
627
+ target_ra,
628
+ target_dec,
629
+ max_ang_sep,
630
+ min_elev,
631
+ )
632
+ if len(windows) > 0:
633
+ all_windows += [{"norad_id": norad_ids[i], **window} for window in windows]
634
+
635
+ print(f"Found {len(all_windows)} matching satellites")
636
+ return pd.DataFrame(all_windows)
637
+
638
+
639
+ def get_satellite_positions(tles: list, times_jd: list) -> ArrayLike:
640
+ """Calculate the ICRS positions of satellites by propagating their TLEs over the given times.
641
+
642
+ Parameters
643
+ ----------
644
+ tles : Array (n_sat, 2)
645
+ TLEs usind to propagate positions.
646
+ times : Array (n_time,)
647
+ Times to calculate positions at in Julian date.
648
+
649
+ Returns
650
+ -------
651
+ Array (n_sat, n_time, 3)
652
+ Satellite positions over time
653
+ """
654
+
655
+ ts = load.timescale()
656
+ sf_times = ts.ut1_jd(times_jd)
657
+
658
+ sat_pos = np.array(
659
+ [
660
+ EarthSatellite(tle_line1, tle_line2, ts=ts).at(sf_times).position.km.T * 1e3
661
+ for tle_line1, tle_line2 in tles
662
+ ]
663
+ )
664
+
665
+ return sat_pos
666
+
667
+
668
+ def ant_pos(ant_itrf: ArrayLike, times_jd: ArrayLike) -> ArrayLike:
669
+
670
+ ts = load.timescale()
671
+ t = ts.ut1_jd(times_jd)
672
+
673
+ location = EarthLocation(x=ant_itrf[0], y=ant_itrf[1], z=ant_itrf[2], unit="m")
674
+ observer = wgs84.latlon(
675
+ location.lat.degree, location.lon.degree, location.height.value
676
+ )
677
+
678
+ return (observer.at(t).position.km * 1e3).T
679
+
680
+
681
+ def ants_pos(ants_itrf: ArrayLike, times_jd: ArrayLike) -> ArrayLike:
682
+
683
+ return np.transpose(
684
+ np.array([ant_pos(ant_itrf, times_jd) for ant_itrf in ants_itrf]),
685
+ axes=(1, 0, 2),
686
+ )
687
+
688
+
689
+ def sat_distance(tle: list[str], times_jd: ArrayLike, obs_itrf: ArrayLike) -> ArrayLike:
690
+
691
+ ts = load.timescale()
692
+
693
+ t = ts.ut1_jd(times_jd)
694
+
695
+ satellite = EarthSatellite(tle[0], tle[1], ts=ts)
696
+
697
+ location = EarthLocation(x=obs_itrf[0], y=obs_itrf[1], z=obs_itrf[2], unit="m")
698
+
699
+ observer = wgs84.latlon(location.lat.degree, location.lon.degree, location.height)
700
+
701
+ topo = (satellite - observer).at(t)
702
+
703
+ return topo.distance().m
704
+
705
+
706
+ def sathub_time_to_isot(sathub_time: str) -> str:
707
+ """Convert the epoch time return by a call to the SatChecker from SatHub to isot.
708
+
709
+ Parameters
710
+ ----------
711
+ sathub_time : str
712
+ Time format returned by the SatChecker api.
713
+
714
+ Returns
715
+ -------
716
+ str
717
+ Time format in isot, easily ingested by astropy.time.Time.
718
+ """
719
+
720
+ dt = datetime.strptime(sathub_time, "%Y-%m-%d %H:%M:%S UTC")
721
+ isot = dt.strftime("%Y-%m-%dT%H:%M:%S.000")
722
+
723
+ return isot
724
+
725
+
726
+ def get_sat_pos_tle(
727
+ tle_line1: str, tle_line2: str, sat_name: str, times_jd: float
728
+ ) -> ArrayLike:
729
+ """Calculate the satellite position in GCRS (ECI) frame at the given Julian dates.
730
+
731
+ Parameters
732
+ ----------
733
+ tle_line1 : str
734
+ First line of the TLE.
735
+ tle_line2 : str
736
+ Second line fo the TLE
737
+ sat_name : str
738
+ Satellite name. This is often given in the line above the TLE.
739
+ times_jd : float
740
+ Julian dates at which to evaluate the satellite position.
741
+
742
+ Returns
743
+ -------
744
+ ArrayLike
745
+ Satellite positions in metres in the GCRS (ECI) frame.
746
+ """
747
+
748
+ ts = load.timescale()
749
+ sat = EarthSatellite(tle_line1, tle_line2, sat_name, ts)
750
+ t_s = ts.ut1_jd(times_jd)
751
+ sat_pos = sat.at(t_s).position.m
752
+
753
+ return sat_pos