SpUD-io 1.0.0__tar.gz

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.
spud_io-1.0.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2023 crandallowen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
spud_io-1.0.0/PKG-INFO ADDED
@@ -0,0 +1,13 @@
1
+ Metadata-Version: 2.4
2
+ Name: SpUD_io
3
+ Version: 1.0.0
4
+ Summary: A package that interprets Spectrum XXI output files and uploads data to the NOAA Spectrum Usage Database application
5
+ Author-email: Owen Crandall <crandallowen@gmail.com>
6
+ License-Expression: MIT
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Operating System :: OS Independent
9
+ Requires-Python: >=3.10
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Requires-Dist: psycopg[c]
13
+ Dynamic: license-file
File without changes
@@ -0,0 +1,22 @@
1
+ [build-system]
2
+ requires = ["setuptools"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "SpUD_io"
7
+ version = "1.0.0"
8
+ authors = [
9
+ {name = "Owen Crandall", email = "crandallowen@gmail.com"},
10
+ ]
11
+ description = "A package that interprets Spectrum XXI output files and uploads data to the NOAA Spectrum Usage Database application"
12
+ readme = "README.md"
13
+ requires-python = ">=3.10"
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "Operating System :: OS Independent",
17
+ ]
18
+ license = "MIT"
19
+ license-files = ["LICEN[CS]E*"]
20
+ dependencies = [
21
+ "psycopg[c]",
22
+ ]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,764 @@
1
+ # RFA.py
2
+ # This file is a module that allows a user to more easily manipulate output data from SXXI. This module contains a class declaration
3
+ # for an RFA object, useful methods for manipulating RFA objects, a function to import records in either GMF or SFAF format,
4
+ # functions for exporting the data in particular formats, and functions for converting SXXI values into more readable formats.
5
+ # The current version of this module should be considered an 'alpha' version as it contains minimal documentation and error handling.
6
+
7
+ import os
8
+ import datetime
9
+ import re
10
+ from datetime import date
11
+ import sys
12
+
13
+ # An RFA object that represents a Radio Frequency Assignment from SXXI using fields derived from both GMF and SFAF format
14
+ # Some SXXI columns can be repeated to represent multiple values for a particular column which are represented as lists
15
+ # in this object declaration. Columns that can only ever have one value are represented as strings.
16
+ class RFA:
17
+ def __init__(self):
18
+ self.serial_number = None
19
+ self.agency_action_number = None
20
+ self.classification = None
21
+ self.bureau = None
22
+ self.agency = None
23
+ self.record_type = None
24
+ self.main_function_id = None
25
+ self.intermediate_function_id = None
26
+ self.detailed_function_id = None
27
+ self.irac_docket_number = []
28
+ self.docket_number_old = None
29
+ self.sxxi_frequency = None
30
+ self.sxxi_frequency_upper_limit = None
31
+ self.sxxi_excluded_frequency_bands = []
32
+ self.sxxi_paired_frequency = []
33
+ self.gmf_time = None
34
+ self.irac_notes = []
35
+ self.free_text = []
36
+ self.misc_agency_data = []
37
+ self.fas_agenda = []
38
+ self.supplementary_details = ''
39
+ self.point_of_contact = None
40
+ self.poc_name = None
41
+ self.poc_phone_number = None
42
+ self.poc_verification_date = None
43
+ self.joint_agency_names = []
44
+ self.international_coordination_id = None
45
+ self.canadian_coordination_comments = []
46
+ self.mexican_coordination_comments = []
47
+ self.user_net_code = []
48
+ # Emission group
49
+ self.station_class = []
50
+ self.emission_designator = []
51
+ self.sxxi_power = []
52
+ # These two are not used in DoC records
53
+ self.effective_radiated_power = []
54
+ self.power_augmentation = []
55
+ # Transmitter group
56
+ self.tx_state_country_code = None
57
+ self.tx_antenna_location = None
58
+ self.tx_station_control = None
59
+ self.tx_station_call_sign = None
60
+ self.tx_antenna_latitude = None
61
+ self.tx_antenna_longitude = None
62
+ self.tx_authorized_radius = None
63
+ self.tx_inclination_angle = None
64
+ self.tx_apogee = None
65
+ self.tx_perigee = None
66
+ self.tx_period_of_orbit = None
67
+ self.tx_number_of_satellites = None
68
+ self.tx_power_density = None
69
+ self.tx_equipment_nomenclature = []
70
+ self.tx_system_name = None
71
+ self.tx_number_of_stations = None
72
+ self.tx_ots_equipment = None
73
+ self.tx_radar_tunability = None
74
+ self.tx_pulse_duration = []
75
+ self.tx_pulse_repetition_rate = []
76
+ self.tx_antenna_name = None
77
+ self.tx_antenna_nomenclature = None
78
+ self.tx_antenna_gain = []
79
+ self.tx_antenna_elevation = None
80
+ self.tx_antenna_feed_point_height = None
81
+ self.tx_antenna_horizontal_beamwidth = None
82
+ self.tx_antenna_azimuth = None
83
+ self.tx_antenna_orientation = None
84
+ self.tx_antenna_polarization = None
85
+ self.tx_jsc_area_code = None
86
+ # Receiver group
87
+ self.rx_state_country_code = []
88
+ self.rx_antenna_location = []
89
+ self.rx_control_id_and_server_system_id = []
90
+ self.rx_antenna_latitude = []
91
+ self.rx_antenna_longitude = []
92
+ self.rx_station_call_sign = []
93
+ self.rx_authorized_radius = []
94
+ self.rx_repeater_indicator = []
95
+ self.rx_inclination_angle = []
96
+ self.rx_apogee = []
97
+ self.rx_perigee = []
98
+ self.rx_period_of_orbit = []
99
+ self.rx_number_of_satellites = []
100
+ self.rx_equipment_nomenclature = []
101
+ self.rx_antenna_name = []
102
+ self.rx_antenna_nomenclature = []
103
+ self.rx_antenna_gain = []
104
+ self.rx_antenna_elevation = []
105
+ self.rx_antenna_feed_point_height = []
106
+ self.rx_antenna_horizontal_beamwidth = []
107
+ self.rx_antenna_azimuth = []
108
+ self.rx_antenna_orientation = []
109
+ self.rx_antenna_polarization = []
110
+ self.rx_jsc_area_code = []
111
+ # Area authorization
112
+ self.authorized_area_both = []
113
+ self.rx_authorized_area = []
114
+ self.tx_authorized_area = []
115
+ self.excepted_states_both = []
116
+ self.rx_excepted_states = []
117
+ self.tx_excepted_states = []
118
+ self.authorized_states_both = []
119
+ self.rx_authorized_states = []
120
+ self.tx_authorized_states = []
121
+ # Dates
122
+ self.last_transaction_date = None
123
+ self.revision_date = None
124
+ self.authorization_date = None
125
+ self.expiration_date = None
126
+ self.review_date = None
127
+ self.entry_date = None
128
+ self.receipt_date = None
129
+ # Likely not entered into database for now
130
+ # self.foi_exempt = None
131
+ # self.approval_authority = None
132
+ # self.data_source = None
133
+ # self.routine_agenda_item = None
134
+ # custom columns
135
+ self.power_w = []
136
+ self.center_frequency = None
137
+ self.max_power = None
138
+ self.frequency_band = None
139
+ self.bandwidth = None
140
+ self.tx_lat_long = None
141
+ self.rx_lat_long = []
142
+
143
+ # This method overrides the default str() function to print the RFA's serial number.
144
+ def __str__(self):
145
+ return f'<RFA: {self.serial_number}>'
146
+
147
+ def __getitem__(self, item):
148
+ return getattr(self, item)
149
+
150
+ # This method is used to convert an RFA object into a string in .csv format.
151
+ # Any fields that contain commas will have all commas converted into semicolons to be compatible with .csv format.
152
+ # Any list-type fields will be joined into a single field seperated by '|' characters.
153
+ # Any date-type fields will be output in mm/dd/yyyy format
154
+ def toCSVRow(self):
155
+ row = []
156
+ for value in self.__dict__.values():
157
+ if isinstance(value, list):
158
+ value = '|'.join(map(str, value))
159
+ elif isinstance(value, date):
160
+ value = value.strftime('%m/%d/%Y')
161
+ elif value is None:
162
+ value = ''
163
+ row.append(value.replace(',',';'))
164
+ return ','.join(row)
165
+
166
+ # def toCSVRow_formatted(self):
167
+ # row = []
168
+ # for key, value in self.__dict__.items():
169
+ # if key in ['frequency', 'frequency_upper_limit', 'excluded_frequency_bands', 'paired_frequency']:
170
+ # if isinstance(value, list):
171
+ # value = '|'.join(map(formatFrequency, value))
172
+ # else:
173
+ # value = formatFrequency(value)
174
+ # elif key == 'power':
175
+ # value = '|'.join(map(formatPower, value))
176
+ # elif isinstance(value, list):
177
+ # value = '|'.join(value)
178
+ # elif isinstance(value, date):
179
+ # value = value.strftime('%m/%d/%Y')
180
+ # elif value is None:
181
+ # value = ''
182
+ # row.append(value.replace(',',';'))
183
+ # return ','.join(row)
184
+
185
+ # This method is the same as toCSVRow(), but it only outputs specified fields.
186
+ def toCSVRow_NWSFormat(self):
187
+ main_function_id = self.main_function_id.replace(',', ';')
188
+ intermediate_function_id = self.intermediate_function_id.replace(',', ';')
189
+ detailed_function_id = self.detailed_function_id.replace(',', ';')
190
+ point_of_contact = self.point_of_contact.replace(',', ';')
191
+ revision_date = self.revision_date.strftime('%m/%d/%Y')
192
+ station_classes = '|'.join(self.station_class)
193
+ emission_designators = '|'.join(self.emission_designator)
194
+ powers = '|'.join(self.sxxi_power)
195
+ tx_antenna_name = '|'.join(self.tx_antenna_name)
196
+ tx_antenna_polarization = '|'.join(self.tx_antenna_polarization)
197
+ tx_antenna_orientation = '|'.join(self.tx_antenna_orientation)
198
+ return f'{self.serial_number},{self.agency_action_number},{main_function_id},{intermediate_function_id},{detailed_function_id},{self.sxxi_frequency},{point_of_contact},{revision_date},{self.tx_state_country_code},{self.tx_antenna_location},{self.tx_antenna_latitude},{self.tx_antenna_longitude},{station_classes},{emission_designators},{powers},{self.last_transaction_date},{self.record_type},{tx_antenna_name},{tx_antenna_polarization},{tx_antenna_orientation},{self.tx_station_call_sign}'
199
+
200
+ def toCSVRow_TrackerFormat(self):
201
+ point_of_contact = self.point_of_contact.replace(',', ';')
202
+ return f'{self.serial_number},{self.agency_action_number},{self.revision_date},{point_of_contact}'
203
+
204
+
205
+ # This function expects a .txt file which was generated by SXXI using any of the SFAF 1 Column or GMF 1 Column output options
206
+ # and returns a list of RFA objects that correspond one-to-one with records found in the file. This function converts
207
+ # records from either format into a joined format. It should be noted that there are some columns in SFAF format that are
208
+ # not present in GMF format, so the resulting list of RFAs may have slightly different values depending on if an SFAF
209
+ # 1 Column or GMF 1 Column file was used.
210
+ # All column tags from GMF and SFAF format should be included, but this module was developed from an unclassifed release of SXXI
211
+ # with unclassified data, so classified fields will not be handled. Any unrecognized tags will be printed to the console
212
+ # along with the serial number of the record in which they were found.
213
+ # NOTE: If using an SFAF 1 Column output that only contains a subset of columns and not the full record, the '005' column which
214
+ # contains the record classification is required to detect the beginning of an new record. This will be fixed in later versions.
215
+ def importRFAsFrom1ColFile(filename):
216
+ with open(filename, 'r') as iFile:
217
+ lines = iFile.readlines()
218
+ mode = 'assignment'
219
+ if '_p_' in filename:
220
+ mode = 'proposal'
221
+ return importRFAs(lines, mode)
222
+
223
+ def importRFAs(lines, mode='assignment'):
224
+ # regular expression strings
225
+ receiver_group_tag = '(.*),R\d{2}$'
226
+
227
+ serial_suffix = ''
228
+ if mode == 'proposal':
229
+ serial_suffix = 'p'
230
+
231
+ RFAs = []
232
+ rfa = None
233
+ for line_no, line in enumerate(lines, start=1):
234
+ if line == '':
235
+ continue
236
+ # This is some error handling in the rare case that a record has a row with a tag but no value.
237
+ try:
238
+ tag, value = line.strip().split(maxsplit=1)
239
+ except ValueError as err:
240
+ # print(f'{type(err)}: {err}')
241
+ tag = line.strip()
242
+ value = ''
243
+ if tag != '115.':
244
+ if rfa is not None and rfa.serial_number is not None:
245
+ raise Exception(f'RFA with serial number {rfa.serial_number} has no value for tag {tag}') from None
246
+ else:
247
+ raise Exception(f'Line number {line_no} has no value for tag {tag}') from None
248
+ else:
249
+ continue
250
+ except Exception as err:
251
+ if rfa is not None and rfa.serial_number is not None:
252
+ print(f'Unexpected Error: {type(err)} on RFA with serial number {rfa.serial_number}', file=sys.stderr)
253
+ else:
254
+ print(f'Unexpected Error: {type(err)} on line number {line_no}', file=sys.stderr)
255
+ raise err
256
+ # Remove the ',R##' tags from values in receiver groups past the first. This only applies to files in SFAF format.
257
+ if matches := re.findall('(.*),R\d{2}$', value):
258
+ # print(f'{value} becomes {matches[0]}')
259
+ value = matches[0]
260
+
261
+ # If a value is '$', the loop will skip to the next iteration and not record the value. May need to be handled different at client request.
262
+ if value == '$':
263
+ continue
264
+ try:
265
+ # In these formats, there is no delimination between different records, so the end of a record is determined by the start
266
+ # of a new one. In GMF format, the first tag of a record is 'SER' which is the serial number, but in SFAF format, the
267
+ # first tag is '005' which is the security classification.
268
+ if tag == 'SER01' or tag == '005.': #SFAF uses 005 for FOI and CDD in addition to CLA. May need to be handled differently
269
+ if rfa != None:
270
+ addCustomColumns(rfa)
271
+ RFAs.append(rfa)
272
+ rfa = RFA()
273
+ if tag == 'SER01':
274
+ rfa.serial_number = value + serial_suffix
275
+ else:
276
+ rfa.classification = value
277
+ elif tag == '102.':
278
+ # print(value)
279
+ rfa.serial_number = value + serial_suffix
280
+ elif tag == 'TYP01' or tag == '010.':
281
+ rfa.record_type = value
282
+ elif tag == 'DAT01' or tag == '911.':
283
+ rfa.last_transaction_date = formatGMFDate(value) if tag == 'DAT01' else formatSFAFDate(value)
284
+ elif tag == 'CLA01':
285
+ rfa.classification = value
286
+ elif tag == 'FOI01':
287
+ # rfa.foi_exempt = value
288
+ continue
289
+ elif tag == 'ACN01' or tag == '956.':
290
+ rfa.agency_action_number = value
291
+ elif tag == 'EXD01' or tag == '141.':
292
+ rfa.expiration_date = formatGMFDate(value) if tag == 'EXD01' else formatSFAFDate(value)
293
+ elif tag == '142.':
294
+ rfa.review_date = formatSFAFDate(value)
295
+ elif tag == '144.':
296
+ # rfa.approval_authority = value
297
+ continue
298
+ elif tag == 'BUR01' or tag == '203.':
299
+ rfa.bureau = value
300
+ elif tag == '200.':
301
+ rfa.agency = value
302
+ elif tag[:3] == 'NET' or tag[:3] == '208':
303
+ rfa.user_net_code.append(value)
304
+ elif tag == 'FRQ01':
305
+ rfa.sxxi_frequency = value
306
+ elif tag == '110.':
307
+ if '-' in value:
308
+ unit = value[0]
309
+ lower, upper = value[1:].split('-')
310
+ rfa.sxxi_frequency = unit + lower
311
+ rfa.sxxi_frequency_upper_limit = unit + upper
312
+ else:
313
+ rfa.sxxi_frequency = value
314
+ elif tag[:3] == 'PRD' or tag[:3] == '506':
315
+ rfa.sxxi_paired_frequency.append(value)
316
+ elif tag == 'FRU01':
317
+ rfa.sxxi_frequency_upper_limit = value
318
+ elif tag[:3] == 'FBE' or tag[:3] == '111':
319
+ rfa.sxxi_excluded_frequency_bands.append(value)
320
+ elif tag[:3] == 'STC' or tag[:3] == '113':
321
+ rfa.station_class.append(value)
322
+ elif tag[:3] == 'EMS' or tag[:3] == '114':
323
+ rfa.emission_designator.append(value)
324
+ elif tag[:3] == 'PWR' or tag[:3] == '115':
325
+ rfa.sxxi_power.append(value)
326
+ elif tag[:3] == '117':
327
+ rfa.effective_radiated_power.append(value)
328
+ elif tag[:3] == '118':
329
+ rfa.power_augmentation.append(value)
330
+ elif tag == 'TME01' or tag == '130.':
331
+ rfa.gmf_time = value
332
+ elif tag == 'XSC01' or tag == '300.':
333
+ rfa.tx_state_country_code = value
334
+ elif tag == 'XAL01' or tag == '301.':
335
+ rfa.tx_antenna_location = value
336
+ elif tag == 'XLA01':
337
+ rfa.tx_antenna_latitude = value
338
+ elif tag == 'XLG01':
339
+ rfa.tx_antenna_longitude = value
340
+ elif tag == 'XRD01' or tag == '306.':
341
+ rfa.tx_authorized_radius = value
342
+ elif tag[:3] == 'ARB':
343
+ rfa.authorized_area_both.append(value)
344
+ elif tag == 'XAR01':
345
+ rfa.tx_authorized_area.append(value)
346
+ elif tag[:3] == '530':
347
+ if value[:3] == 'ART':
348
+ rfa.tx_authorized_area.append(value[4:])
349
+ elif value[:3] == 'ARR':
350
+ rfa.rx_authorized_area.append(value[4:])
351
+ elif value[:3] == 'ARB':
352
+ rfa.authorized_area_both.append(value[4:])
353
+ else:
354
+ print(f'Unknown tag in SFAF 530 of record{rfa.serial_number}')
355
+ elif tag[:3] == 'EQS' or tag[:3] == '344':
356
+ rfa.tx_ots_equipment = value
357
+ elif tag == '303.':
358
+ rfa.tx_antenna_latitude = value[:7] #double check if char count is sufficient condition
359
+ rfa.tx_antenna_longitude = value[7:]
360
+ elif tag[:3] == 'XSE' or tag[:3] == '358':
361
+ rfa.tx_antenna_elevation = value
362
+ elif tag[:3] == 'XAH' or tag[:3] == '359':
363
+ rfa.tx_antenna_feed_point_height = value
364
+ elif tag == 'XRC01' or tag == '302.':
365
+ rfa.tx_station_control = value
366
+ elif tag == 'XCL01' or tag == '304.':
367
+ rfa.tx_station_call_sign = value
368
+ elif tag[:3] == 'XAG' or tag[:3] == '357':
369
+ rfa.tx_antenna_gain.append(value)
370
+ elif tag[:3] == 'XAT' or tag[:3] == '354':
371
+ rfa.tx_antenna_name = value
372
+ elif tag[:3] == 'XAK' or tag[:3] == '355':
373
+ rfa.tx_antenna_nomenclature = value
374
+ elif tag[:3] == 'XAZ':
375
+ rfa.tx_antenna_orientation = value
376
+ elif tag[:3] == '362': #Will need additional processing of tag 362
377
+ if ',' in value:
378
+ rfa.tx_antenna_orientation, rfa.tx_antenna_azimuth = value.split(',')
379
+ else:
380
+ rfa.tx_antenna_orientation = value
381
+ elif tag[:3] == 'XAA':
382
+ rfa.tx_antenna_azimuth = value
383
+ elif tag[:3] == 'XAP' or tag[:3] == '363':
384
+ rfa.tx_antenna_polarization = value
385
+ elif tag == 'TUN01' or tag == '345.':
386
+ rfa.tx_radar_tunability = value
387
+ elif tag[:3] == 'PDD' or tag[:3] == '346':
388
+ rfa.tx_pulse_duration.append(value)
389
+ elif tag[:3] == 'PRR' or tag[:3] == '347':
390
+ rfa.tx_pulse_repetition_rate.append(value)
391
+ elif tag[:3] == 'XEQ' or tag[:3] == '340':
392
+ rfa.tx_equipment_nomenclature.append(value)
393
+ elif tag[:3] == 'NTT':
394
+ rfa.tx_number_of_stations = value
395
+ elif tag[:3] == 'NAM':
396
+ rfa.tx_system_name = value
397
+ elif tag[:3] == '341':
398
+ rfa.tx_number_of_stations, rfa.tx_system_name = value.split(',')
399
+ if rfa.tx_number_of_stations[0] == 'X':
400
+ rfa.tx_number_of_stations = None
401
+ elif tag[:3] == '373':
402
+ rfa.tx_jsc_area_code == value
403
+ elif tag[:3] == 'RSC' or tag[:3] == '400':
404
+ rfa.rx_state_country_code.append(value)
405
+ elif tag[:3] == 'RAL' or tag[:3] == '401':
406
+ rfa.rx_antenna_location.append(value)
407
+ elif tag[:3] == 'RLA':
408
+ rfa.rx_antenna_latitude.append(value)
409
+ elif tag[:3] == 'RLG':
410
+ rfa.rx_antenna_longitude.append(value)
411
+ elif tag[:3] == '403':
412
+ rfa.rx_antenna_latitude.append(value[:7])
413
+ rfa.rx_antenna_longitude.append(value[7:])
414
+ elif tag[:3] == 'RSE' or tag[:3] == '458':
415
+ rfa.rx_antenna_elevation.append(value)
416
+ elif tag[:3] == 'RRD' or tag[:3] == '406':
417
+ rfa.rx_authorized_radius.append(value)
418
+ elif tag[:3] == 'RRC' or tag[:3] == '402':
419
+ rfa.rx_control_id_and_server_system_id.append(value)
420
+ elif tag[:3] == 'RCL' or tag[:3] == '404':
421
+ rfa.rx_station_call_sign.append(value)
422
+ elif tag[:3] == 'RAG' or tag[:3] == '457':
423
+ rfa.rx_antenna_gain.append(value)
424
+ elif tag[:3] == 'RAT' or tag[:3] == '454':
425
+ rfa.rx_antenna_name.append(value)
426
+ elif tag[:3] == 'RAK' or tag[:3] == '455':
427
+ rfa.rx_antenna_nomenclature.append(value)
428
+ elif tag[:3] == 'RAH' or tag[:3] == '459':
429
+ rfa.rx_antenna_feed_point_height.append(value)
430
+ elif tag[:3] == 'RAZ' or tag[:3] == '462':
431
+ rfa.rx_antenna_orientation.append(value)
432
+ elif tag[:3] == 'RAA':
433
+ rfa.rx_antenna_azimuth.append(value)
434
+ elif tag[:3] == 'RAZ':
435
+ rfa.rx_antenna_orientation.append(value)
436
+ elif tag[:3] == '462':
437
+ if value.find(',') == -1:
438
+ rfa.rx_antenna_orientation.append(value)
439
+ else:
440
+ rfa.rx_antenna_orientation.append(value[:value.find(',')])
441
+ elif tag[:3] == 'RAP' or tag[:3] == '463':
442
+ rfa.rx_antenna_polarization.append(value)
443
+ elif tag[:3] == 'RPT' or tag[:3] == '408':
444
+ rfa.rx_repeater_indicator.append(value)
445
+ elif tag[:3] == 'REQ' or tag[:3] == '440':
446
+ rfa.rx_equipment_nomenclature.append(value)
447
+ elif tag[:3] == '473':
448
+ rfa.rx_jsc_area_code.append(value[0])
449
+ elif tag == 'SPD01' or tag == '321.':
450
+ rfa.tx_power_density = value
451
+ elif tag[:3] == 'XBW' or tag[:3] == '360':
452
+ rfa.tx_antenna_horizontal_beamwidth =value
453
+ elif tag == 'XIN01' or tag == '315.':
454
+ rfa.tx_inclination_angle = value
455
+ elif tag == 'XAE01' or tag == '316.':
456
+ rfa.tx_apogee = value
457
+ elif tag == 'XPE01' or tag == '317.':
458
+ rfa.tx_perigee = value
459
+ elif tag == 'XPD01' or tag == '318.':
460
+ rfa.tx_period_of_orbit = value
461
+ elif tag == 'XNR01' or tag == '319.':
462
+ rfa.tx_number_of_satellites = value
463
+ elif tag[:3] == 'RBW' or tag[:3] == '460':
464
+ rfa.rx_antenna_horizontal_beamwidth.append(value)
465
+ elif tag[:3] == 'RIN' or tag[:3] == '415':
466
+ rfa.rx_inclination_angle.append(value)
467
+ elif tag[:3] == 'RAE' or tag[:3] == '416':
468
+ rfa.rx_apogee.append(value)
469
+ elif tag[:3] == 'RPE' or tag[:3] == '417':
470
+ rfa.rx_perigee.append(value)
471
+ elif tag[:3] == 'RPD' or tag[:3] == '418':
472
+ rfa.rx_period_of_orbit.append(value)
473
+ elif tag[:3] == 'RNR' or tag[:3] == '419':
474
+ rfa.rx_number_of_satellites.append(value)
475
+ elif tag[:3] == 'JNT' or tag[:3] == '147':
476
+ rfa.joint_agency_names.append(value)
477
+ elif tag == 'MFI01' or tag == '511.':
478
+ rfa.main_function_id = value
479
+ elif tag == 'IFI01' or tag == '512.':
480
+ rfa.intermediate_function_id = value
481
+ elif tag[:3] == 'DFI' or tag[:3] == '513':
482
+ rfa.detailed_function_id = value
483
+ elif tag[:3] == 'NTS' or tag[:3] == '500':
484
+ rfa.irac_notes.append(value)
485
+ elif tag[:3] == '117':
486
+ rfa.effective_radiated_power.append(value)
487
+ elif tag == 'ICI01' or tag == '151.':
488
+ rfa.international_coordination_id = value
489
+ elif tag[:3] == 'CAN':
490
+ rfa.canadian_coordination_comments.append(value)
491
+ elif tag[:3] == 'MEX':
492
+ rfa.mexican_coordination_comments.append(value)
493
+ elif tag[:3] == '152':
494
+ if value[0] == 'C':
495
+ rfa.canadian_coordination_comments.append(value[2:])
496
+ elif value[0] == 'M':
497
+ rfa.mexican_coordination_comments.append(value[2:])
498
+ else:
499
+ print(f'Unrecognized format for SFAF 152 in record {rfa.serial_number}')
500
+ elif tag[:3] == 'NOT' or tag[:3] == '501':
501
+ rfa.free_text.append(value)
502
+ elif tag == 'DOC01' or tag == '108.':
503
+ rfa.docket_number_old = value
504
+ elif tag == 'POC01' or tag == '803.':
505
+ rfa.point_of_contact = value
506
+ rfa.poc_name, rfa.poc_phone_number, rfa.poc_verification_date = rfa.point_of_contact.split(',')
507
+ rfa.poc_verification_date = formatGMFDate(rfa.poc_verification_date)
508
+ elif tag[:3] == 'AGN' or tag[:3] == '503':
509
+ rfa.misc_agency_data.append(value)
510
+ elif tag[:3] == 'FAS' or tag[:3] == '504':
511
+ rfa.fas_agenda.append(value)
512
+ elif tag == 'RTN01' or tag == '958.':
513
+ # rfa.routine_agenda_item = value
514
+ continue
515
+ elif tag[:3] == 'SUP' or tag[:3] == '520':
516
+ if rfa.supplementary_details != '':
517
+ rfa.supplementary_details += f' {value}'
518
+ else:
519
+ rfa.supplementary_details = value
520
+ elif tag[:3] == 'AUS' or tag[:3] == '103':
521
+ rfa.irac_docket_number.append(value)
522
+ elif tag == 'AUD01' or tag == '107.':
523
+ rfa.authorization_date = formatGMFDate(value) if tag == 'AUD01' else formatSFAFDate(value)
524
+ elif tag == 'RVD01' or tag == '143.':
525
+ rfa.revision_date = formatGMFDate(value) if tag == 'RVD01' else formatSFAFDate(value)
526
+ elif tag[:3] == 'AST' or tag[:3] == '531':
527
+ if value[:3] == 'ESB':
528
+ rfa.excepted_states_both.append(value[4:])
529
+ elif value[:3] == 'ESR':
530
+ rfa.rx_excepted_states.append(value[4:])
531
+ elif value[:3] == 'EST':
532
+ rfa.tx_excepted_states.append(value[4:])
533
+ elif value[:3] == 'LSB':
534
+ rfa.authorized_states_both.append(value[4:])
535
+ elif value[:3] == 'LSR':
536
+ rfa.rx_authorized_states.append(value[4:])
537
+ elif value[:3] == 'LST':
538
+ rfa.tx_authorized_states.append(value[4:])
539
+ else:
540
+ print(f'Unrecognized format for SFAF 531 or GMF AST in record {rfa.serial_number}')
541
+ elif tag == '924.':
542
+ # rfa.data_source = value
543
+ continue
544
+ elif tag == '927.':
545
+ rfa.entry_date = formatSFAFDate(value)
546
+ elif tag == '928.':
547
+ rfa.receipt_date = formatSFAFDate(value)
548
+ else:
549
+ print(f'Unknown Tag {tag} at line {line_no}')
550
+ except Exception as error:
551
+ if rfa is not None and rfa.serial_number is not None:
552
+ print(f'Unexpected Error: {type(error)} on RFA with serial number {rfa.serial_number}', file=sys.stderr)
553
+ else:
554
+ print(f'Unexpected Error: {type(error)} in line {line_no}', file=sys.stderr)
555
+ raise error
556
+ addCustomColumns(rfa)
557
+ RFAs.append(rfa)
558
+ return RFAs
559
+
560
+
561
+ # This function takes a list of RFAs and a string that represents the name of a .csv file, and converts each RFA
562
+ # into .csv format, and exports them into a new file where each record is on its own line. The first line of
563
+ # the file is a header.
564
+ # This function includes file creation and writing which will require additional error handling to ensure proper
565
+ # resource handling and protection. This will be included in future versions.
566
+ def exportRFAsToCSV(RFAs, filename='output.csv'):
567
+ if os.path.isdir('./outputs/'):
568
+ filename = './outputs/' + filename
569
+ with open(filename, 'w') as oFile:
570
+ headers = ','.join(RFAs[0].__dict__.keys())
571
+ oFile.write(f'{headers}\n')
572
+ for rfa in RFAs:
573
+ oFile.write(f'{rfa.toCSVRow()}\n')
574
+
575
+
576
+ def exportRFAsToCSV_formatted(RFAs, filename='output.csv'):
577
+ if os.path.isdir('./outputs/'):
578
+ filename = './outputs/' + filename
579
+ with open(filename, 'w') as oFile:
580
+ headers = ','.join(RFAs[0].__dict__.keys())
581
+ oFile.write(f'{headers}\n')
582
+ for rfa in RFAs:
583
+ oFile.write(f'{rfa.toCSVRow_formatted()}\n')
584
+
585
+
586
+ # This function is the same as exportRFAsToCSV(), but uses the NWS format.
587
+ def exportRFAsToCSV_NWSFormat(RFAs, filename='output.csv'):
588
+ if os.path.isdir('./outputs/'):
589
+ filename = './outputs/' + filename
590
+ with open(filename, 'w') as oFile:
591
+ oFile.write('Serial Number,Action Number,Main Function Identifier,Intermediate Function Identifier,Detailed Function Identifier,Frequency,Point of Contact,Revision Date,Transmitter State/Country Code,Transmitter Antenna Location,Transmitter Latitude,Transmitter Longitude,Station Class(es),Emission Designator(s),Power(s),Last Transaction Date,Type of Action,Transmitter Antenna Name,Transmitter Antenna Polarization,Transmitter Antenna Orientation,Station Call Sign\n')
592
+ for rfa in RFAs:
593
+ oFile.write(f'{rfa.toCSVRow_NWSFormat()}\n')
594
+
595
+
596
+ def exportRFAsToCSV_TrackerFormat(RFAs, filename='output.csv'):
597
+ if os.path.isdir('./outputs/'):
598
+ filename = './outputs/' + filename
599
+ with open(filename, 'w') as oFile:
600
+ oFile.write('Serial Number,Action Number,Revision Date,Point of Contact\n')
601
+ for rfa in RFAs:
602
+ oFile.write(f'{rfa.toCSVRow_TrackerFormat()}\n')
603
+
604
+
605
+ # This function converts a frequency in SXXI format into kHz. The purpose of this function is to create a sortable
606
+ # format for frequencies.
607
+ # Future versions will include formatting options.
608
+ def formatFrequency(SXXI_frequency):
609
+ unit = SXXI_frequency[0].lower()
610
+ quantity = SXXI_frequency[1:]
611
+
612
+ if unit == 'h':
613
+ conversion = 0
614
+ elif unit == 'k':
615
+ conversion = 3
616
+ elif unit == 'm':
617
+ conversion = 6
618
+ elif unit == 'g':
619
+ conversion = 9
620
+ elif unit == 't':
621
+ conversion = 12
622
+ else:
623
+ print(f'Unknown unit \'{unit}\'in frequency format')
624
+
625
+ return str(float(quantity) * (10 ** conversion))
626
+
627
+
628
+ def formatFrequencyBand(SXXI_frequency_band):
629
+ unit = SXXI_frequency_band[0].lower()
630
+ lower_end, upper_end = map(formatFrequency, list(map(lambda x: unit + x, SXXI_frequency_band[1:].split('-'))))
631
+ return (lower_end, upper_end)
632
+
633
+
634
+ # This function converts a date in GMF format into standard dd/mm/yyyy format.
635
+ def formatGMFDate(GMF_date):
636
+ year = GMF_date[:2]
637
+ month = GMF_date[2:4]
638
+ day = GMF_date[4:]
639
+
640
+ if int(year) > 60:
641
+ year = '19' + year
642
+ else:
643
+ year = '20' + year
644
+
645
+ return datetime.date(int(year), int(month), int(day))
646
+
647
+
648
+ # This function converts a date in SFAF format into standard dd/mm/yyyy format.
649
+ def formatSFAFDate(SFAF_date):
650
+ year = SFAF_date[:4]
651
+ month = SFAF_date[4:6]
652
+ day = SFAF_date[6:]
653
+ return datetime.date(int(year), int(month), int(day))
654
+
655
+
656
+ # This function converts a power in SXXI format into Watts.
657
+ def formatPower(SXXI_power):
658
+ if SXXI_power is None or SXXI_power == '':
659
+ return 0
660
+ unit = SXXI_power[0]
661
+ quantity = SXXI_power[1:]
662
+
663
+ if unit == 'W':
664
+ conversion = 0
665
+ elif unit == 'K':
666
+ conversion = 3
667
+ elif unit == 'M':
668
+ conversion = 6
669
+
670
+ return float(quantity) * (10 ** conversion)
671
+
672
+
673
+ def formatLatLong(SXXI_latitude, SXXI_longitude):
674
+ lat_degrees, lat_minutes, lat_seconds, lat_sign = [(SXXI_latitude[i:i+2]) for i in range(0, len(SXXI_latitude), 2)]
675
+ long_degrees, long_minutes, long_seconds, long_sign = SXXI_longitude[:3], SXXI_longitude[3:5], SXXI_longitude[5:7], SXXI_longitude[7]
676
+
677
+ if lat_sign == 'N':
678
+ lat_sign = 1
679
+ else:
680
+ lat_sign = -1
681
+
682
+ if long_sign == 'E':
683
+ long_sign = 1
684
+ else:
685
+ long_sign = -1
686
+
687
+ lat_degrees = lat_sign * DMSToDD(lat_degrees, lat_minutes, lat_seconds)
688
+ long_degrees = long_sign * DMSToDD(long_degrees, long_minutes, long_seconds)
689
+
690
+ return str(lat_degrees) + ',' + str(long_degrees)
691
+
692
+
693
+ def DMSToDD(degrees, minutes, seconds):
694
+ return float(degrees) + (float(minutes) / 60) + (float(seconds) / 3600)
695
+
696
+
697
+ def decodeEmissionDesignator(emissionDesignator):
698
+ before_point = ''
699
+ after_point = ''
700
+ unit = None
701
+ for char in emissionDesignator:
702
+ if unit is None:
703
+ if char.isnumeric():
704
+ before_point += char
705
+ else:
706
+ unit = char
707
+ else:
708
+ if char.isnumeric():
709
+ after_point += char
710
+ else:
711
+ break
712
+ if unit == 'N':
713
+ return None
714
+ else:
715
+ unit = unit.lower()
716
+ if after_point == '':
717
+ value = float(before_point)
718
+ else:
719
+ value = float(before_point + '.' + after_point)
720
+ if unit == 'h':
721
+ return value
722
+ elif unit == 'k':
723
+ return value * (10 ** 3)
724
+ elif unit == 'm':
725
+ return value * (10 ** 6)
726
+ elif unit == 'g':
727
+ return value * (10 ** 9)
728
+
729
+
730
+ def addCustomColumns(rfa):
731
+ for power in rfa.sxxi_power:
732
+ rfa.power_w.append(formatPower(power))
733
+
734
+ max_power = 0
735
+ for power in rfa.power_w:
736
+ power = float(power)
737
+ if power > max_power:
738
+ max_power = power
739
+ rfa.max_power = str(max_power)
740
+
741
+ bandwidth = 0
742
+ for emission_designator in rfa.emission_designator:
743
+ band = decodeEmissionDesignator(emission_designator)
744
+ if band is not None and band > bandwidth:
745
+ bandwidth = band
746
+ rfa.bandwidth = str(bandwidth)
747
+
748
+ if rfa.sxxi_frequency_upper_limit is not None:
749
+ lower_limit = float(formatFrequency(rfa.sxxi_frequency))
750
+ upper_limit = float(formatFrequency(rfa.sxxi_frequency_upper_limit))
751
+ rfa.frequency_band = '['+str(lower_limit) + ',' + str(upper_limit)+']'
752
+ rfa.center_frequency = str(lower_limit + ((upper_limit - lower_limit) / 2))
753
+ else:
754
+ rfa.center_frequency = formatFrequency(rfa.sxxi_frequency)
755
+ rfa.frequency_band = '['+str(float(rfa.center_frequency) - (bandwidth / 2)) + ',' + str(float(rfa.center_frequency) + (bandwidth / 2))+']'
756
+
757
+
758
+
759
+ if rfa.tx_antenna_latitude is not None and rfa.tx_antenna_longitude is not None:
760
+ rfa.tx_lat_long = formatLatLong(rfa.tx_antenna_latitude, rfa.tx_antenna_longitude)
761
+
762
+ for rx_lat, rx_long in zip(rfa.rx_antenna_latitude, rfa.rx_antenna_longitude):
763
+ # print(rx_lat, rx_long)
764
+ rfa.rx_lat_long.append(formatLatLong(rx_lat, rx_long))
@@ -0,0 +1,56 @@
1
+ import psycopg
2
+ from psycopg import Rollback, sql
3
+ from SpUD import RFA
4
+
5
+ def progress_bar(current, total, bar_length=20):
6
+ fraction = current / total
7
+
8
+ arrow = int(fraction * bar_length - 1) * '-' + '>'
9
+ padding = int(bar_length - len(arrow)) * ' '
10
+
11
+ ending = '\n' if current == total else '\r'
12
+
13
+ print(f'Progress: [{arrow}{padding}] {int(fraction*100)}%', end=ending)
14
+
15
+ def connect(params):
16
+ connection = None
17
+ print('Connecting to the PostgreSQL database {0} as user {1}...'.format(params['dbname'], params['user']))
18
+ try:
19
+ connection = psycopg.connect(**params)
20
+ cursor = connection.cursor()
21
+ with connection.transaction():
22
+ cursor.execute('SELECT version()')
23
+ db_version = cursor.fetchone()
24
+ print(db_version)
25
+ return connection
26
+ except (Exception, psycopg.DatabaseError) as error:
27
+ print(f'Connection Error: {error}')
28
+ raise error
29
+
30
+ def disconnect(connection):
31
+ connection.close()
32
+ print('Database connection closed.')
33
+
34
+ def uploadRFAs(RFAs, connectParams, enableProgressBar=False):
35
+ current = 0
36
+ total = len(RFAs)
37
+ if enableProgressBar:
38
+ progress_bar(current, total)
39
+ columns = RFAs[0].__dict__.keys()
40
+ insertSQL = sql.SQL('INSERT INTO RFAs ({}) VALUES ({})').format(sql.SQL(', ').join(map(sql.Identifier, columns)), sql.SQL(', ').join(map(sql.Placeholder, columns)))
41
+ connection = connect(connectParams)
42
+ cursor = connection.cursor()
43
+ with connection.transaction():
44
+ cursor.execute('DELETE FROM RFAs')
45
+ for rfa in RFAs:
46
+ try:
47
+ cursor.execute(insertSQL, vars(rfa))
48
+ except Exception as error:
49
+ print(f'Execute Error: {error}')
50
+ print(f'Error on RFA with serial number: {rfa.serial_number}')
51
+ raise Rollback()
52
+ if enableProgressBar:
53
+ current += 1
54
+ progress_bar(current, total)
55
+ if connection is not None:
56
+ disconnect(connection)
File without changes
@@ -0,0 +1,13 @@
1
+ Metadata-Version: 2.4
2
+ Name: SpUD_io
3
+ Version: 1.0.0
4
+ Summary: A package that interprets Spectrum XXI output files and uploads data to the NOAA Spectrum Usage Database application
5
+ Author-email: Owen Crandall <crandallowen@gmail.com>
6
+ License-Expression: MIT
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Operating System :: OS Independent
9
+ Requires-Python: >=3.10
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Requires-Dist: psycopg[c]
13
+ Dynamic: license-file
@@ -0,0 +1,11 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/SpUD_io/RFA.py
5
+ src/SpUD_io/SpUD_upload.py
6
+ src/SpUD_io/__init__.py
7
+ src/SpUD_io.egg-info/PKG-INFO
8
+ src/SpUD_io.egg-info/SOURCES.txt
9
+ src/SpUD_io.egg-info/dependency_links.txt
10
+ src/SpUD_io.egg-info/requires.txt
11
+ src/SpUD_io.egg-info/top_level.txt
@@ -0,0 +1 @@
1
+ psycopg[c]
@@ -0,0 +1 @@
1
+ SpUD_io