PyFT8 3.1.0__tar.gz → 3.3.1__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.
Files changed (42) hide show
  1. {pyft8-3.1.0 → pyft8-3.3.1}/PKG-INFO +5 -3
  2. {pyft8-3.1.0 → pyft8-3.3.1}/PyFT8/databases.py +47 -56
  3. pyft8-3.3.1/PyFT8/gui.py +310 -0
  4. pyft8-3.3.1/PyFT8/osd.py +88 -0
  5. pyft8-3.3.1/PyFT8/pyft8.py +219 -0
  6. pyft8-3.3.1/PyFT8/qso_manager.py +119 -0
  7. pyft8-3.3.1/PyFT8/receiver.py +613 -0
  8. {pyft8-3.1.0 → pyft8-3.3.1}/PyFT8/time_utils.py +20 -7
  9. {pyft8-3.1.0 → pyft8-3.3.1}/PyFT8/transmitter.py +61 -55
  10. {pyft8-3.1.0 → pyft8-3.3.1}/PyFT8.egg-info/PKG-INFO +5 -3
  11. {pyft8-3.1.0 → pyft8-3.3.1}/PyFT8.egg-info/SOURCES.txt +14 -5
  12. {pyft8-3.1.0 → pyft8-3.3.1}/PyFT8.egg-info/requires.txt +3 -0
  13. {pyft8-3.1.0 → pyft8-3.3.1}/README.md +1 -2
  14. {pyft8-3.1.0 → pyft8-3.3.1}/pyproject.toml +5 -2
  15. pyft8-3.3.1/tests/misc/animation.py +36 -0
  16. pyft8-3.3.1/tests/misc/bits_loopback.py +204 -0
  17. pyft8-3.3.1/tests/misc/bits_loopback_short.py +169 -0
  18. pyft8-3.3.1/tests/misc/symbols_loopback.py +81 -0
  19. {pyft8-3.1.0/tests/dev → pyft8-3.3.1/tests/misc}/test_loopback_performance.py +2 -10
  20. pyft8-3.3.1/tests/pipeline/Fine tuning compare IOS_PC.py +373 -0
  21. pyft8-3.3.1/tests/pipeline/Targetted tests.py +396 -0
  22. pyft8-3.3.1/tests/pipeline/test_01.wav +0 -0
  23. {pyft8-3.1.0 → pyft8-3.3.1}/tests/plot_baseline.py +10 -9
  24. pyft8-3.3.1/tests/spare.py +52 -0
  25. {pyft8-3.1.0 → pyft8-3.3.1}/tests/test_batch_and_live.py +60 -49
  26. pyft8-3.1.0/PyFT8/gui.py +0 -227
  27. pyft8-3.1.0/PyFT8/pyft8.py +0 -404
  28. pyft8-3.1.0/PyFT8/receiver.py +0 -422
  29. pyft8-3.1.0/tests/spare.py +0 -18
  30. {pyft8-3.1.0 → pyft8-3.3.1}/LICENSE +0 -0
  31. {pyft8-3.1.0 → pyft8-3.3.1}/MANIFEST.in +0 -0
  32. {pyft8-3.1.0 → pyft8-3.3.1}/PyFT8/__init__.py +0 -0
  33. {pyft8-3.1.0 → pyft8-3.3.1}/PyFT8/pskreporter.py +0 -0
  34. {pyft8-3.1.0 → pyft8-3.3.1}/PyFT8/rigctrl.py +0 -0
  35. {pyft8-3.1.0 → pyft8-3.3.1}/PyFT8.egg-info/dependency_links.txt +0 -0
  36. {pyft8-3.1.0 → pyft8-3.3.1}/PyFT8.egg-info/entry_points.txt +0 -0
  37. {pyft8-3.1.0 → pyft8-3.3.1}/PyFT8.egg-info/top_level.txt +0 -0
  38. {pyft8-3.1.0 → pyft8-3.3.1}/setup.cfg +0 -0
  39. {pyft8-3.1.0/tests/dev → pyft8-3.3.1/tests/misc}/CQ AAAA.py +0 -0
  40. {pyft8-3.1.0/tests/dev → pyft8-3.3.1/tests/misc}/osd.py +0 -0
  41. {pyft8-3.1.0/tests/dev → pyft8-3.3.1/tests/misc}/test_generate_wav.py +0 -0
  42. {pyft8-3.1.0/tests/dev → pyft8-3.3.1/tests/misc}/view_worked_before.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyFT8
3
- Version: 3.1.0
3
+ Version: 3.3.1
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
@@ -15,6 +15,9 @@ License-File: LICENSE
15
15
  Requires-Dist: numpy>=1.24.0
16
16
  Requires-Dist: matplotlib
17
17
  Requires-Dist: pyaudio
18
+ Requires-Dist: pyserial
19
+ Requires-Dist: paho-mqtt
20
+ Requires-Dist: psutil
18
21
  Dynamic: license-file
19
22
 
20
23
  # PyFT8 [![PyPI Downloads](https://static.pepy.tech/personalized-badge/pyft8?period=total&units=INTERNATIONAL_SYSTEM&left_color=BLACK&right_color=GREEN&left_text=downloads)](https://pepy.tech/projects/pyft8) [![PyPI Downloads](https://static.pepy.tech/personalized-badge/pyft8?period=weekly&units=INTERNATIONAL_SYSTEM&left_color=BLACK&right_color=GREEN&left_text=downloads/wk)](https://pepy.tech/projects/pyft8) [PyPI link](https://pypi.org/project/PyFT8/)
@@ -112,8 +115,7 @@ upload = Y
112
115
 
113
116
  The image below shows the number of decodes from PyFT8, WSJT-x V2.7.0 running in NORM mode, and FT8_lib, using the same 10 minutes of busy 20m audio that is used to test ft8_lib.
114
117
 
115
- <img width="640" height="480" alt="performance snapshot" src="https://github.com/user-attachments/assets/b62221ec-2d74-4fbc-9c61-34b2bbc0e963" />
116
-
118
+ <img width="900" height="898" alt="batch test performance" src="https://github.com/user-attachments/assets/513d4121-5483-4e7b-81df-785dc4db3d37" />
117
119
 
118
120
  ## Limitations
119
121
  PyFT8 doesn't decode / encode *all* message types. The table below shows which are handled.
@@ -1,5 +1,6 @@
1
+ import threading, os, pickle, json, time
1
2
  from PyFT8.pskreporter import PSKR_MQTT_listener
2
- import threading, time, os, pickle, json
3
+ from PyFT8.time_utils import time_utils
3
4
 
4
5
  call_hashes, hashes_for_calls = {}, {}
5
6
 
@@ -56,9 +57,9 @@ class DiskDict:
56
57
  threading.Thread(target = self._autosave, args=(autosave_t0,), daemon = True).start()
57
58
 
58
59
  def _autosave(self, autosave_t0, autosave_period = 15):
59
- time.sleep(autosave_t0)
60
+ time_utils.sleep(autosave_t0)
60
61
  while True:
61
- time.sleep(autosave_period)
62
+ time_utils.sleep(autosave_period)
62
63
  self.save()
63
64
 
64
65
  def load(self):
@@ -84,38 +85,31 @@ class DiskDict:
84
85
  return
85
86
 
86
87
  class History:
87
- def __init__(self, config_folder, my_call, home_square, pskr_refresh_mins, parse_all_file):
88
+ def __init__(self, config_folder, my_call, home_square, pskr_refresh_mins):
88
89
  self.pskr_refresh_mins = pskr_refresh_mins
89
- self.config_folder = config_folder
90
+ self.log_cache = None
90
91
  self.my_call = my_call
91
92
  self.home_square = home_square
92
93
  self.home_square_lev4 = home_square[:4]
93
94
  self.freqs_to_bands = {}
94
95
  self.dist_brg_cache = {}
95
- self.hearing_me = DiskDict(f"{self.config_folder}/hearing_me.json", 3) # all-time record of hearing me
96
- self.heard_by_me = DiskDict(f"{self.config_folder}/heard_by_me.json", 5) # all-time record of heard by me
97
96
  self.hearing_me_new = {}
98
97
  self.heard_by_me_new = {}
99
- self.call_to_grid = DiskDict(f"{self.config_folder}/call_to_grid.json", 7) # all time cache call -> fine locator
100
- self.band_TxRx_homecall_recent_l4grid = DiskDict(f"{self.config_folder}/recent_l4grid.json", 9) # last 20 mins data -> per band tx/rx & current band detail
101
98
  self.home_activity = {}
102
99
  self.home_most_remotes = {}
103
100
  self.lock = threading.Lock()
104
- if parse_all_file:
105
- self.load_hearing_heard_from_all_file(f"{self.config_folder}/ALL.txt")
106
- self.hearing_me.save()
107
- self.heard_by_me.save()
108
- print("All file parsed and saved to hearing_me / heard_by_me files")
109
- else:
110
- mqtt = PSKR_MQTT_listener(self.home_square_lev4, self.add_mqtt_spot)
111
- threading.Thread(target = self.count_activity, daemon = True).start()
101
+ self.all_file = f"{config_folder}/ALL.txt"
102
+ self.hearing_me = DiskDict(f"{config_folder}/hearing_me.json", 3) # all-time record of hearing me
103
+ self.heard_by_me = DiskDict(f"{config_folder}/heard_by_me.json", 5) # all-time record of heard by me
104
+ self.call_to_grid = DiskDict(f"{config_folder}/call_to_grid.json", 7) # all time cache call -> fine locator
105
+ 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
112
106
 
113
- def set_bands(self, bands):
114
- for b in bands:
115
- f = float(bands[b])
116
- self.freqs_to_bands[round(f,1)] = b
117
-
118
- def load_hearing_heard_from_adif(self, log_cache):
107
+ def start_collect_new(self):
108
+ mqtt = PSKR_MQTT_listener(self.home_square_lev4, self.add_mqtt_spot)
109
+ threading.Thread(target = self.count_activity, daemon = True).start()
110
+
111
+ def incorporate_log_data(self, log_cache):
112
+ self.log_cache = log_cache
119
113
  for key in log_cache:
120
114
  key_parts = key.split('_')
121
115
  if len(key_parts) > 1:
@@ -127,9 +121,13 @@ class History:
127
121
  else:
128
122
  print(m)
129
123
 
130
- def load_hearing_heard_from_all_file(self, all_file):
131
- recs = self.parse_all_txt(all_file)
132
- if not any(recs): return
124
+ def load_hearing_heard_from_all_file(self, bands):
125
+ recs = self.parse_all_txt()
126
+ if not any(recs):
127
+ return
128
+ for b in bands:
129
+ f = float(bands[b])
130
+ self.freqs_to_bands[round(f,1)] = b
133
131
  for r in recs:
134
132
  if r['md'] == 'FT8':
135
133
  band = self.freqs_to_bands.get(round(r['fMHz'], 1), None)
@@ -140,10 +138,10 @@ class History:
140
138
  data = self.heard_by_me.data if TxRx == 'Rx' else self.hearing_me.data
141
139
  self.add_myspots_record(data, None, band, call, 0, 0)
142
140
 
143
- def parse_all_txt(self, all_file):
141
+ def parse_all_txt(self):
144
142
  rows, recs = None, []
145
- if os.path.exists(all_file):
146
- with open(all_file, 'r') as f:
143
+ if os.path.exists(self.all_file):
144
+ with open(self.all_file, 'r') as f:
147
145
  rows = f.readlines()
148
146
  if rows is not None:
149
147
  for r in rows:
@@ -157,14 +155,13 @@ class History:
157
155
  return recs
158
156
 
159
157
  def write_all_txt_row(self, cyclestart_string, fMHz, TxRx, mode, snr, dt, fHz, msg):
160
- all_file = f"{self.config_folder}/ALL.txt"
161
- filemode = 'w' if not os.path.exists(all_file) else 'a'
158
+ filemode = 'w' if not os.path.exists(self.all_file) else 'a'
162
159
  row = f"{cyclestart_string} {fMHz:8.3f} {TxRx} {mode} {snr:+03d} {dt:4.1f} {fHz:4.0f} {msg}"
163
- with open(all_file, filemode) as f:
160
+ with open(self.all_file, filemode) as f:
164
161
  f.write(f"{row}\n")
165
162
 
166
163
  def add_mqtt_spot(self, d):
167
- tnow = int(time.time())
164
+ tnow = int(time_utils.time())
168
165
  sc, rc = (d['sc'], d['sl']), (d['rc'], d['rl'])
169
166
  for iTxRx, call_grid in enumerate([sc, rc]):
170
167
  call, grid = call_grid
@@ -183,8 +180,8 @@ class History:
183
180
  self.call_to_grid.data[call] = grid
184
181
 
185
182
  def add_homespots_record(self, key, t):
186
- self.band_TxRx_homecall_recent_l4grid.data.setdefault(key, [])
187
- self.band_TxRx_homecall_recent_l4grid.data[key].append(int(t))
183
+ self.band_TxRx_homecall_recent_L4grid.data.setdefault(key, [])
184
+ self.band_TxRx_homecall_recent_L4grid.data[key].append(int(t))
188
185
 
189
186
  def add_myspots_record(self, historic_data, new_alert_data, band, call, t, rp):
190
187
  self._update_new_alert(band, call, historic_data, new_alert_data)
@@ -210,7 +207,7 @@ class History:
210
207
  result = call in new_alert_data[band]
211
208
  return result
212
209
 
213
- def is_hearing_me(self, band, call, since_seconds):
210
+ def is_hearing_me(self, band, call, since_seconds = 120):
214
211
  result = False
215
212
  if band in self.hearing_me.data:
216
213
  if call in self.hearing_me.data[band]:
@@ -220,7 +217,7 @@ class History:
220
217
  def count_activity(self):
221
218
  import numpy as np
222
219
  while True:
223
- time.sleep(5)
220
+ time_utils.sleep(5)
224
221
  self.home_activity = {}
225
222
  self.home_most_remotes = {}
226
223
  with self.lock:
@@ -231,29 +228,29 @@ class History:
231
228
  self.home_most_remotes[b] = [('Nobody',0), ('Nobody',0)]
232
229
 
233
230
  # keep only the remote spots that happened in the self.pskr_refresh_mins window
234
- for band_TxRx_homecall in self.band_TxRx_homecall_recent_l4grid.data:
235
- band_TxRx_homecall_recent_l4grid = self.band_TxRx_homecall_recent_l4grid.data[band_TxRx_homecall]
236
- band_TxRx_homecall_recent_l4grid = [t for t in band_TxRx_homecall_recent_l4grid if (time.time() - t) < 60*self.pskr_refresh_mins]
237
- self.band_TxRx_homecall_recent_l4grid.data[band_TxRx_homecall] = band_TxRx_homecall_recent_l4grid
231
+ for band_TxRx_homecall in self.band_TxRx_homecall_recent_L4grid.data:
232
+ band_TxRx_homecall_recent_L4grid = self.band_TxRx_homecall_recent_L4grid.data[band_TxRx_homecall]
233
+ band_TxRx_homecall_recent_L4grid = [t for t in band_TxRx_homecall_recent_L4grid if (time_utils.time() - t) < 60*self.pskr_refresh_mins]
234
+ self.band_TxRx_homecall_recent_L4grid.data[band_TxRx_homecall] = band_TxRx_homecall_recent_L4grid
238
235
 
239
236
  # count number of local Tx and Rx, and identify the local Tx and Rx with most remote spots
240
- for band_TxRx_homecall in self.band_TxRx_homecall_recent_l4grid.data:
241
- band_TxRx_homecall_recent_l4grid = self.band_TxRx_homecall_recent_l4grid.data[band_TxRx_homecall]
242
- if len(band_TxRx_homecall_recent_l4grid):
237
+ for band_TxRx_homecall in self.band_TxRx_homecall_recent_L4grid.data:
238
+ band_TxRx_homecall_recent_L4grid = self.band_TxRx_homecall_recent_L4grid.data[band_TxRx_homecall]
239
+ if len(band_TxRx_homecall_recent_L4grid):
243
240
  b, iTxRx, c = band_TxRx_homecall.split('_')
244
241
  iTxRx = int(iTxRx)
245
242
  self.home_activity.setdefault(b, [0, 0])
246
243
  self.home_activity[b][iTxRx] +=1
247
244
  self.home_most_remotes.setdefault(b, [('Nobody',0), ('Nobody',0)])
248
- nremotes = len(band_TxRx_homecall_recent_l4grid)
245
+ nremotes = len(band_TxRx_homecall_recent_L4grid)
249
246
  current_winner = self.home_most_remotes[b][iTxRx]
250
247
  if nremotes > current_winner[1]:
251
248
  if c != self.my_call:
252
249
  self.home_most_remotes[b][iTxRx] = (c, nremotes)
253
250
 
254
251
  def get_spot_counts(self, band, call):
255
- tx_reports = self.band_TxRx_homecall_recent_l4grid.data.get(f"{band}_0_{call}", [])
256
- rx_reports = self.band_TxRx_homecall_recent_l4grid.data.get(f"{band}_1_{call}", [])
252
+ tx_reports = self.band_TxRx_homecall_recent_L4grid.data.get(f"{band}_0_{call}", [])
253
+ rx_reports = self.band_TxRx_homecall_recent_L4grid.data.get(f"{band}_1_{call}", [])
257
254
  n_spotting = len(tx_reports) if tx_reports else 0
258
255
  n_spotted = len(rx_reports) if rx_reports else 0
259
256
  return n_spotted, n_spotting
@@ -285,20 +282,14 @@ class ADIF:
285
282
  ensure_file_exists(self.adif_log_file, header = "header <eoh>\n")
286
283
  self.cache = self._build_cache()
287
284
 
288
- def log(self, times, band_info, mStation, oStation, rpts):
289
- log_dict = {'call':oStation['c'], 'gridsquare':oStation['g'], 'mode':'FT8',
290
- 'operator':mStation['c'], 'station_callsign':mStation['c'], 'my_gridsquare':mStation['g'],
291
- 'rst_sent':rpts['sent'], 'rst_rcvd':rpts['rcvd'],
292
- 'qso_date':time.strftime("%Y%m%d", times['time_on']), 'qso_date_off':time.strftime("%Y%m%d", times['time_off']),
293
- 'time_on':time.strftime("%H%M%S", times['time_on']), 'time_off':time.strftime("%H%M%S", times['time_on']),
294
- 'band':band_info['b'], 'freq':band_info['fMHz']}
285
+ def log(self, log_dict):
295
286
  with open(self.adif_log_file,'a') as f:
296
287
  for k, v in log_dict.items():
297
288
  v = str(v)
298
289
  f.write(f"<{k}:{len(v)}>{v} ")
299
290
  f.write(f"<eor>\n")
300
291
  cbm = log_dict['call'] + "_" + log_dict['band'] + "_FT8"
301
- tm = time.time()
292
+ tm = time_utils.time()
302
293
  self.cache[log_dict['call']] = tm
303
294
  self.cache[cbm] = tm
304
295
 
@@ -318,7 +309,7 @@ class ADIF:
318
309
  with open(self.adif_log_file, 'r') as f:
319
310
  for l in f.readlines():
320
311
  if parse(l, 'mode') == "FT8":
321
- c, b, d, t = parse(l, 'call'), parse(l, 'band'), parse(l, 'qso_date'), parse(l, 'time_on')
312
+ c, b, d, t = parse(l, 'call:'), parse(l, 'band'), parse(l, 'qso_date'), parse(l, 'time_on')
322
313
  if c and b and d and t:
323
314
  time_tuple = time.strptime(d+t, "%Y%m%d%H%M%S")
324
315
  tm = calendar.timegm(time_tuple)
@@ -0,0 +1,310 @@
1
+ import numpy as np
2
+ import matplotlib.pyplot as plt
3
+ from matplotlib import rcParams
4
+ import queue
5
+ from matplotlib.animation import FuncAnimation
6
+ from matplotlib.widgets import Slider, Button
7
+ from PyFT8.time_utils import time_utils
8
+
9
+ rcParams['toolbar'] = 'None'
10
+ MAIN_TEXT_COLOR = '#f0f9fa'
11
+ TEXT_BACKGROUND_COLOR = '#2a2b2b'
12
+ INFO_TEXT_COLOR = 'white'
13
+ BUTTONCOLOR = 'grey'
14
+ HOVERCOLOR = 'darkgreen'
15
+ ACTIVE_BUTTON_COLOR = 'cyan'
16
+ INACTIVE_BUTTON_COLOR = '#edeef0'
17
+ MAX_FONT_SIZE_MAIN = 10
18
+ L = {'pmargin':0.04, 'sidebar_width': 0.17, 'banner_height':0.1, 'vsep1':0.01, 'hsep1':0.02}
19
+
20
+ # ================== WATERFALL ======================================================
21
+
22
+ class Scrollbox:
23
+ def __init__(self, fig, box, nlines = 5, monospace = False, default_text = '', fontsize = None):
24
+ self.fig = fig
25
+ self.ax = fig.add_axes(box)
26
+ self.default_text = default_text
27
+ bbox = self.ax.get_window_extent().transformed(fig.dpi_scale_trans.inverted())
28
+ self.fontsize = np.min([0.5 * bbox.height * fig.dpi / nlines, MAX_FONT_SIZE_MAIN]) if fontsize is None else fontsize
29
+ self.nlines = nlines
30
+ self.line_height = 0.9 / nlines
31
+ self.lines = []
32
+ self.lineartists = []
33
+ for i in range(self.nlines):
34
+ self.lineartists.append(self.ax.text(0.03,1 - self.line_height * (i+1),
35
+ '', color = MAIN_TEXT_COLOR, fontsize = self.fontsize, clip_on = True))
36
+ if monospace:
37
+ self.lineartists[-1].set_fontfamily('monospace')
38
+ self.ax.set_xticks([])
39
+ self.ax.set_yticks([])
40
+ self.ax.set_facecolor(TEXT_BACKGROUND_COLOR)
41
+
42
+ def scroll_print(self, text, color = MAIN_TEXT_COLOR):
43
+ self.lines = self.lines[-(self.nlines-1):]
44
+ self.lines.append({'text':text, 'color':color})
45
+ for i, line in enumerate(self.lines):
46
+ self.lineartists[i].set_text(line['text'])
47
+ self.lineartists[i].set_color(line['color'])
48
+
49
+ def clear(self):
50
+ self.lines = []
51
+ for i in range(self.nlines):
52
+ self.lineartists[i].set_text(self.default_text)
53
+
54
+ def list_print(self, lst, colors = None):
55
+ for i, txt in enumerate(lst[:self.nlines]):
56
+ if txt != self.lineartists[i].get_text():
57
+ self.lineartists[i].set_text(txt)
58
+ col = colors[i] if colors is not None and i < len(colors) else 'white'
59
+ self.lineartists[i].set_color(col)
60
+ for i in range(len(lst), self.nlines):
61
+ if self.lineartists[i].get_text() != '':
62
+ self.lineartists[i].set_text('')
63
+
64
+ class ButtonBox:
65
+ def __init__(self, fig, box, btn_pc = 30, onclick = None, clickargs=None, btn_label = ''):
66
+ btnbox, infobox = box.copy(), box.copy()
67
+ btnbox[2] = box[2] * btn_pc /100
68
+ infobox[2] = box[2] * (100-btn_pc) /100
69
+ infobox[0] = box[0] + box[2] * (btn_pc /100)
70
+ self.btn_axs = fig.add_axes(btnbox)
71
+ self.info_axs = fig.add_axes(infobox)
72
+ self.info_axs.set_xticks([])
73
+ self.info_axs.set_yticks([])
74
+ self.info_axs.set_facecolor(TEXT_BACKGROUND_COLOR)
75
+ self.label2 = self.info_axs.text(0.03, 0.5, '', color = INFO_TEXT_COLOR, verticalalignment = 'center', clip_on = True)
76
+ self.btn_widg = Button(self.btn_axs, btn_label, color = BUTTONCOLOR, hovercolor = HOVERCOLOR)
77
+ self.label = self.btn_widg.label
78
+ self.label.set_color(MAIN_TEXT_COLOR)
79
+ self.clickargs = clickargs
80
+ self.active = False
81
+ self.btn_widg.on_clicked(lambda x: onclick(clickargs))
82
+
83
+ def set_active(self, active: bool):
84
+ if self.active != active:
85
+ self.active = active
86
+ color = ACTIVE_BUTTON_COLOR if self.active else INACTIVE_BUTTON_COLOR
87
+ self.label.set_color(color)
88
+ self.label2.set_color(color)
89
+
90
+ def set_info_text(self, text):
91
+ if text != self.label2:
92
+ self.label2.set_text(text)
93
+
94
+ MESSAGE_TYPES = {'generic':{'bg':'blue', 'fg':'white'}, 'CQ':{'bg':'green', 'fg':'white'},'from_me': {'bg':'yellow', 'fg':'white'}, 'to_me':{'bg':'red', 'fg':'white'}}
95
+ class Msg_box:
96
+ def __init__(self, fig, ax, w, h, onclick):
97
+ from matplotlib.patches import Rectangle
98
+ self.onclick = onclick
99
+ self.fig, self.ax = fig, ax
100
+ rect = Rectangle((0, 0), w, h, alpha=0.5, edgecolor='lime', lw=1)
101
+ self.patch = self.ax.add_patch(rect)
102
+ self.text_inst = self.ax.text(0, 0, '', fontsize='small', fontweight = 'bold' )
103
+ self.cid = fig.canvas.mpl_connect('button_press_event', self._onclick)
104
+ self.visible = False
105
+
106
+ def set_properties(self, x, y, message):
107
+ self.patch.set_xy((x, y))
108
+ self.text_inst.set_position((x, y+1))
109
+ self.text_inst.set_text(message['display_text'])
110
+ message_type_params = MESSAGE_TYPES[message['message_type']]
111
+ self.text_inst.set_color(message_type_params['fg'])
112
+ self.patch.set_facecolor(message_type_params['bg'])
113
+ self.message = message
114
+ self.cycle = message['origin']['odd_even']
115
+ self.patch.set_visible(True)
116
+ self.text_inst.set_visible(True)
117
+ self.visible = True
118
+
119
+
120
+ def hide(self):
121
+ self.patch.set_visible(False)
122
+ self.text_inst.set_visible(False)
123
+ self.visible = False
124
+
125
+ def _onclick(self, event):
126
+ if self.visible:
127
+ b, _ = self.patch.contains(event)
128
+ if(b):
129
+ self.onclick({'action': 'MESSAGE_CLICK', 'message':self.message})
130
+
131
+ class Gui:
132
+ def __init__(self, config, rig, history, console_print, wf_data, hearing_me_since_mins = 5):
133
+ self.rig = rig
134
+ self.on_click = None
135
+ self.plt = plt
136
+ self.fig = plt.figure(figsize = (10,10), facecolor=(.18, .71, .71, 0.4))
137
+ self.fig.canvas.manager.set_window_title('PyFT8 by G1OJS')
138
+ self.config, self.history = config, history
139
+ self.console_print = console_print
140
+ self.wf_top = 1-L['pmargin']-L['banner_height']-L['vsep1']
141
+ self.wf_left = L['pmargin']+L['sidebar_width']+L['hsep1']
142
+ self.hearing_me_since_mins = hearing_me_since_mins
143
+ self.wf_data = wf_data
144
+ self.sidebars_last_update = 0
145
+ self.sidebars_page = 0
146
+ self._make_axes()
147
+ self.gui_queue = queue.Queue()
148
+ self.active_msg_boxes = []
149
+ self.inactive_msg_boxes = []
150
+ self.image = self.ax_wf.imshow(self.wf_data['data'],vmax=120,vmin=90,origin='lower',interpolation='none', aspect = 'auto')
151
+ self._make_buttons()
152
+ self.tlast = 0
153
+ self.band_info = {'current_band': None, 'fMHz':0, 'time_set':0}
154
+ self.sidebars_artists = [*[bb.label for bb in self.button_boxes], *[bb.label2 for bb in self.button_boxes],
155
+ *self.band_stats.lineartists, *self.console.lineartists, *self.hm.lineartists]
156
+
157
+ self.ani = FuncAnimation(self.fig, self._animate, interval = 100, frames=(100000), blit = False)
158
+
159
+ def register_onclick(self, on_click):
160
+ self.on_click = on_click
161
+
162
+ def get_band_info(self):
163
+ return self.band_info
164
+
165
+ def _animate(self, frames):
166
+ #t = time_utils.time()
167
+ #print(t - self.tlast)
168
+ #self.tlast = t
169
+
170
+ if self.gui_queue.empty():
171
+ self.image.set_data(self.wf_data['data'])
172
+ else:
173
+ t0 = time_utils.time()
174
+ while (not self.gui_queue.empty()) and (time_utils.time()-t0 < 0.1):
175
+ action, data = self.gui_queue.get()
176
+ if action == "clear_cycle":
177
+ curr_cycle = data
178
+ self._clear_msg_boxes(curr_cycle)
179
+ else:
180
+ x, y, message = data
181
+ mb = self._get_msg_box()
182
+ self.active_msg_boxes.append(mb)
183
+ mb.set_properties(x, y, message)
184
+
185
+ #p = [mb.patch for mb in self.active_msg_boxes]
186
+ #t = [mb.text_inst for mb in self.active_msg_boxes]
187
+
188
+ #return [self.image, *p, *t, *self.sidebars_artists]
189
+
190
+ def _on_click_local(self, clickargs):
191
+ btn_action = clickargs['action']
192
+ if(btn_action == 'SET_BAND'):
193
+ current_band, freqMHz = clickargs['band'], clickargs['freq']
194
+ self.band_info = {'current_band':current_band, 'fMHz':freqMHz, 'time_set':time_utils.time()}
195
+ self.rig.set_freq_Hz(int(1000000*float(self.band_info['fMHz'])))
196
+ self.console_print(f"[PyFT8] Set band: {self.band_info['current_band']} {self.band_info['fMHz']}")
197
+ if clickargs['action'] == "MESSAGE_CLICK":
198
+ self.console_print(f"[GUI] Clicked on message '{clickargs['message']['msg_tuple']}'")
199
+ self.on_click(clickargs)
200
+ self._refresh_sidebars()
201
+
202
+ def _clear_msg_boxes(self, curr_cycle):
203
+ self._refresh_sidebars()
204
+ to_hide = [mb for mb in self.active_msg_boxes if mb.cycle == curr_cycle]
205
+ for mb in to_hide:
206
+ self.inactive_msg_boxes.append(mb)
207
+ self.active_msg_boxes.remove(mb)
208
+ mb.hide()
209
+
210
+ def _get_msg_box(self):
211
+ if len(self.inactive_msg_boxes) == 0:
212
+ mb = Msg_box(self.fig, self.ax_wf, self.wf_data['sig_w'], self.wf_data['sig_h'], onclick = self._on_click_local)
213
+ else:
214
+ mb = self.inactive_msg_boxes.pop()
215
+ return mb
216
+
217
+ def _make_axes(self):
218
+ self.ax_wf = self.fig.add_axes([self.wf_left, L['pmargin'], 1-self.wf_left-L['pmargin'], self.wf_top-L['pmargin']])
219
+ self.ax_wf.set_xticks([])
220
+ self.ax_wf.set_yticks([])
221
+ self.band_stats = Scrollbox(self.fig, [L['pmargin'], self.wf_top+L['vsep1'], L['sidebar_width'], L['banner_height']], nlines = 4, monospace = True)
222
+ self.band_stats.ax.text(-0.2,0.75,'Tx')
223
+ self.band_stats.ax.text(-0.2,0.25,'Rx')
224
+ self.console = Scrollbox(self.fig, [self.wf_left, self.wf_top+L['vsep1'], 1-self.wf_left-L['pmargin'], L['banner_height']])
225
+
226
+ def _make_buttons(self):
227
+ self.button_boxes = []
228
+ bh, bs = 0.02, 0.002
229
+ bb = ButtonBox(self.fig, [L['pmargin'], self.wf_top - (len(self.button_boxes)+1) * bh + bs, L['sidebar_width'], bh-bs], btn_pc = 100,
230
+ btn_label = "CQ", onclick = self._on_click_local, clickargs = {'action':'CQ'})
231
+ self.button_boxes.append(bb)
232
+ bb = ButtonBox(self.fig, [L['pmargin'], self.wf_top - (len(self.button_boxes)+1) * bh + bs, L['sidebar_width'], bh-bs], btn_pc = 100,
233
+ btn_label = "Repeat last", onclick = self._on_click_local, clickargs = {'action':'RPT_LAST'})
234
+ self.button_boxes.append(bb)
235
+ bb = ButtonBox(self.fig, [L['pmargin'], self.wf_top - (len(self.button_boxes)+1) * bh + bs, L['sidebar_width'], bh-bs], btn_pc = 100,
236
+ btn_label = "Tx off", onclick = self._on_click_local, clickargs = {'action':'TX_OFF'})
237
+ self.button_boxes.append(bb)
238
+ for band, freq in self.config['bands'].items():
239
+ bb = ButtonBox(self.fig, [L['pmargin'], self.wf_top - (len(self.button_boxes)+1) * bh + bs, L['sidebar_width'], bh-bs], btn_pc = 30,
240
+ btn_label = band, onclick = self._on_click_local, clickargs = {'action':'SET_BAND', 'band':band, 'freq':freq})
241
+ self.button_boxes.append(bb)
242
+
243
+ self.hm = Scrollbox(self.fig, [L['pmargin'], L['pmargin'], L['sidebar_width'], self.wf_top - (len(self.button_boxes)+2) * bh + bs - L['vsep1']],
244
+ nlines = 30, monospace = True, fontsize = 8)
245
+
246
+ def _refresh_sidebars(self):
247
+ current_band = self.band_info['current_band']
248
+
249
+ # band stats
250
+ grd = self.config['station']['grid'][:4]
251
+ for bb in self.button_boxes:
252
+ button_band = bb.clickargs.get('band','')
253
+ if button_band:
254
+ if current_band is not None:
255
+ bb.set_active(button_band == current_band)
256
+ if button_band in self.history.home_activity:
257
+ cnts = self.history.home_activity[button_band]
258
+ bb.set_info_text(f"{cnts[0]}Tx, {cnts[1]}Rx")
259
+
260
+ if current_band is not None and self.history is not None:
261
+ # home square counts
262
+ if current_band in self.history.home_most_remotes:
263
+ tx_lead, rx_lead = self.history.home_most_remotes[current_band]
264
+ myCall = self.config['station']['call']
265
+ n_spotted, n_spotting = self.history.get_spot_counts(current_band, myCall)
266
+ self.band_stats.scroll_print(f"{myCall:<7} {tx_lead[0]:<7}", color = '#ff756b')
267
+ self.band_stats.scroll_print(f"{n_spotting:<7} {tx_lead[1]:<7}", color = '#ff756b')
268
+ self.band_stats.scroll_print(f"{myCall:<7} {rx_lead[0]:<7}", color = '#b6f0c6')
269
+ self.band_stats.scroll_print(f"{n_spotted:<7} {rx_lead[1]:<7}", color = '#b6f0c6')
270
+
271
+ #refresh hearing me / heard by me panel
272
+ historic_data = self.history.hearing_me.data if self.sidebars_page == 1 else self.history.heard_by_me.data
273
+ new_calls_data = self.history.hearing_me_new if self.sidebars_page == 1 else self.history.heard_by_me_new
274
+ timewindow_str = f"<{self.hearing_me_since_mins:.0f} mins"
275
+ title_txt = f"Hearing me {timewindow_str}" if self.sidebars_page==1 else f"Heard by me {timewindow_str}"
276
+ display_rows = [(title_txt, 2e40, 'white')]
277
+ tnow = time_utils.time()
278
+ if current_band in historic_data:
279
+ band_rpts = historic_data[current_band]
280
+ calls_now = [call for call in band_rpts if (tnow - band_rpts[call]['t']) < 60*self.hearing_me_since_mins]
281
+ subtitle_txt = f"{len(calls_now)}/{len(band_rpts)} now/ever"
282
+ display_rows.append((subtitle_txt, 1e40, 'white'))
283
+ for remote_call in calls_now:
284
+ rpt = band_rpts[remote_call]
285
+ snr, geo_text, timestamp = int(rpt['rp']), self.history.get_geo_text(remote_call, self.config['gui']['loc']), rpt['t']
286
+ color = 'white' if self.history.is_in_new_alert(current_band, remote_call, new_calls_data) else 'lime'
287
+ display_rows.append((f"{remote_call:<7} {snr:+03d} {geo_text:<12}", timestamp, color))
288
+ display_rows.sort(key = lambda row: row[1], reverse = True)
289
+ self.hm.list_print([row[0] for row in display_rows], [row[2] for row in display_rows])
290
+ self.sidebars_page = (self.sidebars_page +1 )%2
291
+
292
+ def set_bandstats_title(self, txt):
293
+ self.band_stats.ax.set_title(txt, fontsize = 10)
294
+
295
+ def after_new_search(self, curr_cycle):
296
+ self.gui_queue.put(('clear_cycle', curr_cycle))
297
+
298
+ def set_message(self, message):
299
+ o = message['origin']
300
+ x = int(o['t0'] / self.wf_data['dt'] + o['odd_even'] * self.wf_data['pixels_per_cycle'])
301
+ y = int(o['f0'] / self.wf_data['df'])
302
+ self.gui_queue.put(('add_message', (x, y, message)))
303
+
304
+
305
+
306
+
307
+
308
+
309
+
310
+
@@ -0,0 +1,88 @@
1
+ import numpy as np
2
+ from itertools import combinations
3
+
4
+ generator_matrix_rows = ["8329ce11bf31eaf509f27fc", "761c264e25c259335493132", "dc265902fb277c6410a1bdc", "1b3f417858cd2dd33ec7f62", "09fda4fee04195fd034783a", "077cccc11b8873ed5c3d48a", "29b62afe3ca036f4fe1a9da", "6054faf5f35d96d3b0c8c3e", "e20798e4310eed27884ae90", "775c9c08e80e26ddae56318", "b0b811028c2bf997213487c", "18a0c9231fc60adf5c5ea32", "76471e8302a0721e01b12b8", "ffbccb80ca8341fafb47b2e", "66a72a158f9325a2bf67170", "c4243689fe85b1c51363a18", "0dff739414d1a1b34b1c270", "15b48830636c8b99894972e", "29a89c0d3de81d665489b0e", "4f126f37fa51cbe61bd6b94", "99c47239d0d97d3c84e0940", "1919b75119765621bb4f1e8", "09db12d731faee0b86df6b8", "488fc33df43fbdeea4eafb4", "827423ee40b675f756eb5fe", "abe197c484cb74757144a9a", "2b500e4bc0ec5a6d2bdbdd0", "c474aa53d70218761669360", "8eba1a13db3390bd6718cec", "753844673a27782cc42012e", "06ff83a145c37035a5c1268", "3b37417858cc2dd33ec3f62", "9a4a5a28ee17ca9c324842c", "bc29f465309c977e89610a4", "2663ae6ddf8b5ce2bb29488", "46f231efe457034c1814418", "3fb2ce85abe9b0c72e06fbe", "de87481f282c153971a0a2e", "fcd7ccf23c69fa99bba1412", "f0261447e9490ca8e474cec", "4410115818196f95cdd7012", "088fc31df4bfbde2a4eafb4", "b8fef1b6307729fb0a078c0", "5afea7acccb77bbc9d99a90", "49a7016ac653f65ecdc9076", "1944d085be4e7da8d6cc7d0", "251f62adc4032f0ee714002", "56471f8702a0721e00b12b8", "2b8e4923f2dd51e2d537fa0", "6b550a40a66f4755de95c26", "a18ad28d4e27fe92a4f6c84", "10c2e586388cb82a3d80758", "ef34a41817ee02133db2eb0", "7e9c0c54325a9c15836e000", "3693e572d1fde4cdf079e86", "bfb2cec5abe1b0c72e07fbe", "7ee18230c583cccc57d4b08", "a066cb2fedafc9f52664126", "bb23725abc47cc5f4cc4cd2", "ded9dba3bee40c59b5609b4", "d9a7016ac653e6decdc9036", "9ad46aed5f707f280ab5fc4", "e5921c77822587316d7d3c2", "4f14da8242a8b86dca73352", "8b8b507ad467d4441df770e", "22831c9cf1169467ad04b68", "213b838fe2ae54c38ee7180", "5d926b6dd71f085181a4e12", "66ab79d4b29ee6e69509e56", "958148682d748a38dd68baa", "b8ce020cf069c32a723ab14", "f4331d6d461607e95752746", "6da23ba424b9596133cf9c8", "a636bcbc7b30c5fbeae67fe", "5cb0d86a07df654a9089a20", "f11f106848780fc9ecdd80a", "1fbb5364fb8d2c9d730d5ba", "fcb86bc70a50c9d02a5d034", "a534433029eac15f322e34c", "c989d9c7c3d3b8c55d75130", "7bb38b2f0186d46643ae962", "2644ebadeb44b9467d1f42c", "608cc857594bfbb55d69600"]
5
+ kGEN = np.array([int(row,16)>>1 for row in generator_matrix_rows])
6
+ A = np.zeros((83, 91), dtype=np.uint8)
7
+ for i, row in enumerate(kGEN):
8
+ for j in range(91):
9
+ A[i, 90 - j] = (row >> j) & 1
10
+ G = np.concatenate([np.eye(91, dtype=np.uint8), A.T],axis=1)
11
+
12
+ def gf2_systematic_from_reliability(G, reliab_order):
13
+ G = (G.copy() & 1).astype(np.uint8)
14
+ k, n = G.shape
15
+ colperm = np.array(reliab_order, dtype=np.int64)
16
+ inv = np.empty(n, dtype=np.int64)
17
+ inv[colperm] = np.arange(n)
18
+ G = G[:, colperm]
19
+ # Gauss-Jordan:
20
+ row = 0
21
+ for col in range(n):
22
+ if row >= k:
23
+ break
24
+ pivot_rows = np.where(G[row:, col] == 1)[0]
25
+ if pivot_rows.size == 0:
26
+ continue
27
+ piv = row + pivot_rows[0]
28
+ if piv != row:
29
+ G[[row, piv], :] = G[[piv, row], :]
30
+ ones = np.where(G[:, col] == 1)[0]
31
+ for r in ones:
32
+ if r != row:
33
+ G[r, :] ^= G[row, :]
34
+ if col != row:
35
+ G[:, [row, col]] = G[:, [col, row]]
36
+ colperm[[row, col]] = colperm[[col, row]]
37
+ row += 1
38
+ if row < k:
39
+ raise ValueError("Could not find k independent columns to form a systematic generator.")
40
+ return G, colperm
41
+
42
+ def encode_gf2(u, Gsys):
43
+ u = (u.astype(np.uint8) & 1)
44
+ return (u @ Gsys) & 1
45
+
46
+ def weighted_distance_bits(c, r_hard, w):
47
+ diff = c ^ r_hard
48
+ return float(np.sum(w * diff))
49
+
50
+ def osd_decode_minimal(llr_channel, reliab_order, Ls = [55,10]):
51
+ global G
52
+ r = (llr_channel > 0).astype(np.uint8)
53
+ w = np.abs(llr_channel).astype(np.float32)
54
+ k = G.shape[0]
55
+ n = G.shape[1]
56
+ Gsys, colperm = gf2_systematic_from_reliability(G, reliab_order)
57
+ r_sys = r[colperm]
58
+ w_sys = w[colperm]
59
+ u0 = r_sys[:k].copy()
60
+ c0_sys = encode_gf2(u0, Gsys)
61
+ best_c_sys = c0_sys.copy()
62
+ best_m = weighted_distance_bits(best_c_sys, r_sys, w_sys)
63
+ info_reliab = w_sys[:k]
64
+ for t in range(1, len(Ls) + 1):
65
+ flip_pool = np.argsort(info_reliab)[:min(Ls[t-1], k)]
66
+ for comb in combinations(flip_pool, t):
67
+ u = u0.copy()
68
+ u[list(comb)] ^= 1
69
+ c_sys = encode_gf2(u, Gsys)
70
+ m = weighted_distance_bits(c_sys, r_sys, w_sys)
71
+ if m < best_m:
72
+ best_m = m
73
+ best_c_sys = c_sys
74
+ inv = np.empty(n, dtype=np.int64)
75
+ inv[colperm] = np.arange(n)
76
+ best_c_orig = best_c_sys[inv]
77
+ return best_c_orig.astype(np.uint8)
78
+
79
+
80
+ def check_G():
81
+ from PyFT8.cycle_manager import Candidate
82
+ u = np.random.randint(0, 2, size=91, dtype=np.uint8)
83
+ c = (u @ G) & 1
84
+ cand = Candidate()
85
+ cand.llr = np.where(c == 1, +1.0, -1.0)
86
+ assert cand.ldpc.calc_ncheck(self.llr) == 0
87
+
88
+