pygnss 2.1.2__cp314-cp314t-macosx_11_0_arm64.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.
pygnss/nequick.py ADDED
@@ -0,0 +1,57 @@
1
+ import datetime
2
+ import math
3
+ from typing import List
4
+
5
+ import numpy as np
6
+
7
+ import nequick
8
+
9
+ from pygnss import ionex
10
+ import pygnss.iono.gim
11
+
12
+
13
+ class GimIonexHandler(nequick.GimHandler):
14
+ """
15
+ A handler that accumulates GIMs and then generates an IONEX file
16
+ """
17
+
18
+ def __init__(self, coeffs: nequick.Coefficients):
19
+ self._coeffs = coeffs
20
+ self._gims: List[nequick.gim.Gim] = []
21
+
22
+ def process(self, gim: nequick.Gim):
23
+ """
24
+ Store the incoming gim for later process
25
+ """
26
+
27
+ # Check that the latitude and longitude values are
28
+ # the same as the last appended gim
29
+ if len(self._gims) > 0:
30
+ last_gim = self._gims[-1]
31
+ if np.array_equal(last_gim.latitudes, gim.latitudes) == False:
32
+ raise ValueError("Latitude values do not match")
33
+ if np.array_equal(last_gim.longitudes, gim.longitudes) == False:
34
+ raise ValueError("Longitude values do not match")
35
+
36
+ self._gims.append(gim)
37
+
38
+ def to_ionex(self, filename: str, pgm: str = "pygnss", runby: str = "pygnss") -> None:
39
+
40
+ comment_lines = [
41
+ "Maps computed using the NeQuick model with the following",
42
+ "coefficients:",
43
+ f"a0={self._coeffs.a0:<17.6f}a1={self._coeffs.a1:<17.8f}a2={self._coeffs.a2:<17.11f}"
44
+ ]
45
+
46
+ ionex.write(filename, self._gims, pygnss.iono.gim.GimType.TEC, pgm, runby,
47
+ comment_lines=comment_lines)
48
+
49
+
50
+ def to_ionex(filename: str, coeffs: nequick.Coefficients, dates: List[datetime.datetime]):
51
+
52
+ gim_handler = GimIonexHandler(coeffs)
53
+
54
+ for date in dates:
55
+ nequick.to_gim(coeffs, date, gim_handler=gim_handler)
56
+
57
+ gim_handler.to_ionex(filename)
File without changes
pygnss/orbit/kepler.py ADDED
@@ -0,0 +1,63 @@
1
+ from dataclasses import dataclass
2
+ import datetime
3
+ import math
4
+
5
+ from ..constants import EARTH_GRAVITATION_PARAM_MU
6
+
7
+
8
+ @dataclass
9
+ class Kepler(object):
10
+ """
11
+ Data class to represent a satellite orbit in an inertial reference frame
12
+ """
13
+
14
+ """ Time of Ephemeris """
15
+ toe: datetime.datetime
16
+
17
+ """ Semi-major axis [m] """
18
+ a_m: float
19
+
20
+ """ Eccentricity """
21
+ eccentricity: float
22
+
23
+ """ Inclination [rad] """
24
+ inclination_rad: float
25
+
26
+ """ Right ascension of the ascending node [rad] """
27
+ raan_rad: float
28
+
29
+ """ Argument of the perigee [rad] """
30
+ arg_perigee_rad: float
31
+
32
+ """ True anomaly at the time of ephemeris [rad] """
33
+ true_anomaly_rad: float
34
+
35
+ delta_n_dot_rad_per_s: float = 0.0
36
+
37
+ def __repr__(self) -> str:
38
+ out = f"""
39
+ toe: {self.toe}
40
+ semi-major axis[deg]: {math.degrees(self.a_m)}
41
+ eccentricity: {self.eccentricity}
42
+ inclination[deg]: {math.degrees(self.inclination_rad)}
43
+ RAAN[deg]: {math.degrees(self.raan_rad)}
44
+ arg_perigee[deg]: {math.degrees(self.arg_perigee_rad)}
45
+ mean anomaly[deg]: {math.degrees(self.true_anomaly_rad)}
46
+ Delta N [deg/s]: {math.degrees(self.delta_n_dot_rad_per_s)}
47
+ """
48
+ return out
49
+
50
+
51
+ def compute_semi_major_axis(mean_motion_rad_per_s: float) -> float:
52
+ """
53
+ Compute the semi major axis (a) in meters from the mean motion
54
+
55
+ Using the equation $$n = sqrt(mu) / (sqrt(A))^3$$
56
+
57
+ >>> mean_motion_rev_per_day = 15.5918272
58
+ >>> mean_motion_rad_per_s = mean_motion_rev_per_day * math.tau / 86400.0
59
+ >>> compute_semi_major_axis(mean_motion_rad_per_s)
60
+ 6768158.4970976645
61
+ """
62
+
63
+ return math.pow(EARTH_GRAVITATION_PARAM_MU/math.pow(mean_motion_rad_per_s, 2.0), 1.0 / 3.0)
pygnss/orbit/tle.py ADDED
@@ -0,0 +1,186 @@
1
+ import datetime
2
+ import math
3
+ from typing import List, Union, IO
4
+
5
+ from ..gnss.types import ConstellationId, Satellite
6
+
7
+ from .kepler import Kepler, compute_semi_major_axis
8
+
9
+ REV_PER_DAY_TO_RAD_PER_S = math.tau / 86400.0
10
+
11
+
12
+ def parse_decimal_point(number: str) -> float:
13
+ """
14
+ Parse float numers with decimal point assumed and exponent
15
+
16
+ >>> parse_decimal_point("0006703")
17
+ 0.0006703
18
+ >>> f'{parse_decimal_point("-11606-4"):.4e}'
19
+ '-1.1606e-05'
20
+ >>> f'{parse_decimal_point("-11606+4"):.4e}'
21
+ '-1.1606E+03'
22
+ >>> f'{parse_decimal_point(" 11606-4"):.4e}'
23
+ '1.1606e-05'
24
+ """
25
+
26
+ has_exponent = number[-2] == '-' or number[-2] == '+'
27
+
28
+ if has_exponent:
29
+ power = math.pow(10, int(number[-2:]))
30
+ n = len(number[1:-2])
31
+ return float(number[0:-2]) * math.pow(10, -n) * power
32
+ else:
33
+ power = math.pow(10, -len(number))
34
+ return float(number) * power
35
+
36
+
37
+ def calculate_checksum(line: str) -> int:
38
+ checksum = 0
39
+
40
+ for character in line:
41
+ if character.isdigit():
42
+ checksum += int(character)
43
+ elif character == '-':
44
+ checksum += 1
45
+
46
+ return checksum % 10
47
+
48
+
49
+ class TLE(object):
50
+
51
+ def __init__(self, line1: str, line2: str, label=None) -> 'TLE':
52
+
53
+ # Check integrity of TLE input
54
+ id_1 = line1[2:7]
55
+ id_2 = line2[2:7]
56
+ if id_1 != id_2:
57
+ raise RuntimeError(f'TLE lines correspond to different satellite catalog numbers {id_1} == {id_2}')
58
+
59
+ chk = calculate_checksum(line1[:-1])
60
+ chk_expected = int(line1[-1])
61
+ if chk != chk_expected:
62
+ raise RuntimeError(f'Invalid checksum for TLE line 1, got {chk}, expected {chk_expected}')
63
+ chk = calculate_checksum(line2[:-1])
64
+ chk_expected = int(line2[-1])
65
+ if chk != chk_expected:
66
+ raise RuntimeError(f'Invalid checksum for TLE line 2, got {chk}, expected {chk_expected}')
67
+
68
+ self.label = label
69
+ self.id = int(id_1)
70
+
71
+ line = line1
72
+
73
+ # Epoch
74
+ year = int(line[18:20]) + 2000
75
+ day_of_year = float(line[20:32])
76
+ doy = int(day_of_year)
77
+ fraction_of_day = day_of_year - doy
78
+ f_hour = 24 * fraction_of_day
79
+ hour = int(f_hour)
80
+ f_min = 60 * (f_hour - hour)
81
+ min = int(f_min)
82
+ f_sec = 60 * (f_min - min)
83
+ sec = int(f_sec)
84
+ fraction_of_second = f_sec - sec
85
+ datetime_str = f'{year} {doy} {hour} {min} {sec}'
86
+ epoch = datetime.datetime.strptime(datetime_str, '%Y %j %H %M %S')
87
+ offset = datetime.timedelta(seconds=fraction_of_second)
88
+ self.toe = epoch + offset
89
+
90
+ # Mean motion
91
+ self.n_dot_rad_per_s = float(line[33:43]) * REV_PER_DAY_TO_RAD_PER_S
92
+ self.n_dot_dot_rad_per_s2 = parse_decimal_point(line[44:52]) * REV_PER_DAY_TO_RAD_PER_S / 86400.0
93
+
94
+ # Atmospheric drag coefficient
95
+ self.bstar = parse_decimal_point(line[53:61])
96
+
97
+ line = line2
98
+
99
+ self.inclination_rad = math.radians(float(line[8:16]))
100
+ self.RAAN_rad = math.radians(float(line[17:25]))
101
+ self.eccentricity = parse_decimal_point(line[26:33])
102
+ self.arg_perigee_rad = math.radians(float(line[34:42]))
103
+ self.mean_anomaly_rad = math.radians(float(line[43:51]))
104
+ self.mean_motion_rad_per_s = float(line[52:63]) * REV_PER_DAY_TO_RAD_PER_S
105
+
106
+ def __repr__(self) -> str:
107
+ out = f"""
108
+ label: {self.label}
109
+ id: {self.id}
110
+ toe: {self.toe}
111
+ n_dot[rad/s]: {self.n_dot_rad_per_s}
112
+ n_dot_dot[rad/s^2]: {self.n_dot_dot_rad_per_s2}
113
+ b*: {self.bstar}
114
+ inclination[deg]: {math.degrees(self.inclination_rad)}
115
+ RAAN[deg]: {math.degrees(self.RAAN_rad)}
116
+ eccentricity: {self.eccentricity}
117
+ arg_perigee[deg]: {math.degrees(self.arg_perigee_rad)}
118
+ mean anomaly[deg]: {math.degrees(self.mean_anomaly_rad)}
119
+ mean motion[rad/s]: {self.mean_motion_rad_per_s}
120
+ """
121
+ return out
122
+
123
+ def get_satellite(self) -> Satellite:
124
+
125
+ constellation = get_constellation_from_label(self.label)
126
+ prn = self.id
127
+
128
+ return Satellite(constellation, prn)
129
+
130
+ def to_kepler(self) -> Kepler:
131
+ """
132
+ Rough mapping of the Two Line Element set into Keplerian parameters
133
+
134
+ Use this method at your own risk, TLE elements cannot be considered
135
+ classical orbital elements
136
+
137
+ Based on https://blog.hardinglabs.com/tle-to-kep.html
138
+ """
139
+
140
+ a_m = compute_semi_major_axis(self.mean_motion_rad_per_s)
141
+ return Kepler(self.toe,
142
+ a_m, self.eccentricity, self.inclination_rad, self.RAAN_rad,
143
+ self.arg_perigee_rad, self.mean_anomaly_rad,
144
+ delta_n_dot_rad_per_s=self.n_dot_rad_per_s)
145
+
146
+
147
+ def read_celestrak(tle_source: Union[str, IO]) -> List[TLE]:
148
+ """
149
+ Read a NORAD General Perturbations (GP) file in TLE format, that can be found
150
+ at https://celestrak.org/NORAD/elements/
151
+ """
152
+ tles = []
153
+
154
+ # Check if tle_source is a string (filename) or a file handler
155
+ if isinstance(tle_source, str):
156
+ with open(tle_source, "r") as fh:
157
+ _read_celestrak_from_stream(fh, tles)
158
+ else:
159
+ _read_celestrak_from_stream(tle_source, tles)
160
+
161
+ return tles
162
+
163
+
164
+ def _read_celestrak_from_stream(file_handle: IO[str], tles: List[TLE]) -> None:
165
+
166
+ while True:
167
+ lines = [next(file_handle, None) for _ in range(3)]
168
+ if any(line is None for line in lines):
169
+ break
170
+
171
+ lines = [line.strip() for line in lines]
172
+ tles.append(TLE(lines[1], lines[2], label=lines[0]))
173
+
174
+
175
+ def get_constellation_from_label(label: str) -> ConstellationId:
176
+
177
+ out = ConstellationId.UNKNOWN
178
+
179
+ if 'ONEWEB' in label:
180
+ out = ConstellationId.ONEWEB
181
+ elif 'LEMUR' in label:
182
+ out = ConstellationId.SPIRE
183
+ elif 'STARLINK' in label:
184
+ out = ConstellationId.STARLINK
185
+
186
+ return out
@@ -0,0 +1,166 @@
1
+ import datetime
2
+ import numpy as np
3
+ import pandas as pd
4
+
5
+ from pygnss import logger
6
+ from pygnss.file import grep_lines
7
+ from pygnss.time import from_week_tow
8
+ from pygnss.gnss.residuals import Residuals
9
+ from pygnss.gnss.types import TrackingChannel, INVALID_TRACKING_CHANNEL
10
+
11
+
12
+ def parse(filename: str) -> Residuals:
13
+ """
14
+ Loads $SAT lines of a.stat file from a rtklib solution
15
+ The format of those files are the following:
16
+ Residuals of pseudorange and carrier-phase observables. The format
17
+ of a record is as follows.
18
+ $SAT,week,tow,sat,frq,az,el,resp,resc,vsat,snr,fix,slip,lock,outc,slipc,rejc,icbias,bias,bias_var,lambda
19
+ - week/tow : gps week no/time of week (s)
20
+ - sat/frq : satellite id/frequency (1:L1,2:L2,3:L5,...)
21
+ - az/el : azimuth/elevation angle (deg)
22
+ - resp : pseudorange residual (m)
23
+ - resc : carrier-phase residual (m)
24
+ - vsat : valid data flag (0:invalid,1:valid)
25
+ - snr : signal strength (dbHz)
26
+ - fix : ambiguity flag (0:no data,1:not part of AR set,2:part of AR set,3:part of hold set)
27
+ - slip : cycle-slip flag (bit1:slip,bit2:parity unknown)
28
+ - lock : carrier-lock count
29
+ - outc : data outage count
30
+ - slipc : cycle-slip count
31
+ - rejc : data reject (outlier) count
32
+ - icbias : interchannel bias (GLONASS)
33
+ - bias : phase bias
34
+ - bias_var : variance of phase bias
35
+ - lambda : wavelength
36
+
37
+
38
+ Example:
39
+ $SAT,2215,152783.000,G03,1,82.8,35.9,16.3975,0.0018,1,36,0,0,0,0,0,0,-117643836.98,182.318392,0.00000
40
+
41
+ Args:
42
+ stat_file (str): .stat file path
43
+
44
+ Returns:
45
+ np.array: A numpy array with the content of the file
46
+ """
47
+
48
+ DTYPE = [
49
+ ('week', 'i8'),
50
+ ('tow', 'f8'),
51
+ ('sat', 'S3'),
52
+ ('freq', 'i8'),
53
+ ('az', 'f8'),
54
+ ('el', 'f8'),
55
+ ('res_code_m', 'f8'),
56
+ ('res_phase_m', 'f8'),
57
+ ('vsat', 'i8'),
58
+ ('snr_dbHz', 'f8'),
59
+ ('fix', 'f8'),
60
+ ('slip', 'f8'),
61
+ ('lock', 'f8'),
62
+ ('outc', 'f8'),
63
+ ('slipc', 'f8'),
64
+ ('rejc', 'f8'),
65
+ # ('icbias', 'f8'),
66
+ # ('bias', 'f8'),
67
+ # ('bias_var', 'f8'),
68
+ # ('lambda', 'f8'),
69
+ ]
70
+
71
+ USECOLS = list(range(1, len(DTYPE) + 1))
72
+
73
+ generator = grep_lines(filename, "$SAT")
74
+
75
+ data = np.loadtxt(generator, delimiter=',', usecols=USECOLS, dtype=DTYPE)
76
+
77
+ data = __add_field_in_numpy_array(data, [('epoch', datetime.datetime)])
78
+ data['epoch'] = [from_week_tow(int(row['week']), float(row['tow'])) for row in data]
79
+
80
+ data = __add_field_in_numpy_array(data, [('processing_direction', 'S8')])
81
+ data_length = len(data)
82
+ data['processing_direction'] = ['forward'] * data_length
83
+ if (np.unique(data['epoch']).size > 1) and (data['epoch'][0] == data['epoch'][-1]):
84
+ data['processing_direction'][int(data_length / 2):] = 'backward'
85
+ df = pd.DataFrame(data)
86
+ df['sat'] = df['sat'].str.decode('utf-8')
87
+ df['processing_direction'] = df['processing_direction'].str.decode('utf-8')
88
+ df['constellation'] = df['sat'].str[0]
89
+ df['channel'] = [__compute_channel(constellation, frequency)
90
+ for constellation, frequency in zip(df['constellation'], df['freq'])]
91
+ df['signal'] = [str(sat) + str(channel) for sat, channel in zip(df['sat'], df['channel'])]
92
+
93
+ return Residuals(df)
94
+
95
+
96
+ def __compute_channel(constellation: str, frequency: int) -> TrackingChannel:
97
+
98
+ CHANNEL_1C = TrackingChannel(1, 'C')
99
+ CHANNEL_2C = TrackingChannel(2, 'C')
100
+ CHANNEL_5Q = TrackingChannel(5, 'Q')
101
+
102
+ conversion_rule = {
103
+ 'G': {
104
+ 1: CHANNEL_1C,
105
+ 2: CHANNEL_2C,
106
+ 3: CHANNEL_5Q,
107
+ },
108
+ 'E': {
109
+ 1: CHANNEL_1C,
110
+ 2: TrackingChannel(7, 'Q'),
111
+ 3: CHANNEL_5Q,
112
+ },
113
+ 'C': {
114
+ 1: TrackingChannel(2, 'I'),
115
+ 2: TrackingChannel(6, 'I'),
116
+ 3: TrackingChannel(7, 'I'),
117
+ },
118
+ 'R': {
119
+ 1: CHANNEL_1C,
120
+ 2: CHANNEL_2C,
121
+ 3: INVALID_TRACKING_CHANNEL,
122
+ },
123
+ 'J': {
124
+ 1: CHANNEL_1C,
125
+ 2: CHANNEL_2C,
126
+ 3: CHANNEL_5Q,
127
+ }
128
+ }
129
+
130
+ try:
131
+ return conversion_rule[constellation][frequency]
132
+ except Exception:
133
+ logger.debug(f"Unknown channel for {constellation} with frequency {frequency}")
134
+ return INVALID_TRACKING_CHANNEL
135
+
136
+
137
+ def __add_field_in_numpy_array(a: np.array, descr) -> np.array:
138
+ """Return a new array that is like "a", but has additional fields.
139
+
140
+ Arguments:
141
+ a -- a structured numpy array
142
+ descr -- a numpy type description of the new fields
143
+
144
+ The contents of "a" are copied over to the appropriate fields in
145
+ the new array, whereas the new fields are uninitialized. The
146
+ arguments are not modified.
147
+
148
+ >>> sa = numpy.array([(1, 'Foo'), (2, 'Bar')], dtype=[('id', '<i8'), ('name', '|S3')])
149
+ >>> sa.dtype.descr
150
+ [('id', '<i8'), ('name', '|S3')]
151
+
152
+ >>> sb = __add_field_in_numpy_array(sa, [('score', '<f8')])
153
+ >>> sb.dtype.descr
154
+ [('id', '<i8'), ('name', '|S3'), ('score', '<f8')]
155
+
156
+ >>> numpy.all(sa['id'] == sb['id'])
157
+ True
158
+ >>> numpy.all(sa['name'] == sb['name'])
159
+ True
160
+ """
161
+ if a.dtype.fields is None:
162
+ raise ValueError("'A' must be a structured numpy array")
163
+ b = np.empty(a.shape, dtype=a.dtype.descr + descr)
164
+ for name in a.dtype.names:
165
+ b[name] = a[name]
166
+ return b