PyFT8 3.7.0__tar.gz → 3.7.3__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.
- {pyft8-3.7.0 → pyft8-3.7.3}/PKG-INFO +27 -4
- {pyft8-3.7.0 → pyft8-3.7.3}/PyFT8/databases.py +148 -127
- {pyft8-3.7.0 → pyft8-3.7.3}/PyFT8/gui.py +152 -121
- {pyft8-3.7.0 → pyft8-3.7.3}/PyFT8/pyft8.py +48 -38
- {pyft8-3.7.0 → pyft8-3.7.3}/PyFT8/qso_manager.py +67 -29
- {pyft8-3.7.0 → pyft8-3.7.3}/PyFT8/receiver.py +54 -114
- {pyft8-3.7.0 → pyft8-3.7.3}/PyFT8/time_utils.py +3 -4
- {pyft8-3.7.0 → pyft8-3.7.3}/PyFT8.egg-info/PKG-INFO +27 -4
- {pyft8-3.7.0 → pyft8-3.7.3}/PyFT8.egg-info/SOURCES.txt +1 -5
- {pyft8-3.7.0 → pyft8-3.7.3}/README.md +26 -3
- {pyft8-3.7.0 → pyft8-3.7.3}/pyproject.toml +1 -1
- pyft8-3.7.3/tests/live test.py +175 -0
- pyft8-3.7.3/tests/plot_decode_times.py +41 -0
- pyft8-3.7.0/PyFT8/message_broker.py +0 -69
- pyft8-3.7.0/tests/play_wavs.py +0 -54
- pyft8-3.7.0/tests/plot_decode_times.py +0 -26
- pyft8-3.7.0/tests/spare.py +0 -52
- pyft8-3.7.0/tests/spare2.py +0 -37
- pyft8-3.7.0/tests/test_batch_and_live.py +0 -160
- {pyft8-3.7.0 → pyft8-3.7.3}/LICENSE +0 -0
- {pyft8-3.7.0 → pyft8-3.7.3}/MANIFEST.in +0 -0
- {pyft8-3.7.0 → pyft8-3.7.3}/PyFT8/__init__.py +0 -0
- {pyft8-3.7.0 → pyft8-3.7.3}/PyFT8/osd.py +0 -0
- {pyft8-3.7.0 → pyft8-3.7.3}/PyFT8/pskreporter.py +0 -0
- {pyft8-3.7.0 → pyft8-3.7.3}/PyFT8/rigctrl.py +0 -0
- {pyft8-3.7.0 → pyft8-3.7.3}/PyFT8/transmitter.py +0 -0
- {pyft8-3.7.0 → pyft8-3.7.3}/PyFT8.egg-info/dependency_links.txt +0 -0
- {pyft8-3.7.0 → pyft8-3.7.3}/PyFT8.egg-info/entry_points.txt +0 -0
- {pyft8-3.7.0 → pyft8-3.7.3}/PyFT8.egg-info/requires.txt +0 -0
- {pyft8-3.7.0 → pyft8-3.7.3}/PyFT8.egg-info/top_level.txt +0 -0
- {pyft8-3.7.0 → pyft8-3.7.3}/setup.cfg +0 -0
- {pyft8-3.7.0 → pyft8-3.7.3}/tests/misc/CQ AAAA.py +0 -0
- {pyft8-3.7.0 → pyft8-3.7.3}/tests/misc/animation.py +0 -0
- {pyft8-3.7.0 → pyft8-3.7.3}/tests/misc/bits_loopback.py +0 -0
- {pyft8-3.7.0 → pyft8-3.7.3}/tests/misc/bits_loopback_short.py +0 -0
- {pyft8-3.7.0 → pyft8-3.7.3}/tests/misc/osd.py +0 -0
- {pyft8-3.7.0 → pyft8-3.7.3}/tests/misc/symbols_loopback.py +0 -0
- {pyft8-3.7.0 → pyft8-3.7.3}/tests/misc/test_generate_wav.py +0 -0
- {pyft8-3.7.0 → pyft8-3.7.3}/tests/misc/test_loopback_performance.py +0 -0
- {pyft8-3.7.0 → pyft8-3.7.3}/tests/misc/text.py +0 -0
- {pyft8-3.7.0 → pyft8-3.7.3}/tests/misc/view_worked_before.py +0 -0
- {pyft8-3.7.0 → pyft8-3.7.3}/tests/pipeline/Fine tuning compare IOS_PC.py +0 -0
- {pyft8-3.7.0 → pyft8-3.7.3}/tests/pipeline/Targetted tests.py +0 -0
- {pyft8-3.7.0 → pyft8-3.7.3}/tests/pipeline/test_01.wav +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: PyFT8
|
|
3
|
-
Version: 3.7.
|
|
3
|
+
Version: 3.7.3
|
|
4
4
|
Summary: FT8 Decoding and Encoding in Python with test/loopback code
|
|
5
5
|
Author-email: G1OJS <g1ojs@yahoo.com>
|
|
6
6
|
License-Expression: GPL-3.0-or-later
|
|
@@ -23,7 +23,7 @@ Dynamic: license-file
|
|
|
23
23
|
# PyFT8 [](https://pepy.tech/projects/pyft8) [](https://pepy.tech/projects/pyft8) [PyPI link](https://pypi.org/project/PyFT8/)
|
|
24
24
|
# All-Python FT8 Transceiver GUI / Command Line Modem
|
|
25
25
|
|
|
26
|
-
This repository contains the source code for PyFT8, an all-Python open source FT8 transceiver that you can run as a basic GUI or from the command line to receive and transmit. Decoding performance (number of decodes) is
|
|
26
|
+
This repository contains the source code for PyFT8, an all-Python open source FT8 transceiver that you can run as a basic GUI or from the command line to receive and transmit. Decoding performance (number of decodes) is very close (~90% of decodes) to that of WSJT-x 2.7.0 running in FAST mode.
|
|
27
27
|
|
|
28
28
|
PyFT8 is somewhat experimental, with a focus on demonstrating FT8 written in Python, but can be used as a standalone replacement for WSJT-x and other software.
|
|
29
29
|
|
|
@@ -112,9 +112,32 @@ upload = Y
|
|
|
112
112
|
|
|
113
113
|
## Performance Compared with WSJT-x
|
|
114
114
|
|
|
115
|
-
The image below shows the number of decodes from PyFT8, WSJT-x V2.7.0 running in NORM mode, using
|
|
115
|
+
The image below shows the number of decodes from PyFT8, WSJT-x V2.7.0 running in NORM mode, using 5 minutes of busy 20m audio taken from the files used to test ft8_lib. On this dataset, WSJT-x performs almost the same on DEEP mode as it does on NORM, so the latter has been left off. Also, using AP with DEEP mode causes WSJT-x to be starved of CPU on my old PC (that's my theory), resulting in some cycles with hardly any decodes. PyFT8 gets about 90% of the decodes that WSJT-x gets on FAST mode, and about 78% of those it gets on DEEP mode.
|
|
116
116
|
|
|
117
|
-
<img width="900" height="900" alt="live playback vs wsjtx_v2 7 0" src="https://github.com/user-attachments/assets/
|
|
117
|
+
<img width="900" height="900" alt="live wav playback vs wsjtx_v2 7 0" src="https://github.com/user-attachments/assets/d05755c9-07d2-4e2f-98ff-c15f64b320fe" />
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
## Decoding approach
|
|
121
|
+
The approach to decoding, as at v3.7.0, is as follows.
|
|
122
|
+
|
|
123
|
+
- As audio arrives
|
|
124
|
+
- FFT to a spectrum 4 times per symbol duration & assemble a time-frequency grid with 2 bins per tone.
|
|
125
|
+
- Collect all audio samples for later full cycle FFT
|
|
126
|
+
- Search the time frequency grid for candidates based on Costas array sync score (using middle Costas block)
|
|
127
|
+
- Send candidates for decode using symbol & tone points drawn directly from the search grid to get early decodes using limited LDPC cycles.
|
|
128
|
+
|
|
129
|
+
This often decodes around three quarters of the candidates decoded by WSJT-x, with the steps below getting most of the rest:
|
|
130
|
+
|
|
131
|
+
- For candidates that haven't decoded:
|
|
132
|
+
- FFT the full cycle audio, downsample to 200 samples per second & shift the signal to f = 0
|
|
133
|
+
- Explore fine time and frequency offsets to maximise the Costas sync score
|
|
134
|
+
- Check for bad candidates (count maxima of Costas tones)
|
|
135
|
+
- Extract the LLR & wait for decoder to pick up the candidate
|
|
136
|
+
- For candidates awaiting decode from above:
|
|
137
|
+
- Try more extensive LDPC
|
|
138
|
+
- Try several 'A Priori' masks and input to more LDPC attempts, recording the output LLR on failures
|
|
139
|
+
- Try Ordered Statistics Decoding on the base LLR
|
|
140
|
+
- Try Ordered Statistics Decoding on the LLRs output from the AP+LDPC runs above
|
|
118
141
|
|
|
119
142
|
## Limitations
|
|
120
143
|
PyFT8 doesn't decode / encode *all* message types. The table below shows which are handled.
|
|
@@ -2,6 +2,9 @@ import threading, os, pickle, json
|
|
|
2
2
|
from PyFT8.pskreporter import PSKR_MQTT_listener
|
|
3
3
|
from PyFT8.time_utils import time_utils
|
|
4
4
|
|
|
5
|
+
|
|
6
|
+
# call hash calculator for receiver ==============================================================
|
|
7
|
+
|
|
5
8
|
call_hashes, hashes_for_calls = {}, {}
|
|
6
9
|
|
|
7
10
|
def add_call_hashes(call):
|
|
@@ -22,31 +25,8 @@ def add_call_hashes(call):
|
|
|
22
25
|
call_hashes[(x, m)] = call
|
|
23
26
|
hashes_for_calls[call] = hashes
|
|
24
27
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
grid = grid.upper()
|
|
28
|
-
if centre:
|
|
29
|
-
grid = grid + "LL44LL44LL44"[len(grid):]
|
|
30
|
-
mults = [20, 2, 2/24, 0.2/24, 0.2/(24*24), 0.02/(24*24)]
|
|
31
|
-
grid = grid[:2*len(mults)]
|
|
32
|
-
pairs = [grid[i:i+2] for i in range(0,len(grid),2)]
|
|
33
|
-
for i, p in enumerate(pairs):
|
|
34
|
-
zero = [ord('A'),ord('0')][i % 2]
|
|
35
|
-
lon += mults[i] * (ord(p[0]) - zero)
|
|
36
|
-
lat += mults[i] * (ord(p[1]) - zero) / 2
|
|
37
|
-
return (lat, lon)
|
|
38
|
-
|
|
39
|
-
def grids_to_dist_brg(sq1, sq2, units):
|
|
40
|
-
from numpy import sin, cos, asin, atan2, sqrt, radians, degrees
|
|
41
|
-
ll1, ll2 = grid_to_latlong(sq1), grid_to_latlong(sq2)
|
|
42
|
-
lats = [radians(ll1[0]), radians(ll2[0])]
|
|
43
|
-
dlat, dlon = radians(ll2[0] - ll1[0]), radians(ll2[1] - ll1[1])
|
|
44
|
-
s_lats, c_lats = sin(lats), cos(lats)
|
|
45
|
-
a = sin(dlat/2)**2 + c_lats[0] * c_lats[1] * sin(dlon/2)**2
|
|
46
|
-
r = 6371 * 2 * asin(sqrt(a))
|
|
47
|
-
b = atan2(c_lats[1] * sin(dlon), c_lats[0] * s_lats[1] - s_lats[0] * c_lats[1] * cos(dlon))
|
|
48
|
-
b *= (1.0 if 'km' in units else 0.621371)
|
|
49
|
-
return (r, degrees(b) % 360)
|
|
28
|
+
|
|
29
|
+
# Disk-synced dictionary class for History ======================================================
|
|
50
30
|
|
|
51
31
|
class DiskDict:
|
|
52
32
|
def __init__(self, file, autosave_t0):
|
|
@@ -84,11 +64,12 @@ class DiskDict:
|
|
|
84
64
|
except:
|
|
85
65
|
return
|
|
86
66
|
|
|
67
|
+
# History class ==============================================================================================
|
|
68
|
+
|
|
87
69
|
class History:
|
|
88
|
-
def __init__(self, config_folder, my_call, home_square
|
|
70
|
+
def __init__(self, config_folder, my_call, home_square, geo_units, pskr_refresh_mins = 20):
|
|
89
71
|
self.pskr_refresh_mins = pskr_refresh_mins
|
|
90
72
|
self.geo_units = geo_units
|
|
91
|
-
self.log_cache = None
|
|
92
73
|
self.my_call = my_call
|
|
93
74
|
self.home_square = home_square
|
|
94
75
|
self.home_square_lev4 = home_square[:4]
|
|
@@ -104,26 +85,52 @@ class History:
|
|
|
104
85
|
self.heard_by_me = DiskDict(f"{config_folder}/heard_by_me.json", 5) # all-time record of heard by me
|
|
105
86
|
self.call_to_grid = DiskDict(f"{config_folder}/call_to_grid.json", 7) # all time cache call -> fine locator
|
|
106
87
|
self.band_TxRx_homecall_recent_L4grid = DiskDict(f"{config_folder}/recent_l4grid.json", 9) # last 20 mins data -> per band tx/rx & current band detail
|
|
88
|
+
self.start()
|
|
89
|
+
|
|
90
|
+
def start(self):
|
|
91
|
+
mqtt = PSKR_MQTT_listener(self.home_square_lev4, self._add_mqtt_spot)
|
|
92
|
+
threading.Thread(target = self._count_activity, daemon = True).start()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# external query-type functions --------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
def is_in_new_alert(self, band, call, new_alert_data):
|
|
98
|
+
result = False
|
|
99
|
+
if band in new_alert_data:
|
|
100
|
+
result = call in new_alert_data[band]
|
|
101
|
+
return result
|
|
102
|
+
|
|
103
|
+
def is_hearing_me(self, band, call, in_last_n_minutes):
|
|
104
|
+
result = False
|
|
105
|
+
if band in self.hearing_me.data:
|
|
106
|
+
if call in self.hearing_me.data[band]:
|
|
107
|
+
result = int(self.hearing_me.data[band][call]['t']) > (time_utils.time() - 60*in_last_n_minutes)
|
|
108
|
+
return result
|
|
109
|
+
|
|
110
|
+
def get_spot_counts(self, band, call):
|
|
111
|
+
tx_reports = self.band_TxRx_homecall_recent_L4grid.data.get(f"{band}_0_{call}", [])
|
|
112
|
+
rx_reports = self.band_TxRx_homecall_recent_L4grid.data.get(f"{band}_1_{call}", [])
|
|
113
|
+
n_spotting = len(tx_reports) if tx_reports else 0
|
|
114
|
+
n_spotted = len(rx_reports) if rx_reports else 0
|
|
115
|
+
return n_spotted, n_spotting
|
|
116
|
+
|
|
117
|
+
def get_geo_text(self, call):
|
|
118
|
+
geo_text = ''
|
|
119
|
+
grid = self.call_to_grid.data.get(call, False)
|
|
120
|
+
if grid:
|
|
121
|
+
loc = grid if self.geo_units == 'grid' else self._get_dist_brg(grid, self.geo_units)
|
|
122
|
+
units_str = '' if self.geo_units == 'grid' else ('km' if 'km' in self.geo_units else 'mi')
|
|
123
|
+
geo_text = f"{int(loc[0]):5d}{units_str} {int(loc[1]):3d}°"
|
|
124
|
+
return geo_text
|
|
125
|
+
|
|
126
|
+
# external storage-like functions -------------------------------------------------------------------
|
|
107
127
|
|
|
108
|
-
def
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
def incorporate_log_data(self, log_cache):
|
|
113
|
-
self.log_cache = log_cache
|
|
114
|
-
for key in log_cache:
|
|
115
|
-
key_parts = key.split('_')
|
|
116
|
-
if len(key_parts) > 1:
|
|
117
|
-
c, b, m = key_parts
|
|
118
|
-
if m == 'FT8':
|
|
119
|
-
#print(f"Parsing adif log: Add hearing & heard by {c} on {b}")
|
|
120
|
-
self._add_myspots_record(self.hearing_me.data, None, b, c, 0, 0)
|
|
121
|
-
self._add_myspots_record(self.heard_by_me.data, None, b, c, 0, 0)
|
|
122
|
-
else:
|
|
123
|
-
print(m)
|
|
128
|
+
def add_bidirectional_hearing_heard_spot(self, band, their_call, time_on):
|
|
129
|
+
self._add_myspots_record(self.hearing_me.data, band, their_call, time_on, 0)
|
|
130
|
+
self._add_myspots_record(self.heard_by_me.data, band, their_call, time_on, 0)
|
|
124
131
|
|
|
125
132
|
def load_hearing_heard_from_all_file(self, bands):
|
|
126
|
-
recs = self.
|
|
133
|
+
recs = self._parse_all_txt()
|
|
127
134
|
if not any(recs):
|
|
128
135
|
return
|
|
129
136
|
for b in bands:
|
|
@@ -133,79 +140,90 @@ class History:
|
|
|
133
140
|
if r['md'] == 'FT8':
|
|
134
141
|
band = self.freqs_to_bands.get(round(r['fMHz'], 1), None)
|
|
135
142
|
if band is not None:
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
self.
|
|
143
|
+
data = None
|
|
144
|
+
if len(r['call_a']) > 3 and len(r['call_b']) > 3:
|
|
145
|
+
if r['call_a'] == self.my_call:
|
|
146
|
+
data, call = self.hearing_me.data, r['call_b'] # call b is calling me, so call b ishearing me
|
|
147
|
+
if r['call_b'] == self.my_call and not r['call_a'].startswith('CQ'):
|
|
148
|
+
data, call = self.heard_by_me.data, r['call_a'] # I'm calling call a, so I'm hearing call a
|
|
149
|
+
if data:
|
|
150
|
+
self._add_myspots_record(data, band, call, r['t'], r['rp'])
|
|
141
151
|
|
|
142
|
-
def
|
|
143
|
-
rows, recs = None, []
|
|
144
|
-
if os.path.exists(self.all_file):
|
|
145
|
-
with open(self.all_file, 'r') as f:
|
|
146
|
-
rows = f.readlines()
|
|
147
|
-
if rows is not None:
|
|
148
|
-
for r in rows:
|
|
149
|
-
fields = r.strip().split()
|
|
150
|
-
if len(fields) > 8:
|
|
151
|
-
call_a_pos = 7 if fields[7] != '~' else 8
|
|
152
|
-
recs.append({'fMHz':float(fields[1]), 'TxRx':fields[2], 'md':fields[3],
|
|
153
|
-
'call_a':fields[call_a_pos], 'call_b':fields[call_a_pos + 1]} )
|
|
154
|
-
if not any(recs):
|
|
155
|
-
print("Didn't find any records in an ALL.txt file in the config folder")
|
|
156
|
-
return recs
|
|
157
|
-
|
|
158
|
-
def process_message(self, message, band_info, myCall):
|
|
152
|
+
def process_message_for_history(self, message, band_info, myCall):
|
|
159
153
|
m = message
|
|
160
|
-
self._write_all_txt_row(m['
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
154
|
+
self._write_all_txt_row(m['all_txt_format'])
|
|
155
|
+
hail, their_call, grid_rpt = m['msg_tuple']
|
|
156
|
+
current_band = band_info['current_band']
|
|
157
|
+
self._add_myspots_record(self.heard_by_me.data, current_band,
|
|
158
|
+
their_call, int(time_utils.time()), m['their_snr'],
|
|
159
|
+
new_alert_data = self.heard_by_me_new)
|
|
160
|
+
if hail == myCall: # They're calling me, so they're hearing me
|
|
161
|
+
rpt = grid_rpt[-3:]
|
|
166
162
|
if rpt.isnumeric():
|
|
167
|
-
self._add_myspots_record(self.hearing_me.data,
|
|
168
|
-
|
|
163
|
+
self._add_myspots_record(self.hearing_me.data, current_band, their_call,
|
|
164
|
+
int(time_utils.time()), int(rpt), new_alert_data = self.heard_by_me_new)
|
|
165
|
+
|
|
169
166
|
|
|
167
|
+
# ----- internal storing / writing functions ----------------------------------------------------------
|
|
170
168
|
|
|
171
|
-
def _write_all_txt_row(self,
|
|
169
|
+
def _write_all_txt_row(self, row_text):
|
|
172
170
|
filemode = 'w' if not os.path.exists(self.all_file) else 'a'
|
|
173
|
-
row = f"{cyclestart_string} {fMHz:8.3f} {TxRx} {mode} {snr_str} {dt:4.1f} {fHz:4.0f} {hail} {their_call} {grid_rpt}"
|
|
174
171
|
with open(self.all_file, filemode) as f:
|
|
175
|
-
f.write(f"{
|
|
172
|
+
f.write(f"{row_text}\n")
|
|
176
173
|
|
|
177
|
-
def
|
|
174
|
+
def _add_mqtt_spot(self, d):
|
|
178
175
|
tnow = int(time_utils.time())
|
|
179
176
|
sc, rc = (d['sc'], d['sl']), (d['rc'], d['rl'])
|
|
180
177
|
for iTxRx, call_grid in enumerate([sc, rc]):
|
|
181
178
|
call, grid = call_grid
|
|
182
|
-
self.
|
|
179
|
+
self._store_best_grid(call, grid)
|
|
183
180
|
if self.home_square_lev4 in grid:
|
|
184
|
-
self.
|
|
181
|
+
self._add_homespots_record(f"{d['b']}_{iTxRx}_{call}", tnow)
|
|
185
182
|
if d['sc'] == self.my_call:
|
|
186
|
-
self._add_myspots_record(self.hearing_me.data,
|
|
183
|
+
self._add_myspots_record(self.hearing_me.data, d['b'], d['rc'], tnow, d['rp'], new_alert_data = self.hearing_me_new)
|
|
187
184
|
if d['rc'] == self.my_call:
|
|
188
|
-
self._add_myspots_record(self.heard_by_me.data,
|
|
185
|
+
self._add_myspots_record(self.heard_by_me.data, d['b'], d['sc'], tnow, d['rp'], new_alert_data = self.heard_by_me_new)
|
|
189
186
|
|
|
190
|
-
def
|
|
187
|
+
def _add_homespots_record(self, key, t):
|
|
188
|
+
self.band_TxRx_homecall_recent_L4grid.data.setdefault(key, [])
|
|
189
|
+
self.band_TxRx_homecall_recent_L4grid.data[key].append(int(t))
|
|
190
|
+
|
|
191
|
+
def _store_best_grid(self, call, grid):
|
|
191
192
|
if call.startswith('<'): return
|
|
192
193
|
existing_grid = self.call_to_grid.data.get(call, '')
|
|
193
194
|
if len(grid) > len(existing_grid):
|
|
194
195
|
self.call_to_grid.data[call] = grid
|
|
195
|
-
|
|
196
|
-
def add_homespots_record(self, key, t):
|
|
197
|
-
self.band_TxRx_homecall_recent_L4grid.data.setdefault(key, [])
|
|
198
|
-
self.band_TxRx_homecall_recent_L4grid.data[key].append(int(t))
|
|
199
196
|
|
|
200
|
-
def
|
|
201
|
-
|
|
197
|
+
def _parse_all_txt(self):
|
|
198
|
+
import time, calendar
|
|
199
|
+
rows, recs = None, []
|
|
200
|
+
if os.path.exists(self.all_file):
|
|
201
|
+
with open(self.all_file, 'r') as f:
|
|
202
|
+
rows = f.readlines()
|
|
203
|
+
if rows is not None:
|
|
204
|
+
for r in rows:
|
|
205
|
+
fields = r.strip().split()
|
|
206
|
+
if len(fields) > 8:
|
|
207
|
+
call_a_pos = 7 if fields[7] != '~' else 8
|
|
208
|
+
if len(fields[0]) == 13:
|
|
209
|
+
time_tuple = time.strptime(fields[0], "%y%m%d_%H%M%S")
|
|
210
|
+
t = calendar.timegm(time_tuple)
|
|
211
|
+
recs.append({'t':t , 'fMHz':float(fields[1]), 'TxRx':fields[2], 'md':fields[3],
|
|
212
|
+
'rp':fields[4], 'call_a':fields[call_a_pos], 'call_b':fields[call_a_pos + 1]} )
|
|
213
|
+
if not any(recs):
|
|
214
|
+
print("Didn't find any records in an ALL.txt file in the config folder")
|
|
215
|
+
return recs
|
|
216
|
+
|
|
217
|
+
def _add_myspots_record(self, historic_data, band, call, t, rp, new_alert_data = None):
|
|
218
|
+
self._update_new_alert_data(band, call, historic_data, new_alert_data)
|
|
202
219
|
historic_data.setdefault(band, {})
|
|
203
220
|
if call in historic_data[band]:
|
|
204
221
|
if int(t) < int(historic_data[band][call]['t']):
|
|
205
222
|
return
|
|
206
|
-
|
|
223
|
+
if t > 0:
|
|
224
|
+
historic_data[band][call] = {'t': int(t),'rp':int(rp)}
|
|
207
225
|
|
|
208
|
-
def
|
|
226
|
+
def _update_new_alert_data(self, band, call, historic_data, new_alert_data):
|
|
209
227
|
if new_alert_data is None:
|
|
210
228
|
return
|
|
211
229
|
new = band not in historic_data
|
|
@@ -215,20 +233,14 @@ class History:
|
|
|
215
233
|
new_alert_data.setdefault(band, [])
|
|
216
234
|
new_alert_data[band].append(call)
|
|
217
235
|
|
|
218
|
-
def
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
result = call in new_alert_data[band]
|
|
222
|
-
return result
|
|
236
|
+
def _get_dist_brg(self, grid, units):
|
|
237
|
+
self.dist_brg_cache.setdefault(grid, _grids_to_dist_brg(self.home_square, grid, units))
|
|
238
|
+
return self.dist_brg_cache[grid]
|
|
223
239
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
result = int(self.hearing_me.data[band][call]['t']) > since_seconds
|
|
229
|
-
return result
|
|
230
|
-
|
|
231
|
-
def count_activity(self):
|
|
240
|
+
|
|
241
|
+
# ---- Internal analysis functions -------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
def _count_activity(self):
|
|
232
244
|
import numpy as np
|
|
233
245
|
while True:
|
|
234
246
|
time_utils.sleep(5)
|
|
@@ -261,33 +273,38 @@ class History:
|
|
|
261
273
|
if nremotes > current_winner[1]:
|
|
262
274
|
if c != self.my_call:
|
|
263
275
|
self.home_most_remotes[b][iTxRx] = (c, nremotes)
|
|
276
|
+
|
|
277
|
+
# Geo Helpers =================================================================
|
|
264
278
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
279
|
+
def _grids_to_dist_brg(sq1, sq2, units):
|
|
280
|
+
from numpy import sin, cos, asin, atan2, sqrt, radians, degrees
|
|
281
|
+
ll1, ll2 = _grid_to_latlong(sq1), _grid_to_latlong(sq2)
|
|
282
|
+
lats = [radians(ll1[0]), radians(ll2[0])]
|
|
283
|
+
dlat, dlon = radians(ll2[0] - ll1[0]), radians(ll2[1] - ll1[1])
|
|
284
|
+
s_lats, c_lats = sin(lats), cos(lats)
|
|
285
|
+
a = sin(dlat/2)**2 + c_lats[0] * c_lats[1] * sin(dlon/2)**2
|
|
286
|
+
r = 6371 * 2 * asin(sqrt(a))
|
|
287
|
+
b = atan2(c_lats[1] * sin(dlon), c_lats[0] * s_lats[1] - s_lats[0] * c_lats[1] * cos(dlon))
|
|
288
|
+
b *= (1.0 if 'km' in units else 0.621371)
|
|
289
|
+
return (r, degrees(b) % 360)
|
|
271
290
|
|
|
272
|
-
def get_dist_brg(self, grid, units):
|
|
273
|
-
self.dist_brg_cache.setdefault(grid, grids_to_dist_brg(self.home_square, grid, units))
|
|
274
|
-
return self.dist_brg_cache[grid]
|
|
275
291
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
292
|
+
def _grid_to_latlong(grid, centre = True):
|
|
293
|
+
lat, lon = -90, -180
|
|
294
|
+
grid = grid.upper()
|
|
295
|
+
if centre:
|
|
296
|
+
grid = grid + "LL44LL44LL44"[len(grid):]
|
|
297
|
+
mults = [20, 2, 2/24, 0.2/24, 0.2/(24*24), 0.02/(24*24)]
|
|
298
|
+
grid = grid[:2*len(mults)]
|
|
299
|
+
pairs = [grid[i:i+2] for i in range(0,len(grid),2)]
|
|
300
|
+
for i, p in enumerate(pairs):
|
|
301
|
+
zero = [ord('A'),ord('0')][i % 2]
|
|
302
|
+
lon += mults[i] * (ord(p[0]) - zero)
|
|
303
|
+
lat += mults[i] * (ord(p[1]) - zero) / 2
|
|
304
|
+
return (lat, lon)
|
|
305
|
+
|
|
282
306
|
|
|
283
|
-
|
|
284
|
-
geo_text = ''
|
|
285
|
-
grid = self.call_to_grid.data.get(call, False)
|
|
286
|
-
if grid:
|
|
287
|
-
loc = grid if self.geo_units == 'grid' else self.get_dist_brg(grid, self.geo_units)
|
|
288
|
-
units_str = '' if self.geo_units == 'grid' else ('km' if 'km' in self.geo_units else 'mi')
|
|
289
|
-
geo_text = f"{int(loc[0]):5d}{units_str} {int(loc[1]):3d}°"
|
|
290
|
-
return geo_text
|
|
307
|
+
# ==================ADIF LOGGING=========================================================================
|
|
291
308
|
|
|
292
309
|
def ensure_file_exists(path, header = None):
|
|
293
310
|
try:
|
|
@@ -314,6 +331,10 @@ class ADIF:
|
|
|
314
331
|
self.cache[log_dict['call']] = tm
|
|
315
332
|
self.cache[cbm] = tm
|
|
316
333
|
|
|
334
|
+
def get_worked_before_info(self, their_call):
|
|
335
|
+
wb_time = self.cache.get(their_call,'')
|
|
336
|
+
return f"wb: {time_utils.format_duration(time_utils.time() - float(wb_time))}" if wb_time else ''
|
|
337
|
+
|
|
317
338
|
def _build_cache(self):
|
|
318
339
|
import calendar, time
|
|
319
340
|
def parse(rec, field):
|