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.
File without changes
pygnss/gnss/edit.py ADDED
@@ -0,0 +1,66 @@
1
+ import numpy as np
2
+ import pandas as pd
3
+
4
+ ARC_ID_FIELD = 'arc_id'
5
+
6
+
7
+ def compute_phase_arc_id(data: pd.DataFrame) -> pd.DataFrame:
8
+ """
9
+ Computes the phase arc ID, that can be used later to perform operations
10
+ on a per-arc basis (compute arc bias, ...)
11
+ """
12
+
13
+ data[ARC_ID_FIELD] = data.groupby('signal')['slip'].transform('cumsum')
14
+
15
+ return data
16
+
17
+
18
+ def mark_time_gap(data: pd.DataFrame, threshold_s: float = 5) -> pd.DataFrame:
19
+ """
20
+ Mark a phase cycle slip when the time series show a time gap
21
+ of a given threshold
22
+ """
23
+
24
+ # Function to mark epochs with a difference > threshold
25
+ def mark_epochs(group):
26
+ EPOCH_FIELD = 'epoch'
27
+ marked = group[EPOCH_FIELD].diff() > pd.Timedelta(seconds=threshold_s)
28
+ return marked
29
+
30
+ # Apply the function per group using groupby
31
+ marked_epochs = data.groupby('signal').apply(mark_epochs)
32
+
33
+ data['slip'] = np.any([data['slip'], marked_epochs], axis=0)
34
+
35
+ data = compute_phase_arc_id(data)
36
+
37
+ return data
38
+
39
+
40
+ def detrend(data: pd.DataFrame, observable: str, n_samples: int) -> pd.DataFrame:
41
+ """
42
+ Detrend a given observable by using a rolling window of a certain number of
43
+ samples
44
+ """
45
+
46
+ trend_column = f'{observable}_trend'
47
+ detrended_column = f'{observable}_detrended'
48
+
49
+ trend = data.groupby(['signal', ARC_ID_FIELD])[observable].transform(lambda x: x.rolling(n_samples).mean())
50
+ data[trend_column] = trend
51
+ data[detrended_column] = data[observable] - data[trend_column]
52
+
53
+ return data
54
+
55
+
56
+ def remove_mean(data: pd.DataFrame, observable: str) -> pd.DataFrame:
57
+
58
+ bias_field = f'{observable}_bias'
59
+ aligned_field = f'{observable}_aligned'
60
+
61
+ # For each arch id, compute the median
62
+ data[bias_field] = data.groupby(['signal', ARC_ID_FIELD])[observable].transform('mean')
63
+
64
+ data[aligned_field] = data[observable] - data[bias_field]
65
+
66
+ return data
@@ -0,0 +1,43 @@
1
+ import pandas as pd
2
+ from .types import ConstellationId, TrackingChannel
3
+
4
+ def compute_geometry_free(data: pd.DataFrame, constellation: ConstellationId, channel_a: TrackingChannel, channel_b: TrackingChannel) -> pd.DataFrame:
5
+ """
6
+ Compute the geometry (ionospheric free combination)
7
+ """
8
+
9
+ columns = ['epoch', 'constellation', 'sat', 'channel', 'signal', 'range', 'phase', 'slip']
10
+
11
+ # Create a new dataframe
12
+ df = data[columns].copy()
13
+
14
+ # Create subsets of the DataFrame corresponding to the constellation and each of
15
+ # the channels selected to build the ionospheric combination
16
+ df_a = df[(df['constellation'] == constellation.value) & (df['channel'] == str(channel_a))]
17
+ df_b = df[(df['constellation'] == constellation.value) & (df['channel'] == str(channel_b))]
18
+
19
+ # Compute the wavelength of the two tracking channels
20
+ wl_a = channel_a.get_wavelength(constellation)
21
+ wl_b = channel_b.get_wavelength(constellation)
22
+
23
+ # Use merge to join the two tables
24
+ df_out = pd.merge(df_a, df_b, on=['epoch', 'sat'], how='inner', suffixes=('_a', '_b'))
25
+ df_out['li_m'] = df_out['phase_a'] * wl_a - df_out['phase_b'] * wl_b
26
+ df_out['pi_m'] = df_out['range_b'] - df_out['range_a']
27
+
28
+ return df_out
29
+
30
+ def compute_code_minus_carrier(data: pd.DataFrame) -> pd.DataFrame:
31
+ """
32
+ Compute the geometry (ionospheric free combination)
33
+ """
34
+
35
+ # Create a new dataframe
36
+ df = data.copy()
37
+
38
+ # Compute the wavelength
39
+ df['wl'] = df.apply(lambda row : TrackingChannel.from_string(row['channel']).get_wavelength(row['constellation']), axis=1)
40
+
41
+ df['cmc'] = df['range'] - df['phase'] * df['wl']
42
+
43
+ return df
@@ -0,0 +1,43 @@
1
+ import datetime
2
+ from typing import List, Tuple, Dict
3
+
4
+ import pandas as pd
5
+
6
+ CONSTELLATION_FIELD_STR = 'constellation'
7
+ SAT_FIELD_STR = 'sat'
8
+ EPOCH_FIELD_STR = 'epoch'
9
+ AZIMUTH_FIELD_STR = 'az'
10
+ ELEVATION_FIELD_STR = 'el'
11
+ SNR_FIELD_STR = 'snr_dbHz'
12
+
13
+
14
+ class Residuals(object):
15
+
16
+ def __init__(self, dataframe):
17
+ self.df = dataframe
18
+
19
+ def get_sat_visibility(self) -> Dict[str, List[Tuple[float, float]]]:
20
+
21
+ out = {}
22
+
23
+ for sat, group in self.df.groupby(SAT_FIELD_STR):
24
+ # FIXME this should take into account channel to avoid overwriting the info from different channels
25
+ out[sat] = list(zip(group[AZIMUTH_FIELD_STR], group[ELEVATION_FIELD_STR], group[SNR_FIELD_STR]))
26
+
27
+ return out
28
+
29
+ def get_constellation_count(self) -> List[Tuple[datetime.datetime, dict]]:
30
+
31
+ n_sats = []
32
+
33
+ for epoch, epoch_group in self.df.groupby(EPOCH_FIELD_STR):
34
+ n_sats_per_epoch = {}
35
+ for constellation, df in epoch_group.groupby(CONSTELLATION_FIELD_STR):
36
+ n_sats_per_epoch[constellation] = len(pd.unique(df[SAT_FIELD_STR]))
37
+
38
+ n_sats.append((epoch.to_pydatetime(), n_sats_per_epoch))
39
+
40
+ return n_sats
41
+
42
+ def __len__(self):
43
+ return len(self.df)
pygnss/gnss/types.py ADDED
@@ -0,0 +1,359 @@
1
+ from enum import Enum
2
+ from dataclasses import dataclass
3
+ from typing import List, Union
4
+
5
+ from pygnss.constants import SPEED_OF_LIGHT
6
+ from pygnss.decorator import deprecated
7
+
8
+
9
+ class ConstellationId(Enum):
10
+ UNKNOWN: str = '0'
11
+ GPS: str = 'G'
12
+ GALILEO: str = 'E'
13
+ GLONASS: str = 'R'
14
+ BEIDOU: str = 'C'
15
+ QZSS: str = 'J'
16
+ IRNSS: str = 'I'
17
+ SBAS: str = 'S'
18
+ LEO: str = 'L'
19
+ XONA: str = 'P'
20
+ GEELY: str = 'Y'
21
+ STARLINK: str = 'X'
22
+ ONEWEB: str = 'O'
23
+ SPIRE: str = 'V'
24
+ CENTISPACE: str = 'Z'
25
+
26
+ @staticmethod
27
+ def from_string(constellation_char: str) -> 'ConstellationId':
28
+ """
29
+ Returns the corresponding ConstellationId from input character
30
+
31
+ >>> ConstellationId.from_string('E')
32
+ <ConstellationId.GALILEO: 'E'>
33
+ """
34
+ try:
35
+ return ConstellationId(constellation_char)
36
+ except ValueError:
37
+ raise ValueError(f"Invalid constellation character: {constellation_char}")
38
+
39
+ def to_char(self):
40
+ """
41
+ Constellation representation as character
42
+
43
+ >>> c = ConstellationId.GPS
44
+ >>> c.to_char()
45
+ 'G'
46
+ """
47
+
48
+ return self.value
49
+
50
+ def __lt__(self, other):
51
+ """
52
+ Less operator for the ConstellationId class
53
+
54
+ >>> c1 = ConstellationId.GPS
55
+ >>> c2 = ConstellationId.IRNSS
56
+ >>> c1 < c2
57
+ True
58
+ >>> c2 < c1
59
+ False
60
+ >>> c2 < 0
61
+ Traceback (most recent call last):
62
+ ...
63
+ TypeError: '<' not supported between instances of 'ConstellationId' and 'int'
64
+ """
65
+
66
+ if isinstance(other, ConstellationId):
67
+ return self.value < other.value
68
+ return NotImplemented
69
+
70
+
71
+ class Band:
72
+ """
73
+ Namespace to define the supported bands (center frequency in Hz)
74
+ """
75
+
76
+ L1 = 1575420000
77
+ L2 = 1227600000
78
+ L5 = 1176450000
79
+ L6 = 1278750000
80
+ G1 = 1602000000
81
+ G1a = 1600995000
82
+ G2 = 1246000000
83
+ G2a = 1248060000
84
+ G3 = 1202025000
85
+ E1 = 1575420000
86
+ E5a = 1176450000
87
+ E5b = 1207140000
88
+ E5 = 1191795000
89
+ E6 = 1278750000
90
+ B1_2 = 1561098000
91
+ B1 = 1575420000
92
+ B2a = 1176450000
93
+ B2b = 1207140000
94
+ B2 = 1191795000
95
+ B3 = 1268520000
96
+ S = 2492028000
97
+
98
+ def __init__(self):
99
+ """
100
+ Init the class (does nothing)
101
+ """
102
+ pass
103
+
104
+ @staticmethod
105
+ def get_freq_from_rinex_band(constellation: Union[ConstellationId, str], rinex_band: int) -> int:
106
+ """
107
+ Get the band from the RINEX band number (and constellation)
108
+
109
+ >>> Band.get_freq_from_rinex_band(ConstellationId.GPS, 1)
110
+ 1575420000
111
+ >>> Band.get_freq_from_rinex_band('G', 1)
112
+ 1575420000
113
+ >>> Band.get_freq_from_rinex_band(ConstellationId.GALILEO, 5)
114
+ 1176450000
115
+ >>> Band.get_freq_from_rinex_band('E', 5)
116
+ 1176450000
117
+ >>> Band.get_freq_from_rinex_band(ConstellationId.GPS, 8)
118
+ Traceback (most recent call last):
119
+ ...
120
+ ValueError: Invalid band 8 for constellation ConstellationId.GPS
121
+ """
122
+
123
+ band = None
124
+
125
+ if isinstance(constellation, ConstellationId) == True:
126
+ constellation = constellation.value
127
+
128
+ if constellation == ConstellationId.GPS.value:
129
+ if rinex_band == 1:
130
+ band = Band.L1
131
+ elif rinex_band == 2:
132
+ band = Band.L2
133
+ elif rinex_band == 5:
134
+ band = Band.L5
135
+ elif rinex_band == 6:
136
+ band = Band.L6
137
+
138
+ elif constellation == ConstellationId.GLONASS.value:
139
+ if rinex_band == 1:
140
+ band = Band.G1
141
+ elif rinex_band == 4:
142
+ band = Band.G1a
143
+ elif rinex_band == 2:
144
+ band = Band.G2
145
+ elif rinex_band == 6:
146
+ band = Band.G2a
147
+ elif rinex_band == 3:
148
+ band = Band.G3
149
+
150
+ elif constellation == ConstellationId.GALILEO.value:
151
+ if rinex_band == 1:
152
+ band = Band.E1
153
+ elif rinex_band == 5:
154
+ band = Band.E5a
155
+ elif rinex_band == 7:
156
+ band = Band.E5b
157
+ elif rinex_band == 8:
158
+ band = Band.E5
159
+ elif rinex_band == 6:
160
+ band = Band.E6
161
+
162
+ elif constellation == ConstellationId.BEIDOU.value:
163
+ if rinex_band == 2:
164
+ band = Band.B1_2
165
+ elif rinex_band == 1:
166
+ band = Band.B1 = 1575420000
167
+ elif rinex_band == 5:
168
+ band = Band.B2a = 1176450000
169
+ elif rinex_band == 7:
170
+ band = Band.B2b = 1207140000
171
+ elif rinex_band == 8:
172
+ band = Band.B2 = 1191795000
173
+ elif rinex_band == 6:
174
+ band = Band.B3 = 1268520000
175
+
176
+ elif constellation == ConstellationId.IRNSS.value:
177
+ if rinex_band == 5:
178
+ band = Band.L5
179
+ elif rinex_band == 9:
180
+ band = Band.S
181
+
182
+ elif constellation == ConstellationId.SBAS.value:
183
+ if rinex_band == 1:
184
+ band = Band.L1
185
+ elif rinex_band == 5:
186
+ band = Band.L5
187
+
188
+ elif constellation == ConstellationId.QZSS.value:
189
+ if rinex_band == 1:
190
+ band = Band.L1
191
+ elif rinex_band == 2:
192
+ band = Band.L2
193
+ elif rinex_band == 5:
194
+ band = Band.L5
195
+ elif rinex_band == 6:
196
+ band = Band.L6
197
+
198
+ if not band:
199
+ raise ValueError(f'Invalid band {rinex_band} for constellation {ConstellationId.from_string(constellation)}')
200
+
201
+ return band
202
+
203
+
204
+ @dataclass
205
+ class TrackingChannel(object):
206
+ band: int
207
+ attribute: str
208
+
209
+ @staticmethod
210
+ @deprecated("from_rinex3_code")
211
+ def from_observable_type(observable_type: str) -> 'TrackingChannel':
212
+ """
213
+ Deprecated: This function is deprecated and will be removed in the future.
214
+ Use the from_rinex3_code() method instead.
215
+
216
+ Extract the tracking channel from a RINEX3 observable type
217
+ """
218
+
219
+ return TrackingChannel.from_rinex3_code(observable_type)
220
+
221
+ @staticmethod
222
+ def from_rinex3_code(observable_code: str) -> 'TrackingChannel':
223
+ """
224
+ Extract the tracking channel from a RINEX3 observable type
225
+
226
+ >>> TrackingChannel.from_rinex3_code('C1C')
227
+ 1C
228
+ """
229
+
230
+ return TrackingChannel.from_string(observable_code[1:])
231
+
232
+ @staticmethod
233
+ def from_string(observable_code: str) -> 'TrackingChannel':
234
+ """
235
+ Extract the tracking channel from a string that indicates the tracking
236
+ channel based on the last two characters of the corresponding RINEX3
237
+ code
238
+
239
+ >>> TrackingChannel.from_string('1C')
240
+ 1C
241
+ """
242
+
243
+ band = int(observable_code[0])
244
+ attribute = observable_code[1]
245
+ return TrackingChannel(band, attribute)
246
+
247
+ def get_frequency(self, constellation: Union[ConstellationId, str]) -> int:
248
+ """
249
+ Get the frequency (in Hz) from the tracking channel band
250
+
251
+ >>> ch = TrackingChannel(1, 'C')
252
+ >>> ch.get_frequency(ConstellationId.GPS)
253
+ 1575420000
254
+ >>> ch.get_frequency('G')
255
+ 1575420000
256
+ """
257
+ return Band.get_freq_from_rinex_band(constellation, self.band)
258
+
259
+ def get_wavelength(self, constellation: Union[ConstellationId, str]) -> Band:
260
+ """
261
+ Get the wavelength (in meters) from the tracking channel band
262
+
263
+ >>> ch = TrackingChannel(1, 'C')
264
+ >>> wl_m = ch.get_wavelength(ConstellationId.GPS)
265
+ >>> int(wl_m * 100)
266
+ 19
267
+ >>> wl_m = ch.get_wavelength('G')
268
+ >>> int(wl_m * 100)
269
+ 19
270
+ """
271
+ return SPEED_OF_LIGHT / self.get_frequency(constellation)
272
+
273
+ def __eq__(self, other: 'TrackingChannel') -> bool:
274
+ return self.band == other.band and self.attribute == other.attribute
275
+
276
+ def __lt__(self, other: 'TrackingChannel') -> bool:
277
+ return self.attribute < other.attribute if self.band == other.band else self.band < other.band
278
+
279
+ def __repr__(self):
280
+ return f'{self.band:1d}{self.attribute:1s}'
281
+
282
+ def __hash__(self) -> int:
283
+ return hash((self.band, self.attribute))
284
+
285
+
286
+ INVALID_TRACKING_CHANNEL = TrackingChannel(0, '0')
287
+
288
+
289
+ class ChannelCode(Enum):
290
+ c_1C: str = "1C"
291
+ c_5Q: str = "5Q"
292
+
293
+
294
+ @dataclass
295
+ class Satellite:
296
+ constellation: ConstellationId
297
+ prn: int
298
+
299
+ @staticmethod
300
+ def from_string(satellite: str) -> 'Satellite':
301
+ constellation = ConstellationId.from_string(satellite[0])
302
+ prn = int(satellite[1:3])
303
+ return Satellite(constellation, prn)
304
+
305
+ def __lt__(self, other: 'Satellite') -> bool:
306
+ return self.prn < other.prn if self.constellation == other.constellation else self.constellation < other.constellation
307
+
308
+ def __eq__(self, other: 'Satellite') -> bool:
309
+ return self.constellation == other.constellation and self.prn == other.prn
310
+
311
+ def __hash__(self):
312
+ return hash(self.constellation) + self.prn
313
+
314
+ def __repr__(self) -> str:
315
+ return f"{self.constellation.value:1s}{self.prn:02d}"
316
+
317
+
318
+ @dataclass
319
+ class Signal:
320
+ satellite: Satellite
321
+ channel: ChannelCode
322
+
323
+ def __repr__(self) -> str:
324
+ return f"{self.satellite}{self.channel.value}"
325
+
326
+
327
+ class GnssSystem:
328
+ constellation_id: ConstellationId
329
+ number_satellites: int
330
+ channels: List[ChannelCode]
331
+ signals: List[Signal]
332
+
333
+ def __init__(self, constellation_id: ConstellationId, number_satellites: int, channels: List[ChannelCode]):
334
+ self.constellation_id = constellation_id
335
+ self.number_satellites = number_satellites
336
+ self.channels = channels
337
+ self.signals = self.get_signals()
338
+
339
+ def get_signals(self) -> List[Signal]:
340
+ signals = []
341
+ for index in range(1, self.number_satellites + 1):
342
+ for channel in self.channels:
343
+ signals.append(Signal(Satellite(self.constellation_id, index), channel))
344
+
345
+ return signals
346
+
347
+
348
+ @dataclass
349
+ class GnssSystems:
350
+ gnss_systems: List[GnssSystem]
351
+
352
+ def get_constellations(self) -> List[str]:
353
+ return [system.constellation_id.value for system in self.gnss_systems]
354
+
355
+ def get_signals(self) -> List[Signal]:
356
+ signals = []
357
+ for system in self.gnss_systems:
358
+ signals.extend(system.get_signals())
359
+ return signals
pygnss/hatanaka.py ADDED
@@ -0,0 +1,70 @@
1
+ import gzip
2
+ import pandas as pd
3
+ import tempfile
4
+
5
+ try:
6
+ from pygnss._c_ext import _read_crx # compiled C extension for fast CRX parsing
7
+ except Exception as exc: # pragma: no cover - environment-specific
8
+ _import_err = exc
9
+
10
+ def _read_crx(*args, **kwargs):
11
+ """Fallback stub when the compiled extension is missing.
12
+
13
+ The real implementation lives in the compiled extension module
14
+ ``pygnss._c_ext``. If that extension isn't available (for example on
15
+ CI or when the package wasn't built/installed), attempting to read a
16
+ CRX file will raise a clear ImportError with instructions.
17
+ """
18
+ raise ImportError(
19
+ "pygnss C extension 'pygnss._c_ext' is not available.\n"
20
+ "To enable Hatanaka (.crx) parsing, build and install the package so\n"
21
+ "that the compiled extension is present (e.g. running 'pip install .',\n"
22
+ "or building the wheel in your CI).\n"
23
+ f"Original import error: {type(_import_err).__name__}: {_import_err}"
24
+ ) from _import_err
25
+
26
+ def to_dataframe(filename:str, station:str = "none", strict_lli: bool = True) -> pd.DataFrame:
27
+ """
28
+ Convert a Compressed (crx.gz) or uncompressed (crx) Hatanaka file into a
29
+ DataFrame
30
+
31
+ :param filename: Hatanaka [gzip compressed] filename
32
+ :param station: force station name
33
+ :param strict_lli: Mark cycle slips only when Phase LLI is 1 (as per RINEX convention).
34
+ If False, any value of Phase LLI will trigger a cycle slip flag
35
+ """
36
+
37
+ if filename.endswith('crx.gz') or filename.endswith('crx.Z') or filename.endswith('crz'):
38
+ try:
39
+ with gzip.open(filename, 'rb') as f_in:
40
+ with tempfile.NamedTemporaryFile(delete=False) as f_out:
41
+ f_out.write(f_in.read())
42
+ f_out.seek(0)
43
+ array = _read_crx(f_out.name)
44
+ except gzip.BadGzipFile:
45
+ raise ValueError(f"{filename} is not a valid gzip file.")
46
+
47
+ else:
48
+ array = _read_crx(filename)
49
+
50
+ df = pd.DataFrame(array, columns=['epoch', 'sat', 'rinex3_code', 'value', 'lli'])
51
+ df['channel'] = df['rinex3_code'].str[-2:]
52
+ df['signal'] = df['sat'] + df['channel']
53
+ MAPPING = {'C': 'range', 'L': 'phase', 'D': 'doppler', 'S': 'snr'}
54
+ df['obstype'] = df['rinex3_code'].str[0].map(lambda x: MAPPING.get(x, 'Unknown'))
55
+ df = df.pivot_table(index=['epoch', 'signal', 'sat', 'channel'], columns=['obstype'], values=['value', 'lli'])
56
+
57
+ # Remove all LLI columns except for the phase (for the cycle slips)
58
+ if strict_lli:
59
+ df['cslip'] = (df.loc[:, pd.IndexSlice['lli', 'phase']] % 2) == 1
60
+ else:
61
+ df['cslip'] = df.loc[:, pd.IndexSlice['lli', 'phase']] > 0
62
+
63
+ df.drop('lli', axis=1, inplace=True)
64
+ df.columns = [v[1] if v[0] == 'value' else v[0] for v in df.columns.values]
65
+
66
+ df.reset_index(inplace=True)
67
+
68
+ df['station'] = station
69
+
70
+ return df