nrl-tracker 1.5.0__py3-none-any.whl → 1.7.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.
@@ -0,0 +1,558 @@
1
+ """
2
+ Two-Line Element (TLE) set parsing and data structures.
3
+
4
+ This module provides functions for parsing NORAD Two-Line Element sets,
5
+ which are the standard format for distributing satellite orbital data.
6
+
7
+ TLE Format
8
+ ----------
9
+ A TLE consists of two 69-character lines containing orbital elements
10
+ in a specialized format used by NORAD/USSPACECOM.
11
+
12
+ Line 1 format:
13
+ 1 NNNNNC NNNNNAAA NNNNN.NNNNNNNN +.NNNNNNNN +NNNNN-N +NNNNN-N N NNNNN
14
+
15
+ Column Description
16
+ 01 Line number (1)
17
+ 03-07 Satellite catalog number
18
+ 08 Classification (U=unclassified)
19
+ 10-11 International designator (last two digits of launch year)
20
+ 12-14 International designator (launch number of the year)
21
+ 15-17 International designator (piece of the launch)
22
+ 19-20 Epoch year (last two digits)
23
+ 21-32 Epoch (day of the year and fractional portion of the day)
24
+ 34-43 First derivative of mean motion (ballistic coefficient)
25
+ 45-52 Second derivative of mean motion (decimal point assumed)
26
+ 54-61 BSTAR drag term (decimal point assumed)
27
+ 63 Ephemeris type
28
+ 65-68 Element set number
29
+ 69 Checksum (modulo 10)
30
+
31
+ Line 2 format:
32
+ 2 NNNNN NNN.NNNN NNN.NNNN NNNNNNN NNN.NNNN NNN.NNNN NN.NNNNNNNNNNNNNN
33
+
34
+ Column Description
35
+ 01 Line number (2)
36
+ 03-07 Satellite catalog number
37
+ 09-16 Inclination (degrees)
38
+ 18-25 Right ascension of ascending node (degrees)
39
+ 27-33 Eccentricity (decimal point assumed)
40
+ 35-42 Argument of perigee (degrees)
41
+ 44-51 Mean anomaly (degrees)
42
+ 53-63 Mean motion (revolutions per day)
43
+ 64-68 Revolution number at epoch
44
+ 69 Checksum (modulo 10)
45
+
46
+ References
47
+ ----------
48
+ .. [1] Vallado, D. A., "Fundamentals of Astrodynamics and Applications,"
49
+ 4th ed., Microcosm Press, 2013, Appendix C.
50
+ .. [2] Hoots, F. R. and Roehrich, R. L., "Spacetrack Report No. 3:
51
+ Models for Propagation of NORAD Element Sets," 1980.
52
+ .. [3] CelesTrak, https://celestrak.org/NORAD/documentation/tle-fmt.php
53
+ """
54
+
55
+ from datetime import datetime, timezone
56
+ from typing import NamedTuple
57
+
58
+ import numpy as np
59
+
60
+
61
+ class TLE(NamedTuple):
62
+ """Two-Line Element set data structure.
63
+
64
+ Contains all orbital elements and metadata from a NORAD TLE.
65
+
66
+ Attributes
67
+ ----------
68
+ name : str
69
+ Satellite name (from line 0, if present).
70
+ catalog_number : int
71
+ NORAD catalog number.
72
+ classification : str
73
+ Classification ('U' = unclassified).
74
+ int_designator : str
75
+ International designator (e.g., '98067A').
76
+ epoch_year : int
77
+ Epoch year (4-digit).
78
+ epoch_day : float
79
+ Epoch day of year (fractional).
80
+ ndot : float
81
+ First derivative of mean motion (rev/day^2).
82
+ nddot : float
83
+ Second derivative of mean motion (rev/day^3).
84
+ bstar : float
85
+ BSTAR drag coefficient (1/Earth radii).
86
+ ephemeris_type : int
87
+ Ephemeris type (usually 0 for SGP4).
88
+ element_set_number : int
89
+ Element set number.
90
+ inclination : float
91
+ Inclination (radians).
92
+ raan : float
93
+ Right ascension of ascending node (radians).
94
+ eccentricity : float
95
+ Eccentricity (dimensionless).
96
+ arg_perigee : float
97
+ Argument of perigee (radians).
98
+ mean_anomaly : float
99
+ Mean anomaly (radians).
100
+ mean_motion : float
101
+ Mean motion (radians/minute).
102
+ revolution_number : int
103
+ Revolution number at epoch.
104
+ line1 : str
105
+ Original TLE line 1.
106
+ line2 : str
107
+ Original TLE line 2.
108
+
109
+ Notes
110
+ -----
111
+ Angular quantities are stored in radians for consistency with the
112
+ rest of the pytcl library. Mean motion is in radians/minute as
113
+ required by SGP4/SDP4.
114
+ """
115
+
116
+ name: str
117
+ catalog_number: int
118
+ classification: str
119
+ int_designator: str
120
+ epoch_year: int
121
+ epoch_day: float
122
+ ndot: float
123
+ nddot: float
124
+ bstar: float
125
+ ephemeris_type: int
126
+ element_set_number: int
127
+ inclination: float
128
+ raan: float
129
+ eccentricity: float
130
+ arg_perigee: float
131
+ mean_anomaly: float
132
+ mean_motion: float
133
+ revolution_number: int
134
+ line1: str
135
+ line2: str
136
+
137
+
138
+ def _parse_decimal_with_exponent(s: str) -> float:
139
+ """Parse TLE decimal format with implicit decimal point and exponent.
140
+
141
+ TLE format uses: +NNNNN-N meaning 0.NNNNN * 10^-N
142
+
143
+ Parameters
144
+ ----------
145
+ s : str
146
+ String in TLE decimal format (e.g., '+12345-4' or ' 12345-4').
147
+
148
+ Returns
149
+ -------
150
+ float
151
+ Parsed value.
152
+ """
153
+ s = s.strip()
154
+ if not s or s == "00000-0" or s == "+00000-0" or s == " 00000-0":
155
+ return 0.0
156
+
157
+ # Handle sign
158
+ if s[0] == "-":
159
+ sign = -1
160
+ s = s[1:]
161
+ elif s[0] == "+" or s[0] == " ":
162
+ sign = 1
163
+ s = s[1:]
164
+ else:
165
+ sign = 1
166
+
167
+ # Find exponent marker (- or +)
168
+ for i in range(len(s) - 1, 0, -1):
169
+ if s[i] == "-" or s[i] == "+":
170
+ mantissa = float("0." + s[:i])
171
+ exponent = int(s[i:])
172
+ return sign * mantissa * (10**exponent)
173
+
174
+ # No exponent found, just parse as decimal
175
+ return sign * float("0." + s)
176
+
177
+
178
+ def _verify_checksum(line: str) -> bool:
179
+ """Verify TLE line checksum.
180
+
181
+ Parameters
182
+ ----------
183
+ line : str
184
+ TLE line (69 characters).
185
+
186
+ Returns
187
+ -------
188
+ bool
189
+ True if checksum is valid.
190
+ """
191
+ if len(line) < 69:
192
+ return False
193
+
194
+ checksum = 0
195
+ for c in line[:68]:
196
+ if c.isdigit():
197
+ checksum += int(c)
198
+ elif c == "-":
199
+ checksum += 1
200
+
201
+ expected = int(line[68])
202
+ return (checksum % 10) == expected
203
+
204
+
205
+ def _epoch_year_to_full_year(year_2digit: int) -> int:
206
+ """Convert 2-digit epoch year to 4-digit year.
207
+
208
+ Uses the convention that years < 57 are in 2000s, otherwise 1900s.
209
+ This matches the NORAD convention (Sputnik launched in 1957).
210
+
211
+ Parameters
212
+ ----------
213
+ year_2digit : int
214
+ Two-digit year (0-99).
215
+
216
+ Returns
217
+ -------
218
+ int
219
+ Four-digit year.
220
+ """
221
+ if year_2digit < 57:
222
+ return 2000 + year_2digit
223
+ else:
224
+ return 1900 + year_2digit
225
+
226
+
227
+ def parse_tle(
228
+ line1: str,
229
+ line2: str,
230
+ name: str = "",
231
+ verify_checksum: bool = True,
232
+ ) -> TLE:
233
+ """Parse a Two-Line Element set.
234
+
235
+ Parameters
236
+ ----------
237
+ line1 : str
238
+ First line of the TLE (69 characters).
239
+ line2 : str
240
+ Second line of the TLE (69 characters).
241
+ name : str, optional
242
+ Satellite name. Default empty.
243
+ verify_checksum : bool, optional
244
+ Whether to verify line checksums. Default True.
245
+
246
+ Returns
247
+ -------
248
+ tle : TLE
249
+ Parsed TLE data structure.
250
+
251
+ Raises
252
+ ------
253
+ ValueError
254
+ If TLE format is invalid or checksum fails.
255
+
256
+ Examples
257
+ --------
258
+ >>> line1 = "1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9993"
259
+ >>> line2 = "2 25544 51.6400 247.4627 0006703 130.5360 325.0288 15.49815350479001"
260
+ >>> tle = parse_tle(line1, line2, name="ISS (ZARYA)")
261
+ >>> print(f"Inclination: {np.degrees(tle.inclination):.4f} deg")
262
+ """
263
+ # Validate line lengths
264
+ line1 = line1.rstrip()
265
+ line2 = line2.rstrip()
266
+
267
+ if len(line1) < 69:
268
+ raise ValueError(f"Line 1 too short: {len(line1)} characters (need 69)")
269
+ if len(line2) < 69:
270
+ raise ValueError(f"Line 2 too short: {len(line2)} characters (need 69)")
271
+
272
+ # Verify line numbers
273
+ if line1[0] != "1":
274
+ raise ValueError(f"Line 1 should start with '1', got '{line1[0]}'")
275
+ if line2[0] != "2":
276
+ raise ValueError(f"Line 2 should start with '2', got '{line2[0]}'")
277
+
278
+ # Verify checksums
279
+ if verify_checksum:
280
+ if not _verify_checksum(line1):
281
+ raise ValueError("Line 1 checksum failed")
282
+ if not _verify_checksum(line2):
283
+ raise ValueError("Line 2 checksum failed")
284
+
285
+ # Parse line 1
286
+ catalog_number = int(line1[2:7])
287
+ classification = line1[7]
288
+ int_designator = line1[9:17].strip()
289
+
290
+ epoch_year_2digit = int(line1[18:20])
291
+ epoch_year = _epoch_year_to_full_year(epoch_year_2digit)
292
+ epoch_day = float(line1[20:32])
293
+
294
+ # First derivative of mean motion (revs/day^2, divided by 2)
295
+ ndot_str = line1[33:43].strip()
296
+ ndot = float(ndot_str) * 2 # Multiply by 2 (stored as ndot/2)
297
+
298
+ # Second derivative of mean motion (revs/day^3, divided by 6)
299
+ nddot = _parse_decimal_with_exponent(line1[44:52]) * 6 # Multiply by 6
300
+
301
+ # BSTAR drag coefficient
302
+ bstar = _parse_decimal_with_exponent(line1[53:61])
303
+
304
+ ephemeris_type = int(line1[62])
305
+ element_set_number = int(line1[64:68].strip() or "0")
306
+
307
+ # Parse line 2
308
+ catalog_number_2 = int(line2[2:7])
309
+ if catalog_number_2 != catalog_number:
310
+ raise ValueError(
311
+ f"Catalog number mismatch: {catalog_number} vs {catalog_number_2}"
312
+ )
313
+
314
+ # Angles in degrees
315
+ inclination_deg = float(line2[8:16])
316
+ raan_deg = float(line2[17:25])
317
+
318
+ # Eccentricity (decimal point assumed)
319
+ eccentricity = float("0." + line2[26:33])
320
+
321
+ arg_perigee_deg = float(line2[34:42])
322
+ mean_anomaly_deg = float(line2[43:51])
323
+
324
+ # Mean motion (revs/day) -> radians/minute
325
+ mean_motion_revs_day = float(line2[52:63])
326
+ mean_motion = mean_motion_revs_day * 2 * np.pi / 1440.0 # rad/min
327
+
328
+ revolution_number = int(line2[63:68].strip() or "0")
329
+
330
+ # Convert angles to radians
331
+ deg_to_rad = np.pi / 180.0
332
+
333
+ return TLE(
334
+ name=name,
335
+ catalog_number=catalog_number,
336
+ classification=classification,
337
+ int_designator=int_designator,
338
+ epoch_year=epoch_year,
339
+ epoch_day=epoch_day,
340
+ ndot=ndot,
341
+ nddot=nddot,
342
+ bstar=bstar,
343
+ ephemeris_type=ephemeris_type,
344
+ element_set_number=element_set_number,
345
+ inclination=inclination_deg * deg_to_rad,
346
+ raan=raan_deg * deg_to_rad,
347
+ eccentricity=eccentricity,
348
+ arg_perigee=arg_perigee_deg * deg_to_rad,
349
+ mean_anomaly=mean_anomaly_deg * deg_to_rad,
350
+ mean_motion=mean_motion,
351
+ revolution_number=revolution_number,
352
+ line1=line1,
353
+ line2=line2,
354
+ )
355
+
356
+
357
+ def parse_tle_3line(lines: str) -> TLE:
358
+ """Parse a three-line TLE (with satellite name).
359
+
360
+ Parameters
361
+ ----------
362
+ lines : str
363
+ Three-line TLE string (name, line1, line2).
364
+
365
+ Returns
366
+ -------
367
+ tle : TLE
368
+ Parsed TLE data structure.
369
+
370
+ Examples
371
+ --------
372
+ >>> tle_text = '''ISS (ZARYA)
373
+ ... 1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9993
374
+ ... 2 25544 51.6400 247.4627 0006703 130.5360 325.0288 15.49815350479001'''
375
+ >>> tle = parse_tle_3line(tle_text)
376
+ >>> print(tle.name)
377
+ ISS (ZARYA)
378
+ """
379
+ line_list = lines.strip().split("\n")
380
+
381
+ if len(line_list) < 2:
382
+ raise ValueError("TLE must have at least 2 lines")
383
+
384
+ if len(line_list) == 2:
385
+ return parse_tle(line_list[0], line_list[1])
386
+ else:
387
+ name = line_list[0].strip()
388
+ return parse_tle(line_list[1], line_list[2], name=name)
389
+
390
+
391
+ def tle_epoch_to_jd(tle: TLE) -> float:
392
+ """Convert TLE epoch to Julian date.
393
+
394
+ Parameters
395
+ ----------
396
+ tle : TLE
397
+ Parsed TLE.
398
+
399
+ Returns
400
+ -------
401
+ jd : float
402
+ Julian date of TLE epoch.
403
+
404
+ Examples
405
+ --------
406
+ >>> tle = parse_tle(line1, line2)
407
+ >>> jd = tle_epoch_to_jd(tle)
408
+ """
409
+ # Start of year
410
+ year = tle.epoch_year
411
+
412
+ # Julian date at midnight Jan 1 of epoch year
413
+ # Using algorithm from time_systems module
414
+ a = (14 - 1) // 12
415
+ y = year + 4800 - a
416
+ m = 1 + 12 * a - 3
417
+
418
+ jd_jan1 = 1 + (153 * m + 2) // 5 + 365 * y + y // 4 - y // 100 + y // 400 - 32045
419
+ jd_jan1 = float(jd_jan1) - 0.5 # Midnight
420
+
421
+ # Add day of year (1-based, so subtract 1)
422
+ jd = jd_jan1 + (tle.epoch_day - 1.0)
423
+
424
+ return jd
425
+
426
+
427
+ def tle_epoch_to_datetime(tle: TLE) -> datetime:
428
+ """Convert TLE epoch to Python datetime.
429
+
430
+ Parameters
431
+ ----------
432
+ tle : TLE
433
+ Parsed TLE.
434
+
435
+ Returns
436
+ -------
437
+ dt : datetime
438
+ TLE epoch as UTC datetime.
439
+ """
440
+ year = tle.epoch_year
441
+ day_of_year = tle.epoch_day
442
+
443
+ # Integer day and fractional part
444
+ day_int = int(day_of_year)
445
+ day_frac = day_of_year - day_int
446
+
447
+ # Convert to datetime
448
+ dt = datetime(year, 1, 1, tzinfo=timezone.utc) + __import__("datetime").timedelta(
449
+ days=day_int - 1 + day_frac
450
+ )
451
+
452
+ return dt
453
+
454
+
455
+ def format_tle(tle: TLE, include_name: bool = True) -> str:
456
+ """Format TLE data structure back to TLE string.
457
+
458
+ Parameters
459
+ ----------
460
+ tle : TLE
461
+ TLE data structure.
462
+ include_name : bool, optional
463
+ Whether to include satellite name as line 0. Default True.
464
+
465
+ Returns
466
+ -------
467
+ str
468
+ Formatted TLE string.
469
+ """
470
+ lines = []
471
+
472
+ if include_name and tle.name:
473
+ lines.append(tle.name)
474
+
475
+ lines.append(tle.line1)
476
+ lines.append(tle.line2)
477
+
478
+ return "\n".join(lines)
479
+
480
+
481
+ def is_deep_space(tle: TLE) -> bool:
482
+ """Determine if TLE requires deep-space (SDP4) propagation.
483
+
484
+ Satellites with orbital period >= 225 minutes use SDP4 instead
485
+ of SGP4 due to lunar-solar perturbations.
486
+
487
+ Parameters
488
+ ----------
489
+ tle : TLE
490
+ Parsed TLE.
491
+
492
+ Returns
493
+ -------
494
+ bool
495
+ True if deep-space propagation (SDP4) is required.
496
+ """
497
+ # Mean motion in rad/min, period = 2*pi / n
498
+ period_minutes = 2 * np.pi / tle.mean_motion
499
+ return period_minutes >= 225.0
500
+
501
+
502
+ def semi_major_axis_from_mean_motion(n: float, mu: float = 398600.4418) -> float:
503
+ """Compute semi-major axis from mean motion.
504
+
505
+ Uses the relationship n = sqrt(mu / a^3) where n is in rad/s.
506
+
507
+ Parameters
508
+ ----------
509
+ n : float
510
+ Mean motion (radians/minute).
511
+ mu : float, optional
512
+ Gravitational parameter (km^3/s^2). Default is Earth.
513
+
514
+ Returns
515
+ -------
516
+ a : float
517
+ Semi-major axis (km).
518
+ """
519
+ # Convert to rad/s
520
+ n_rad_s = n / 60.0
521
+
522
+ # a = (mu / n^2)^(1/3)
523
+ return (mu / (n_rad_s**2)) ** (1.0 / 3.0)
524
+
525
+
526
+ def orbital_period_from_tle(tle: TLE) -> float:
527
+ """Compute orbital period from TLE mean motion.
528
+
529
+ Parameters
530
+ ----------
531
+ tle : TLE
532
+ Parsed TLE.
533
+
534
+ Returns
535
+ -------
536
+ period : float
537
+ Orbital period (seconds).
538
+ """
539
+ # Mean motion in rad/min, period = 2*pi / n (in minutes)
540
+ period_minutes = 2 * np.pi / tle.mean_motion
541
+ return period_minutes * 60.0 # Convert to seconds
542
+
543
+
544
+ __all__ = [
545
+ # Types
546
+ "TLE",
547
+ # Parsing
548
+ "parse_tle",
549
+ "parse_tle_3line",
550
+ # Conversion
551
+ "tle_epoch_to_jd",
552
+ "tle_epoch_to_datetime",
553
+ "format_tle",
554
+ # Utilities
555
+ "is_deep_space",
556
+ "semi_major_axis_from_mean_motion",
557
+ "orbital_period_from_tle",
558
+ ]
@@ -24,6 +24,12 @@ from pytcl.atmosphere.ionosphere import (
24
24
  simple_iri,
25
25
  )
26
26
  from pytcl.atmosphere.models import G0 # Constants
27
+ from pytcl.atmosphere.nrlmsise00 import (
28
+ F107Index,
29
+ NRLMSISE00,
30
+ NRLMSISE00Output,
31
+ nrlmsise00,
32
+ )
27
33
  from pytcl.atmosphere.models import (
28
34
  GAMMA,
29
35
  P0,
@@ -46,6 +52,11 @@ __all__ = [
46
52
  "altitude_from_pressure",
47
53
  "mach_number",
48
54
  "true_airspeed_from_mach",
55
+ # NRLMSISE-00 High-Fidelity Model
56
+ "NRLMSISE00",
57
+ "NRLMSISE00Output",
58
+ "F107Index",
59
+ "nrlmsise00",
49
60
  # Atmosphere constants
50
61
  "T0",
51
62
  "P0",