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/ionex.py ADDED
@@ -0,0 +1,410 @@
1
+ import argparse
2
+ import datetime
3
+ import math
4
+ import os
5
+ from typing import List
6
+
7
+ import nequick
8
+ import numpy as np
9
+
10
+
11
+ from .iono import gim
12
+ from .decorator import read_contents
13
+
14
+
15
+ def load(filename: str, gim_handler: gim.GimHandler):
16
+ """
17
+ Load an IONEX file and process its contents using the provided GIM handler.
18
+
19
+ :param filename: The path to the IONEX file to load.
20
+ :param gim_handler: An instance of a GIM handler to process the GIMs read from the file.
21
+ :return: The result of processing the IONEX file.
22
+ """
23
+
24
+ return _load(filename, gim_handler)
25
+
26
+
27
+ def write(filename: str, gims: List[gim.Gim], gim_type: gim.GimType,
28
+ pgm: str = "pygnss", runby: str = "pygnss", comment_lines: List[str] = []) -> None:
29
+ """
30
+ Write a list of GIMs to an IONEX file.
31
+
32
+ :param filename: The path to the IONEX file to write.
33
+ :param gims: A list of GIM objects to write to the file.
34
+ """
35
+
36
+ EXPONENT = -1
37
+ FACTOR = math.pow(10, EXPONENT)
38
+
39
+ if not gims:
40
+ raise ValueError("The list of GIMs is empty. Cannot write to the IONEX file.")
41
+
42
+ # Extract latitudes and longitudes from the first GIM
43
+ latitudes = gims[0].latitudes
44
+ longitudes = gims[0].longitudes
45
+
46
+ lon1 = longitudes[0]
47
+ lon2 = longitudes[-1]
48
+ dlon = longitudes[1] - longitudes[0]
49
+
50
+ # Ensure all GIMs have the same latitudes and longitudes
51
+ for gim_obj in gims:
52
+ if np.array_equal(gim_obj.latitudes, latitudes) == False or \
53
+ np.array_equal(gim_obj.longitudes, longitudes) == False:
54
+ raise ValueError("All GIMs must have the same latitudes and longitudes.")
55
+
56
+ # Sort the IONEX files by epoch
57
+ gims.sort(key=lambda gim: gim.epoch)
58
+
59
+ first_epoch = gims[0].epoch
60
+ last_epoch = gims[-1].epoch
61
+ n_maps = len(gims)
62
+
63
+ lat_0 = gims[0].latitudes[0]
64
+ lat_1 = gims[0].latitudes[-1]
65
+ dlat = gims[0].latitudes[1] - gims[0].latitudes[0]
66
+
67
+ # We will print the map from North to South, therefore check if the
68
+ # latitudes need to be reversed
69
+ latitude_reversal = lat_0 < lat_1
70
+ if latitude_reversal:
71
+ lat_0 = gims[0].latitudes[-1]
72
+ lat_1 = gims[0].latitudes[0]
73
+ dlat = gims[0].latitudes[0] - gims[0].latitudes[1]
74
+
75
+ lon_0 = gims[0].longitudes[0]
76
+ lon_1 = gims[0].longitudes[-1]
77
+ dlon = gims[0].longitudes[1] - gims[0].longitudes[0]
78
+
79
+ doc = ""
80
+
81
+ # Header
82
+ today = datetime.datetime.now()
83
+ epoch_str = today.strftime('%d-%b-%y %H:%M')
84
+
85
+ doc +=" 1.0 IONOSPHERE MAPS NEQUICK IONEX VERSION / TYPE\n"
86
+ doc +=f"{pgm[:20]:<20}{runby[:20]:<20}{epoch_str[:20]:<20}PGM / RUN BY / DATE\n"
87
+
88
+ for comment_line in comment_lines:
89
+ doc += f"{comment_line[:60]:<60}COMMENT\n"
90
+
91
+ doc += first_epoch.strftime(" %Y %m %d %H %M %S EPOCH OF FIRST MAP\n")
92
+ doc += last_epoch.strftime(" %Y %m %d %H %M %S EPOCH OF LAST MAP\n")
93
+ doc += " 0 INTERVAL\n"
94
+ doc += f"{n_maps:>6} # OF MAPS IN FILE\n"
95
+ doc += " NONE MAPPING FUNCTION\n"
96
+ doc += " 0.0 ELEVATION CUTOFF\n"
97
+ doc += " OBSERVABLES USED\n"
98
+ doc += " 6371.0 BASE RADIUS\n"
99
+ doc += " 2 MAP DIMENSION\n"
100
+ doc += " 450.0 450.0 0.0 HGT1 / HGT2 / DHGT\n"
101
+ doc += f" {lat_0:6.1f}{lat_1:6.1f}{dlat:6.1f} LAT1 / LAT2 / DLAT\n"
102
+ doc += f" {lon_0:6.1f}{lon_1:6.1f}{dlon:6.1f} LON1 / LON2 / DLON\n"
103
+ doc += f"{EXPONENT:>6} EXPONENT\n"
104
+ doc += " END OF HEADER\n"
105
+
106
+ # Write each GIM
107
+ for i_map, gim_obj in enumerate(gims):
108
+
109
+ doc += f"{i_map+1:>6} START OF {gim_type.name} MAP\n"
110
+
111
+ # Write the epoch
112
+ epoch = gim_obj.epoch
113
+ doc += epoch.strftime(" %Y %m %d %H %M %S EPOCH OF CURRENT MAP\n")
114
+
115
+
116
+ for i, _ in enumerate(latitudes):
117
+
118
+ if latitude_reversal:
119
+ i = len(latitudes) - 1 - i
120
+
121
+ lat = latitudes[i]
122
+
123
+ doc += f" {lat:6.1f}{lon1:6.1f}{lon2:6.1f}{dlon:6.1f} 450.0 LAT/LON1/LON2/DLON/H\n"
124
+
125
+ lat_row = gim_obj.vtec_values[i]
126
+ for j in range(0, len(longitudes), 16):
127
+ doc += "".join(f"{round(vtec / FACTOR):5d}" for vtec in lat_row[j:j+16]) + "\n"
128
+
129
+ doc += f"{i_map+1:>6} END OF {gim_type.name} MAP\n"
130
+
131
+ # Tail
132
+ doc += " END OF FILE\n"
133
+
134
+ with open(filename, "wt") as fh:
135
+ fh.write(doc)
136
+
137
+
138
+
139
+ def diff(filename_lhs: str, filename_rhs: str, output_file: str, pgm="pygnss.ionex") -> None:
140
+ """
141
+ Compute the difference between two IONEX files and write the result in IONEX format
142
+ """
143
+
144
+ gim_handler_lhs = gim.GimHandlerArray()
145
+ gim_handler_rhs = gim.GimHandlerArray()
146
+
147
+ load(filename_lhs, gim_handler=gim_handler_lhs)
148
+ load(filename_rhs, gim_handler=gim_handler_rhs)
149
+
150
+ gim_diffs = gim.subtract_gims(gim_handler_lhs.vtec_gims, gim_handler_rhs.vtec_gims)
151
+
152
+ comment_lines = [
153
+ "This IONEX file contains the differences of VTEC values,",
154
+ "computed as vtec_left - vtec_right, where:",
155
+ f"- vtec_left: {os.path.basename(filename_lhs)}",
156
+ f"- vtec_right: {os.path.basename(filename_rhs)}",
157
+ ]
158
+
159
+ write(output_file, gim_diffs, gim.GimType.TEC, pgm=pgm, comment_lines=comment_lines)
160
+
161
+
162
+ @read_contents
163
+ def _load(doc: str, gim_handler: gim.GimHandler):
164
+ """
165
+ Parse the contents of an IONEX file and process each GIM using the provided handler.
166
+
167
+ :param doc: The contents of the IONEX file as a string.
168
+ :param gim_handler: An instance of a GIM handler to process the GIMs read from the file.
169
+ :raises ValueError: If the file is not a valid IONEX file or contains unsupported features.
170
+ """
171
+
172
+ lines = doc.splitlines()
173
+ n_lines = len(lines)
174
+ i_body = 0
175
+
176
+ latitudes_deg: List[float] = []
177
+ longitudes_deg: List[float] = []
178
+
179
+ header_mark_found = False
180
+
181
+ # Header
182
+ for i in range(n_lines):
183
+
184
+ line = lines[i]
185
+
186
+ if line[60:].startswith('IONEX VERSION / TYPE'):
187
+ header_mark_found = True
188
+
189
+ elif line[60:].startswith('HGT1 / HGT2 / DHGT'):
190
+ _hgt1, _hgt2, _dhgt = [float(v) for v in line.split()[:3]]
191
+ if _dhgt != 0.0:
192
+ raise ValueError('Multi-layer Ionex files not supported')
193
+
194
+ elif line[60:].startswith('LAT1 / LAT2 / DLAT'):
195
+ _lat1, _lat2, _dlat = [float(v) for v in line.split()[:3]]
196
+ latitudes_deg = np.arange(_lat1, _lat2 + _dlat/2, _dlat)
197
+
198
+ elif line[60:].startswith('LON1 / LON2 / DLON'):
199
+ _lon1, _lon2, _dlon = [float(v) for v in line.split()[:3]]
200
+ longitudes_deg = np.arange(_lon1, _lon2 + _dlon/2, _dlon)
201
+
202
+ elif line[60:].startswith('EXPONENT'):
203
+ exponent: float = float(line[:6])
204
+
205
+ elif line[60:].startswith('END OF HEADER'):
206
+ i_body = i + 1
207
+ break
208
+
209
+ if header_mark_found is False:
210
+ raise ValueError(f'The input does not seem to be a IONEX file [ {doc[:10]} ]')
211
+
212
+ n_lines_lat_row = int(np.ceil(len(longitudes_deg) / 16))
213
+
214
+ current_gim = None
215
+ gim_type = None
216
+
217
+ # Body
218
+ for i in range(i_body, n_lines):
219
+
220
+ line = lines[i]
221
+
222
+ if line[60:].startswith('START OF TEC MAP'):
223
+
224
+ i_lat_row = 0
225
+
226
+ gim_type = gim.GimType.TEC
227
+
228
+ elif line[60:].startswith('START OF RMS MAP'):
229
+
230
+ i_lat_row = 0
231
+
232
+ gim_type = gim.GimType.RMS
233
+
234
+ elif line[60:].startswith('EPOCH OF CURRENT MAP'):
235
+
236
+ # Initialize map
237
+ current_gim = gim.Gim(_parse_ionex_epoch(line),
238
+ longitudes_deg, latitudes_deg,
239
+ [[0] * len(longitudes_deg)] * len(latitudes_deg))
240
+
241
+ elif line[60:].startswith('LAT/LON1/LON2/DLON/H'):
242
+
243
+ lat_row = ''.join([lines[i + 1 + j] for j in range(n_lines_lat_row)])
244
+
245
+ values = np.array([float(v) for v in lat_row.split()])
246
+
247
+ i += n_lines_lat_row
248
+
249
+ current_gim.vtec_values[i_lat_row] = (values * np.power(10, exponent)).tolist()
250
+
251
+ i_lat_row = i_lat_row + 1
252
+
253
+ # If end of map reached, send them to appropriate processor
254
+ elif line[60:].startswith('END OF TEC MAP') or line[60:].startswith('END OF RMS MAP'):
255
+ gim_handler.process(current_gim, gim_type)
256
+
257
+
258
+ def _parse_ionex_epoch(ionex_line: str) -> datetime.datetime:
259
+ """
260
+ Parse the epoch from a IONEX line
261
+
262
+ >>> _parse_ionex_epoch(" 2024 12 11 0 0 14 EPOCH OF FIRST MAP")
263
+ datetime.datetime(2024, 12, 11, 0, 0, 14)
264
+ >>> _parse_ionex_epoch(" 2024 12 11 0 0 0 EPOCH OF CURRENT MAP")
265
+ datetime.datetime(2024, 12, 11, 0, 0)
266
+ """
267
+
268
+ _HEADER_EPOCH_FORMAT = " %Y %m %d %H %M %S"
269
+
270
+ return datetime.datetime.strptime(ionex_line[:36], _HEADER_EPOCH_FORMAT)
271
+
272
+
273
+
274
+ class NeQuickGimHandlerArray(gim.GimHandler):
275
+ """
276
+ Handler to store the incoming GIMs in arrays
277
+ """
278
+
279
+ def __init__(self):
280
+ self.vtec_gims: List[gim.Gim] = []
281
+
282
+ def process(self, nequick_gim: nequick.Gim):
283
+ """
284
+ Process a GIM file
285
+ """
286
+
287
+ incoming_gim = gim.Gim(nequick_gim.epoch,
288
+ nequick_gim.longitudes, nequick_gim.latitudes,
289
+ nequick_gim.vtec_values)
290
+
291
+ self.vtec_gims.append(incoming_gim)
292
+
293
+
294
+ def cli():
295
+ """
296
+ This function allows users to compute the difference between two IONEX files
297
+ or between an IONEX file and the NeQuick model (with three coefficients),
298
+ and save the result in a new IONEX file.
299
+
300
+
301
+ Example:
302
+ Compute the difference between two IONEX files:
303
+ $ python ionex.py file1.ionex file2.ionex output.ionex
304
+
305
+ Compute the difference between an IONEX file and the NeQuick model:
306
+ $ python ionex.py file1.ionex output.ionex --nequick 0.123 0.456 0.789
307
+ """
308
+ parser = argparse.ArgumentParser(description=cli.__doc__,
309
+ formatter_class=argparse.RawDescriptionHelpFormatter )
310
+
311
+ parser.add_argument(
312
+ "lhs",
313
+ type=str,
314
+ help="Path to the first IONEX file (left-hand side).",
315
+ )
316
+
317
+ parser.add_argument(
318
+ "output",
319
+ type=str,
320
+ help="Path to the output IONEX file where the differences will be saved.",
321
+ )
322
+
323
+ rhs = parser.add_mutually_exclusive_group(required=True)
324
+
325
+ rhs.add_argument(
326
+ "--rhs",
327
+ type=str,
328
+ help="Path to the second IONEX file (right-hand side). If not provided, --nequick must be specified.",
329
+ )
330
+
331
+ rhs.add_argument(
332
+ "--nequick",
333
+ type=float,
334
+ nargs=3,
335
+ metavar=("AZ0", "AZ1", "AZ2"),
336
+ help="Use the NeQuick model to compare against the 'lhs' IONEX (instead of another IONEX file). "
337
+ "Specify the three NeQuick coefficients (az0, az1, az2).",
338
+ )
339
+
340
+ parser.add_argument(
341
+ "--nequick-ionex",
342
+ type=str,
343
+ default=None,
344
+ required=False,
345
+ metavar='<file>',
346
+ help="Specify a filename to store the NeQuick model in IONEX format",
347
+ )
348
+
349
+ args = parser.parse_args()
350
+
351
+ PGM = "ionex_diff"
352
+
353
+ # Validate input arguments
354
+ if args.rhs is None and args.nequick is None:
355
+ parser.error("Either a second IONEX file (rhs) or the '--nequick' option must be provided.")
356
+
357
+ if args.rhs is not None and args.nequick is not None:
358
+ parser.error("You cannot specify both a second IONEX file (rhs) and the '--nequick' option.")
359
+
360
+ if args.nequick_ionex is not None and args.nequick is None:
361
+ parser.error("Cannot output the IONEX file with the NeQuick model without the '--nequick' option.")
362
+
363
+ # Process the lhs IONEX file
364
+ gim_handler_lhs = gim.GimHandlerArray()
365
+ load(args.lhs, gim_handler=gim_handler_lhs)
366
+
367
+ # Add comments to the output file
368
+ comment_lines = [
369
+ "This IONEX file contains the differences of VTEC values,",
370
+ "computed as vtec_left - vtec_right, where:",
371
+ f"- vtec_left: {os.path.basename(args.lhs)}"
372
+ ]
373
+
374
+ # Process the rhs input (either an IONEX file or NeQuick coefficients)
375
+ gim_handler_rhs = None
376
+ if args.rhs:
377
+ gim_handler_rhs = gim.GimHandlerArray()
378
+ # Load the second IONEX file
379
+ load(args.rhs, gim_handler=gim_handler_rhs)
380
+ comment_lines += [f"- vtec_right: {os.path.basename(args.rhs)}"]
381
+
382
+ else:
383
+ gim_handler_rhs = NeQuickGimHandlerArray()
384
+ # Generate GIMs using NeQuick coefficients
385
+ coeffs = args.nequick
386
+ nequick_desc = ["NeQuick model", f" az0={coeffs[0]}", f" az1={coeffs[1]}", f" az2={coeffs[2]}"]
387
+ comment_lines += [f"- vtec_right: {nequick_desc[0]}"] + nequick_desc[1:]
388
+
389
+ for ionex_gim in gim_handler_lhs.vtec_gims:
390
+
391
+ nequick.to_gim(nequick.Coefficients(*coeffs),
392
+ ionex_gim.epoch,
393
+ latitudes = ionex_gim.latitudes,
394
+ longitudes = ionex_gim.longitudes,
395
+ gim_handler= gim_handler_rhs)
396
+
397
+ # Compute the difference
398
+ gim_diffs = gim.subtract_gims(gim_handler_lhs.vtec_gims, gim_handler_rhs.vtec_gims)
399
+
400
+
401
+ # Write the result to the output file
402
+ write(args.output, gim_diffs, gim.GimType.TEC, pgm=PGM, comment_lines=comment_lines)
403
+
404
+ if args.nequick_ionex is not None:
405
+ comment_lines = [
406
+ "TEC values generated with the " + nequick_desc[0]
407
+ ] + nequick_desc[1:]
408
+
409
+ write(args.nequick_ionex, gim_handler_lhs.vtec_gims, gim.GimType.TEC, pgm=PGM,
410
+ comment_lines=comment_lines )
@@ -0,0 +1,47 @@
1
+ import numpy as np
2
+
3
+
4
+ def compute_pierce_point(receiver_ecef, sat_ecef, iono_height_m=350000.0):
5
+ """
6
+ Compute the ionospheric pierce point given receiver and satellite positions.
7
+
8
+ Parameters:
9
+ receiver_ecef : array-like
10
+ ECEF coordinates of the receiver (x, y, z) in meters.
11
+ sat_ecef : array-like
12
+ ECEF coordinates of the satellite (x, y, z) in meters.
13
+ iono_height_m : float
14
+ Height of the ionospheric shell in meters.
15
+
16
+ Returns:
17
+ pierce_point_ecef : array-like
18
+ ECEF coordinates of the pierce point (x, y, z) in meters.
19
+
20
+ >>> receiver_ecef = [0, 0, 6470000.0]
21
+ >>> sat_ecef = [0.0, 0.0, 20000000.0]
22
+ >>> pierce_point = compute_pierce_point(receiver_ecef, sat_ecef)
23
+ >>> np.round(pierce_point, 3)
24
+ array([ 0. , 0. , 6829466.77])
25
+ """
26
+
27
+ receiver_ecef = np.array(receiver_ecef)
28
+ sat_ecef = np.array(sat_ecef)
29
+
30
+ # Vector from receiver to satellite
31
+ rho = sat_ecef - receiver_ecef
32
+ rho_norm = np.linalg.norm(rho)
33
+
34
+ # Unit vector in the direction from receiver to satellite
35
+ rho_unit = rho / rho_norm
36
+
37
+ # Compute the distance from the Earth's center to the pierce point
38
+ r_receiver = np.linalg.norm(receiver_ecef)
39
+ r_iono = r_receiver + iono_height_m
40
+
41
+ # Compute the distance along the line of sight to the pierce point
42
+ d = (r_iono**2 - r_receiver**2) / (2.0 * np.dot(receiver_ecef, rho_unit))
43
+
44
+ # Compute the pierce point coordinates
45
+ pierce_point_ecef = receiver_ecef + d * rho_unit
46
+
47
+ return pierce_point_ecef
pygnss/iono/chapman.py ADDED
@@ -0,0 +1,35 @@
1
+ """
2
+ Module to compute the Chapman ionospheric model.
3
+
4
+ This module provides functions to compute the Chapman ionospheric model,
5
+ which is used to estimate the electron density in the ionosphere based on
6
+ solar zenith angle and other parameters.
7
+ """
8
+
9
+ import numpy as np
10
+
11
+ HEIGHTS_KM = np.arange(60, 1000, 10)
12
+
13
+ def compute_profile(hmF2_km: float, NmF2: float, H_km: float, zenith_angle_deg: float, heights_km: float | np.ndarray =HEIGHTS_KM) -> float | np.ndarray:
14
+ """
15
+ Compute the Chapman ionospheric model.
16
+
17
+ Parameters:
18
+ zenith_angle (float or np.ndarray): Solar zenith angle, the angle between the vertical and the direction to the Sun.
19
+ hmF2_km (float): Height of the F2 layer in kilometers.
20
+ NmF2 (float): Peak electron density at hmF2 in electrons/m^3.
21
+ H_km (float): Scale height in kilometers (typical values between 30 and 50 km).
22
+ heights_km (np.ndarray, optional): Heights at which to compute the electron density.
23
+ If None, defaults to a range from 0 to 100 km.
24
+
25
+ Returns:
26
+ float or np.ndarray: Computed electron density based on the Chapman model.
27
+ """
28
+ # Convert zenith angle from degrees to radians
29
+ zenith_angle_rad = np.radians(zenith_angle_deg)
30
+
31
+ # Include scale height
32
+ z = (heights_km - hmF2_km) / H_km
33
+
34
+ # Compute the Chapman function
35
+ return NmF2 * np.exp( 0.5 * (1 - z - np.exp(-z) / np.cos(zenith_angle_rad)))
pygnss/iono/gim.py ADDED
@@ -0,0 +1,131 @@
1
+ from abc import ABC, abstractmethod
2
+ from dataclasses import dataclass
3
+ import datetime
4
+ import enum
5
+ from typing import List
6
+
7
+ import numpy as np
8
+
9
+
10
+ class GimType(enum.Enum):
11
+ """
12
+ Type of Global Ionospheric Map (VTEC, RMS)
13
+ """
14
+
15
+ TEC = enum.auto()
16
+ RMS = enum.auto()
17
+
18
+
19
+ @dataclass
20
+ class Gim():
21
+ epoch: datetime.datetime
22
+ longitudes: List[float]
23
+ latitudes: List[float]
24
+ vtec_values: List[List[float]] # Grid of VTEC values n_latitudes (rows) x n_longitudes (columns)
25
+
26
+ def __sub__(self, other: 'Gim') -> 'Gim':
27
+ """
28
+ Subtract the VTEC values of another Gim from this Gim
29
+
30
+ :param other: The Gim to subtract.
31
+
32
+ :return: A new Gim with the resulting VTEC values.
33
+ """
34
+
35
+ return subtract(self, other)
36
+
37
+
38
+ class GimHandler(ABC):
39
+
40
+ @abstractmethod
41
+ def process(self, gim: Gim, type: GimType):
42
+ """
43
+ Process a GIM file
44
+ """
45
+ pass
46
+
47
+ class GimHandlerArray(GimHandler):
48
+ """
49
+ Handler to store the incoming GIMs in arrays
50
+ """
51
+
52
+ def __init__(self):
53
+ self.vtec_gims: List[Gim] = []
54
+ self.rms_gims: List[Gim] = []
55
+
56
+ def process(self, gim: Gim, type: GimType):
57
+ """
58
+ Process a GIM file
59
+ """
60
+
61
+ if type == GimType.TEC:
62
+ self.vtec_gims.append(gim)
63
+
64
+ elif type == GimType.RMS:
65
+ self.rms_gims.append(gim)
66
+
67
+ else:
68
+ raise ValueError(f'Gim Type [ {type} ] not supported')
69
+
70
+
71
+ def subtract(lhs: Gim, rhs: Gim) -> Gim:
72
+ """
73
+ Subtract the VTEC values of two GIMs (lhs - rhs)
74
+
75
+ :param lhs: Left-hand operand
76
+ :param rhs: Right-hand operand
77
+
78
+ :return: A new Gim with the resulting difference of VTEC values.
79
+
80
+ :raises ValueError: If the dimensions of the GIMs do not match.
81
+ """
82
+
83
+ if lhs.epoch != rhs.epoch:
84
+ raise ValueError(f"Epochs of both GIMs differ: {lhs.epoch} != {rhs.epoch}")
85
+
86
+ if np.array_equal(lhs.latitudes, rhs.latitudes) == False:
87
+ raise ValueError("Latitudes do not match between the two GIMs.")
88
+
89
+ if np.array_equal(lhs.longitudes, rhs.longitudes) == False:
90
+ raise ValueError("Longitude do not match between the two GIMs.")
91
+
92
+ vtec_diff = np.subtract(lhs.vtec_values, rhs.vtec_values)
93
+
94
+ return Gim(
95
+ epoch=lhs.epoch, # Keep the epoch of the first Gim
96
+ longitudes=lhs.longitudes,
97
+ latitudes=lhs.latitudes,
98
+ vtec_values=vtec_diff.tolist(),
99
+ )
100
+
101
+
102
+ def subtract_gims(lhs: List[Gim], rhs: List[Gim]) -> List[Gim]:
103
+ """
104
+ Subtract the VTEC values of two lists of GIMs (lhs - rhs).
105
+
106
+ The subtraction is performed only for GIMs with matching epochs, latitudes, and longitudes.
107
+ If a GIM in one list does not have a matching epoch in the other list, it is ignored.
108
+
109
+ :param lhs: The first list of GIMs (left-hand operand).
110
+ :param rhs: The second list of GIMs (right-hand operand).
111
+ :return: A list of GIMs resulting from the subtraction.
112
+ :raises ValueError: If latitudes or longitudes do not match for matching epochs.
113
+ """
114
+ result = []
115
+
116
+ # Create a dictionary for quick lookup of GIMs in the rhs list by epoch
117
+ rhs_dict = {gim.epoch: gim for gim in rhs}
118
+
119
+ for gim_lhs in lhs:
120
+
121
+ # Check if there is a matching epoch in the rhs list
122
+ if gim_lhs.epoch in rhs_dict:
123
+ gim_rhs = rhs_dict[gim_lhs.epoch]
124
+
125
+ try:
126
+ result.append(gim_lhs - gim_rhs)
127
+ except ValueError as e:
128
+ raise ValueError(f"Error subtracting GIMs for epoch {gim_lhs.epoch}: {e}")
129
+
130
+ return result
131
+
pygnss/logger.py ADDED
@@ -0,0 +1,70 @@
1
+
2
+ import logging
3
+ import os
4
+
5
+ # TODO Move logging configuration to log.ini
6
+ # TODO Use slack handler for notifying error to slack rokubun group
7
+
8
+ FORMAT = '%(asctime)s - %(levelname)-8s - %(message)s'
9
+ EPOCH_FORMAT = "%Y-%m-%d %H:%M:%S"
10
+
11
+ logger = logging.getLogger(__name__)
12
+ logger.setLevel(level=os.environ.get("LOGLEVEL", "INFO"))
13
+ console_handler = logging.StreamHandler()
14
+ formatter = logging.Formatter(FORMAT)
15
+ console_handler.setFormatter(formatter)
16
+ logger.addHandler(console_handler)
17
+
18
+
19
+ class LevelLogFilter(object):
20
+ def __init__(self, levels):
21
+ self.__levels = levels
22
+
23
+ def filter(self, record):
24
+ return record.levelno in self.__levels
25
+
26
+
27
+ def debug(message):
28
+ logger.debug(message)
29
+
30
+
31
+ def info(message):
32
+ logger.info(message)
33
+
34
+
35
+ def warning(message):
36
+ logger.warning(message)
37
+
38
+
39
+ def error(message):
40
+ logger.error(message)
41
+
42
+
43
+ def critical(message, exception=None):
44
+ logger.critical(message, exc_info=exception)
45
+
46
+
47
+ def exception(message, exception):
48
+ logger.critical(message, exc_info=exception)
49
+ raise exception
50
+
51
+
52
+ def log(level, message):
53
+ logger.log(logging._nameToLevel[level], message)
54
+
55
+
56
+ def set_level(level):
57
+ logger.setLevel(level=level)
58
+
59
+
60
+ def setFileHandler(filename):
61
+ handler = logging.FileHandler(filename)
62
+ handler.setLevel(logging.DEBUG)
63
+ handler.setFormatter(logging.Formatter(FORMAT))
64
+ logger.addHandler(handler)
65
+ return handler
66
+
67
+
68
+ def unsetHandler(handler):
69
+ handler.close()
70
+ logger.removeHandler(handler)