PyFT8 3.7.3__tar.gz → 3.9.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 (41) hide show
  1. {pyft8-3.7.3 → pyft8-3.9.1}/PKG-INFO +25 -6
  2. {pyft8-3.7.3 → pyft8-3.9.1}/PyFT8/databases.py +6 -5
  3. pyft8-3.9.1/PyFT8/decoders.py +246 -0
  4. {pyft8-3.7.3 → pyft8-3.9.1}/PyFT8/gui.py +47 -38
  5. {pyft8-3.7.3 → pyft8-3.9.1}/PyFT8/pyft8.py +7 -5
  6. {pyft8-3.7.3 → pyft8-3.9.1}/PyFT8/qso_manager.py +3 -5
  7. pyft8-3.9.1/PyFT8/receiver.py +429 -0
  8. {pyft8-3.7.3 → pyft8-3.9.1}/PyFT8/transmitter.py +41 -15
  9. {pyft8-3.7.3 → pyft8-3.9.1}/PyFT8.egg-info/PKG-INFO +25 -6
  10. pyft8-3.9.1/PyFT8.egg-info/SOURCES.txt +20 -0
  11. {pyft8-3.7.3 → pyft8-3.9.1}/PyFT8.egg-info/requires.txt +1 -0
  12. {pyft8-3.7.3 → pyft8-3.9.1}/README.md +23 -5
  13. {pyft8-3.7.3 → pyft8-3.9.1}/pyproject.toml +2 -1
  14. pyft8-3.7.3/MANIFEST.in +0 -2
  15. pyft8-3.7.3/PyFT8/osd.py +0 -88
  16. pyft8-3.7.3/PyFT8/receiver.py +0 -565
  17. pyft8-3.7.3/PyFT8.egg-info/SOURCES.txt +0 -36
  18. pyft8-3.7.3/tests/live test.py +0 -175
  19. pyft8-3.7.3/tests/misc/CQ AAAA.py +0 -30
  20. pyft8-3.7.3/tests/misc/animation.py +0 -36
  21. pyft8-3.7.3/tests/misc/bits_loopback.py +0 -204
  22. pyft8-3.7.3/tests/misc/bits_loopback_short.py +0 -169
  23. pyft8-3.7.3/tests/misc/osd.py +0 -89
  24. pyft8-3.7.3/tests/misc/symbols_loopback.py +0 -81
  25. pyft8-3.7.3/tests/misc/test_generate_wav.py +0 -58
  26. pyft8-3.7.3/tests/misc/test_loopback_performance.py +0 -220
  27. pyft8-3.7.3/tests/misc/text.py +0 -10
  28. pyft8-3.7.3/tests/misc/view_worked_before.py +0 -10
  29. pyft8-3.7.3/tests/pipeline/Fine tuning compare IOS_PC.py +0 -373
  30. pyft8-3.7.3/tests/pipeline/Targetted tests.py +0 -396
  31. pyft8-3.7.3/tests/pipeline/test_01.wav +0 -0
  32. pyft8-3.7.3/tests/plot_decode_times.py +0 -41
  33. {pyft8-3.7.3 → pyft8-3.9.1}/LICENSE +0 -0
  34. {pyft8-3.7.3 → pyft8-3.9.1}/PyFT8/__init__.py +0 -0
  35. {pyft8-3.7.3 → pyft8-3.9.1}/PyFT8/pskreporter.py +0 -0
  36. {pyft8-3.7.3 → pyft8-3.9.1}/PyFT8/rigctrl.py +0 -0
  37. {pyft8-3.7.3 → pyft8-3.9.1}/PyFT8/time_utils.py +0 -0
  38. {pyft8-3.7.3 → pyft8-3.9.1}/PyFT8.egg-info/dependency_links.txt +0 -0
  39. {pyft8-3.7.3 → pyft8-3.9.1}/PyFT8.egg-info/entry_points.txt +0 -0
  40. {pyft8-3.7.3 → pyft8-3.9.1}/PyFT8.egg-info/top_level.txt +0 -0
  41. {pyft8-3.7.3 → pyft8-3.9.1}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyFT8
3
- Version: 3.7.3
3
+ Version: 3.9.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
@@ -13,6 +13,7 @@ Requires-Python: >=3.8
13
13
  Description-Content-Type: text/markdown
14
14
  License-File: LICENSE
15
15
  Requires-Dist: numpy>=1.24.0
16
+ Requires-Dist: scipy
16
17
  Requires-Dist: matplotlib
17
18
  Requires-Dist: pyaudio
18
19
  Requires-Dist: pyserial
@@ -25,12 +26,11 @@ Dynamic: license-file
25
26
 
26
27
  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
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
-
30
- If you're interested in how this works, maybe have a look at [MiniPyFT8](https://github.com/G1OJS/MiniPyFT8) which puts all of the receive code in a single 300 line Python file.
29
+ PyFT8 has a focus on demonstrating FT8 written in Python, but can be used as a standalone replacement for WSJT-x and other software.
31
30
 
32
31
  ## Features
33
32
  - Rx and Tx of standard messages with optional /P and /R
33
+ - Tx uses GFSK waveform as defined in FT8 standard
34
34
  - RX of nonstandard calls plus hashed calls
35
35
  - Launches quickly (~2 seconds on my old Dell Optiplex 790)
36
36
  - Use with or without gui (receive and send messages via command line commands)
@@ -112,9 +112,9 @@ 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 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.
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 89% of the decodes that WSJT-x gets on FAST mode, and about 77% of those it gets on DEEP mode.
116
116
 
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" />
117
+ <img width="900" height="900" alt="decode_times" src="https://github.com/user-attachments/assets/6bdc3d97-5747-41d6-9c89-033f898102f2" />
118
118
 
119
119
 
120
120
  ## Decoding approach
@@ -166,6 +166,25 @@ and distributed under the GNU General Public License v3 (GPL-3.0), hence the use
166
166
 
167
167
  Also thanks to [Robert Morris](https://github.com/rtmrtmrtmrtm) for [basicft8(*1)](https://github.com/rtmrtmrtmrtm/basicft8) - the first code I properly read when I was wondering whether to start this journey. (*1 note: applies to FT8 pre V2)
168
168
 
169
+ ## My other FT8 repositories
170
+
171
+ If you're interested in how this works, maybe have a look at [MiniPyFT8](https://github.com/G1OJS/MiniPyFT8) which puts all of the receive code in a single 300 line Python file.
172
+ <html>
173
+ <table style = 'border: none; vertical-align:top;'>
174
+ <tr><td>
175
+ In October 2026 I started a new project which is a tidier version of PyFT8, called <a href='https://github.com/G1OJS/PyFT8m'>PyFT8m</a>, which has:
176
+ <ul>
177
+ <li>A Tkinter-based GUI</li>
178
+ <li>A separable receiver and transmitter that communicate with the GUI via UDP only</li>
179
+ </ul>
180
+ </td>
181
+ <td>
182
+ <img width="794" height="653" alt="673136630-e53ab21f-4e98-4dfa-b521-9fbeecc9917b" src="https://github.com/user-attachments/assets/50b3e35e-12bd-4b88-81a7-4318d821fedf" />
183
+ </td>
184
+ </tr>
185
+ </table>
186
+ </html>
187
+
169
188
  ## Useful resources:
170
189
  **The QEX paper**
171
190
  - [The FT4 and FT8 Communication Protocols](https://wsjt.sourceforge.io/FT4_FT8_QEX.pdf)
@@ -149,19 +149,20 @@ class History:
149
149
  if data:
150
150
  self._add_myspots_record(data, band, call, r['t'], r['rp'])
151
151
 
152
- def process_message_for_history(self, message, band_info, myCall):
152
+ def process_message_for_history(self, message, myCall):
153
153
  m = message
154
154
  self._write_all_txt_row(m['all_txt_format'])
155
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,
156
+ self._add_myspots_record(self.heard_by_me.data, m['band'],
158
157
  their_call, int(time_utils.time()), m['their_snr'],
159
158
  new_alert_data = self.heard_by_me_new)
160
159
  if hail == myCall: # They're calling me, so they're hearing me
161
160
  rpt = grid_rpt[-3:]
162
161
  if rpt.isnumeric():
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)
162
+ band = m['band']
163
+ if band:
164
+ self._add_myspots_record(self.hearing_me.data, band, their_call,
165
+ int(time_utils.time()), int(rpt), new_alert_data = self.heard_by_me_new)
165
166
 
166
167
 
167
168
  # ----- internal storing / writing functions ----------------------------------------------------------
@@ -0,0 +1,246 @@
1
+ import numpy as np
2
+ from PyFT8.time_utils import time_utils
3
+ from PyFT8.databases import call_hashes, add_call_hashes
4
+ CALLSIGN_PREFIXES1 = "A,B,C,D,E,F,G,H,I,J,K,L,M,N,O,P,R,S,T,U,V,W,X,Y,Z"
5
+ CALLSIGN_PREFIXES2 = "2A,2B,2C,2D,2E,2F,2G,2H,2I,2J,2K,2L,2M,2N,2O,2P,2Q,2R,2S,2T,2U,2V,2W,2X,2Y,2Z,3A,3B,3C,3D,3D,3E,3F,3G,3H,3I,3J,3K,3L,3M,3N,3O,3P,3Q,3R,3S,3T,3U,3V,3W,3X,3Y,3Z,4A,4B,4C,4D,4E,4F,4G,4H,4I,4J,4K,4L,4M,4O,4P,4Q,4R,4S,4T,4U,4V,4W,4X,4Y,4Z,5A,5B,5C,5D,5E,5F,5G,5H,5I,5J,5K,5L,5M,5N,5O,5P,5Q,5R,5S,5T,5U,5V,5W,5X,5Y,5Z,6A,6B,6C,6D,6E,6F,6G,6H,6I,6J,6K,6L,6M,6N,6O,6P,6Q,6R,6S,6T,6U,6V,6W,6X,6Y,6Z,7A,7B,7C,7D,7E,7F,7G,7H,7I,7J,7K,7L,7M,7N,7O,7P,7Q,7R,7S,7T,7U,7V,7W,7X,7Y,7Z,8A,8B,8C,8D,8E,8F,8G,8H,8I,8J,8K,8L,8M,8N,8O,8P,8Q,8R,8S,8T,8U,8V,8W,8X,8Y,8Z,9A,9B,9C,9D,9E,9F,9G,9H,9I,9J,9K,9L,9M,9N,9O,9P,9Q,9R,9S,9T,9U,9V,9W,9X,9Y,9Z,A2,A3,A4,A5,A6,A7,A8,A9,AA,AB,AC,AD,AE,AF,AG,AH,AI,AJ,AK,AL,AM,AN,AO,AP,AQ,AR,AS,AT,AU,AV,AW,AX,AY,AZ,BA,BB,BC,BD,BE,BF,BG,BH,BI,BJ,BK,BL,BM,BN,BO,BP,BQ,BR,BS,BT,BU,BV,BW,BX,BY,BZ,C2,C3,C4,C5,C6,C7,C8,C9,CA,CB,CC,CD,CE,CF,CG,CH,CI,CJ,CK,CL,CM,CN,CO,CP,CQ,CR,CS,CT,CU,CV,CW,CX,CY,CZ,D2,D3,D4,D5,D6,D7,D8,D9,DA,DB,DC,DD,DE,DF,DG,DH,DI,DJ,DK,DL,DM,DN,DO,DP,DQ,DR,DS,DT,DU,DV,DW,DX,DY,DZ,E2,E3,E4,E5,E6,E7,EA,EB,EC,ED,EE,EF,EG,EH,EI,EJ,EK,EL,EM,EN,EO,EP,EQ,ER,ES,ET,EU,EV,EW,EX,EY,EZ,FA,FB,FC,FD,FE,FF,FG,FH,FI,FJ,FK,FL,FM,FN,FO,FP,FQ,FR,FS,FT,FU,FV,FW,FX,FY,FZ,GA,GB,GC,GD,GE,GF,GG,GH,GI,GJ,GK,GL,GM,GN,GO,GP,GQ,GR,GS,GT,GU,GV,GW,GX,GY,GZ,H2,H3,H4,H6,H7,H8,H9,HA,HB,HC,HD,HE,HF,HG,HH,HI,HJ,HK,HL,HM,HN,HO,HP,HQ,HR,HS,HT,HU,HV,HW,HX,HY,HZ,IA,IB,IC,ID,IE,IF,IG,IH,II,IJ,IK,IL,IM,IN,IO,IP,IQ,IR,IS,IT,IU,IV,IW,IX,IY,IZ,J2,J3,J4,J5,J6,J7,J8,JA,JB,JC,JD,JE,JF,JG,JH,JI,JJ,JK,JL,JM,JN,JO,JP,JQ,JR,JS,JT,JU,JV,JW,JX,JY,JZ,KA,KB,KC,KD,KE,KF,KG,KH,KI,KJ,KK,KL,KM,KN,KO,KP,KQ,KR,KS,KT,KU,KV,KW,KX,KY,KZ,L2,L3,L4,L5,L6,L7,L8,L9,LA,LB,LC,LD,LE,LF,LG,LH,LI,LJ,LK,LL,LM,LN,LO,LP,LQ,LR,LS,LT,LU,LV,LW,LX,LY,LZ,MA,MB,MC,MD,ME,MF,MG,MH,MI,MJ,MK,ML,MM,MN,MO,MP,MQ,MR,MS,MT,MU,MV,MW,MX,MY,MZ,NA,NB,NC,ND,NE,NF,NG,NH,NI,NJ,NK,NL,NM,NN,NO,NP,NQ,NR,NS,NT,NU,NV,NW,NX,NY,NZ,OA,OB,OC,OD,OE,OF,OG,OH,OI,OJ,OK,OL,OM,ON,OO,OP,OQ,OR,OS,OT,OU,OV,OW,OX,OY,OZ,P2,P3,P4,P5,P6,P7,P8,P9,PA,PB,PC,PD,PE,PF,PG,PH,PI,PJ,PJ,PJ,PK,PL,PM,PN,PO,PP,PQ,PR,PS,PT,PU,PV,PW,PX,PY,PZ,RA,RB,RC,RD,RE,RF,RG,RH,RI,RJ,RK,RL,RM,RN,RO,RP,RQ,RR,RS,RT,RU,RV,RW,RX,RY,RZ,S2,S3,S5,S6,S7,S8,S9,SA,SB,SC,SD,SE,SF,SG,SH,SI,SJ,SK,SL,SM,SN,SO,SP,SQ,SR,SS,SS,ST,SU,SV,SW,SX,SY,SZ,T2,T3,T4,T5,T6,T7,T8,TA,TB,TC,TD,TE,TF,TG,TH,TI,TJ,TK,TL,TM,TN,TO,TP,TQ,TR,TS,TT,TU,TV,TW,TX,TY,TZ,UA,UB,UC,UD,UE,UF,UG,UH,UI,UJ,UK,UL,UM,UN,UO,UP,UQ,UR,US,UT,UU,UV,UW,UX,UY,UZ,V2,V3,V4,V5,V6,V7,V8,VA,VB,VC,VD,VE,VF,VG,VH,VI,VJ,VK,VL,VM,VN,VO,VP,VQ,VR,VS,VT,VU,VV,VW,VX,VY,VZ,WA,WB,WC,WD,WE,WF,WG,WH,WI,WJ,WK,WL,WM,WN,WO,WP,WQ,WR,WS,WT,WU,WV,WW,WX,WY,WZ,XA,XB,XC,XD,XE,XF,XG,XH,XI,XJ,XK,XL,XM,XN,XO,XP,XQ,XR,XS,XT,XU,XV,XW,XX,XY,XZ,Y2,Y3,Y4,Y5,Y6,Y7,Y8,Y9,YA,YB,YC,YD,YE,YF,YG,YH,YI,YJ,YK,YL,YM,YN,YO,YP,YQ,YR,YS,YT,YU,YV,YW,YX,YY,Z2,Z3,Z8,ZA,ZB,ZC,ZD,ZE,ZF,ZG,ZH,ZI,ZJ,ZK,ZL,ZM,ZN,ZO,ZP,ZQ,ZR,ZS,ZT,ZU,ZV,ZW,ZX,ZY,ZZ"
6
+
7
+ #=========== Unpacking functions ========================================
8
+ def get_bitfields(bits, lengths):
9
+ fields = []
10
+ for n in lengths:
11
+ mask = (1 << n) - 1
12
+ fields.append(bits & mask)
13
+ bits >>= n
14
+ return *fields, bits
15
+
16
+ def unpack(bits):
17
+ if not bits:
18
+ return None
19
+
20
+ i3, bits74 = get_bitfields(bits,[3])
21
+ if i3 == 0:
22
+ n3, bits71 = get_bitfields(bits74,[3])
23
+ if n3 <= 4:
24
+ #return (['Free text', 'DXpedition', 'Field Day', 'Field Day', 'Telemetry'][n3], 'not', 'implemented')
25
+ return None
26
+ else:
27
+ #return ('Unknown mode','not','implemented')
28
+ return None
29
+ elif i3 == 1 or i3 == 2: # 1 = Std Msg incl /R 2 = 'EU VHF' = Std Msg incl /P
30
+ return unpack_std(bits74, i3)
31
+ elif i3 == 3:
32
+ #return ('RTTY RU','not','implemented')
33
+ return None
34
+ elif i3 == 4:
35
+ cq, rrr, swp, c58, hsh, _ = get_bitfields(bits74, [1,2,1,58,12])
36
+ if cq and rrr or (not cq and not rrr):
37
+ return None
38
+ ca = "CQ" if cq else f"<{call_hashes.get((hsh,12), '...')}>"
39
+ cb = ""
40
+ for i in range(12):
41
+ cb = " 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ/"[c58 % 38] + cb
42
+ c58 = c58 // 38
43
+ cb = cb.strip()
44
+ add_call_hashes(cb)
45
+ (ca, cb) = (cb, ca) if swp else (ca, cb)
46
+ return (ca, cb, ('', 'RRR', 'RR73', '73')[rrr])
47
+ elif i3 == 5:
48
+ #return ('EU VHF','not','implemented')
49
+ return None
50
+
51
+ def unpack_std(bits74, i3):
52
+ g16, cb29, ca29, _ = get_bitfields(bits74,[16,29,29])
53
+ g15 = g16 & 0x7FFF
54
+ if g15 == 0:
55
+ return None
56
+ if g15 < 32400:
57
+ a, nn = divmod(g15, 1800)
58
+ b, nn = divmod(nn, 100)
59
+ c, d = divmod(nn, 10)
60
+ grid_rpt = chr(65+a) + chr(65+b) + str(c) + str(d)
61
+ elif g15 - 32400 <= 4:
62
+ grid_rpt = ('', '', 'RRR', 'RR73', '73')[g15 - 32400]
63
+ else:
64
+ prefix = 'R' if (g16 >> 15) else ''
65
+ grid_rpt = prefix + f"{(g15 - 32435):+03d}"
66
+ msg_tuple = (call_29(ca29, i3), call_29(cb29, i3), grid_rpt)
67
+ if not ('' in msg_tuple) and not (None in msg_tuple):
68
+ return msg_tuple
69
+
70
+ def call_29(call_int29, i3):
71
+ portable_rover = call_int29 & 1
72
+ call_int28 = call_int29>>1
73
+ if call_int28 < 3:
74
+ return ['DE', 'QRZ', 'CQ'][call_int28]
75
+ elif call_int28 < 1004:
76
+ return f"CQ {call_int28 - 3:03d}"
77
+ elif call_int28 < 21443:
78
+ x, txt = call_int28 - 1003, ''
79
+ for i in range(4):
80
+ txt = " ABCDEFGHIJKLMNOPQRSTUVWXYZ"[int(x % 27)] + txt
81
+ x //= 27
82
+ return f"CQ {txt.strip()}"
83
+ elif call_int28 < 2063592+4194303:
84
+ return f"<{call_hashes.get((call_int28 - 2063592, 22), '...')}>"
85
+ else:
86
+ call = standard_call28(call_int28, i3)
87
+ if call is not None:
88
+ if portable_rover:
89
+ call = call + ('/P' if i3 == 2 else '/R')
90
+ if call.endswith("/R") and not call[0] in ['A','K','N','W']:
91
+ return None
92
+ add_call_hashes(call)
93
+ return call
94
+
95
+ def standard_call28(call_int28, i3):
96
+ nn = call_int28 - (2063592 + 4194304)
97
+ from string import ascii_uppercase as ltrs, digits as digs
98
+ call_fields = [ (' ' + digs + ltrs, 36*10*27**3), (digs + ltrs, 10*27**3), (digs + ' ' * 17, 27**3),
99
+ (' ' + ltrs, 27**2), (' ' + ltrs, 27), (' ' + ltrs, 1) ]
100
+ chars = []
101
+ for alphabet, div in call_fields:
102
+ idx, nn = divmod(nn, div)
103
+ chars.append(alphabet[idx])
104
+ call = ''.join(chars).strip()
105
+ return simple_validate_call(call)
106
+
107
+ def simple_validate_call(call):
108
+ if not ' ' in call and len(call)>=3:
109
+ if call[0] in CALLSIGN_PREFIXES1 and call[1].isnumeric():
110
+ if not (call[0] in "B,F,G,I,K,M,N,R,W" and call[2].isnumeric()):
111
+ return call
112
+ if call[:2] in CALLSIGN_PREFIXES2 and call[2].isnumeric():
113
+ return call
114
+
115
+ def crc_unpack91(codeword91):
116
+ bits91_int = 0
117
+ for bit in (codeword91 > 0).astype(int).tolist():
118
+ bits91_int = (bits91_int << 1) | bit
119
+ bits77_int = bits91_int >> 14
120
+ msg = None
121
+ if(bits77_int > 0):
122
+ crc14_int = 0
123
+ for i in range(96):
124
+ inbit = ((bits77_int >> (76 - i)) & 1) if i < 77 else 0
125
+ bit14 = (crc14_int >> (14 - 1)) & 1
126
+ crc14_int = ((crc14_int << 1) & ((1 << 14) - 1)) | inbit
127
+ if bit14:
128
+ crc14_int ^= 0x2757
129
+ if crc14_int == bits91_int & 0b11111111111111:
130
+ msg = unpack(bits77_int)
131
+ return msg, bits77_int
132
+
133
+ #============== LDPC ===========================================================
134
+ CV6idx = np.array([[4,31,59,92,114,145],[5,23,60,93,121,150],[6,32,61,94,95,142],[5,31,63,96,125,137],[8,34,65,98,138,145],[9,35,66,99,106,125],[11,37,67,101,104,154],[12,38,68,102,148,161],[14,41,58,105,122,158],[0,32,71,105,106,156],[15,42,72,107,140,159],[10,43,74,109,120,165],[7,45,70,111,118,165],[18,37,76,103,115,162],[19,46,69,91,137,164],[1,47,73,112,127,159],[21,46,57,117,126,163],[15,38,61,111,133,157],[22,42,78,119,130,144],[19,35,62,93,135,160],[13,30,78,97,131,163],[2,43,79,123,126,168],[18,45,80,116,134,166],[11,49,60,117,118,143],[12,50,63,113,117,156],[23,51,75,128,147,148],[20,53,76,99,139,170],[34,81,132,141,170,173],[13,29,82,112,124,169],[3,28,67,119,133,172],[51,83,109,114,144,167],[6,49,80,98,131,172],[22,54,66,94,171,173],[25,40,76,108,140,147],[26,39,55,123,124,125],[17,48,54,123,140,166],[5,32,84,107,115,155],[8,53,62,130,146,154],[21,52,67,108,120,173],[2,12,47,77,94,122],[30,68,132,149,154,168],[4,38,74,101,135,166],[1,53,85,100,134,163],[14,55,86,107,118,170],[22,33,70,93,126,152],[10,48,87,91,141,156],[28,33,86,96,146,161],[21,56,84,92,139,158],[27,31,71,102,131,165],[0,25,44,79,127,146],[16,26,88,102,115,152],[50,56,97,162,164,171],[20,36,72,137,151,168],[15,46,75,129,136,153],[2,23,29,71,103,138],[8,39,89,105,133,150],[17,41,78,143,145,151],[24,37,64,98,121,159],[16,41,74,128,169,171]], dtype = np.int16)
135
+ CV7idx = np.array([[3,30,58,90,91,95,152],[7,24,62,82,92,95,147],[4,33,64,77,97,106,153],[10,36,66,86,100,138,157],[7,39,69,81,103,113,144],[13,40,70,87,101,122,155],[16,36,73,80,108,130,153],[44,54,63,110,129,160,172],[17,35,75,88,112,113,142],[20,44,77,82,116,120,150],[18,34,58,72,109,124,160],[6,48,57,89,99,104,167],[24,52,68,89,100,129,155],[19,45,64,79,119,139,169],[0,3,51,56,85,135,151],[25,50,55,90,121,136,167],[1,26,40,60,61,114,132],[27,47,69,84,104,128,157],[11,42,65,88,96,134,158],[9,43,81,90,110,143,148],[29,49,59,85,136,141,161],[9,52,65,83,111,127,164],[27,28,83,87,116,142,149],[14,57,59,73,110,149,162]], dtype = np.int16)
136
+
137
+ import warnings
138
+ warnings.filterwarnings("error")
139
+
140
+ def pass_ldpc_messages(llr, CVidx, mC2V_prev, update_collector):
141
+ mV2C = llr[CVidx] - mC2V_prev
142
+ tanh_mV2C = np.tanh(-mV2C)
143
+ tanh_mC2V = np.prod(tanh_mV2C, axis=1, keepdims=True)
144
+ orig_err = np.geterr()
145
+ np.seterr(all = 'ignore')
146
+ tanh_mC2V = np.divide(tanh_mC2V, tanh_mV2C)
147
+ np.seterr(**orig_err)
148
+ alpha_atanh_approx = 1.18
149
+ mC2V_curr = tanh_mC2V / ((tanh_mC2V - alpha_atanh_approx) * (alpha_atanh_approx + tanh_mC2V))
150
+ np.add.at(update_collector, CVidx, mC2V_curr - mC2V_prev)
151
+ return mC2V_curr
152
+
153
+ def ldpc_decode(llr, max_ncheck0, max_iters):
154
+ mC2V_prev6, mC2V_prev7 = np.zeros(CV6idx.shape, dtype=np.float32), np.zeros(CV7idx.shape, dtype=np.float32)
155
+ for n_its in range(max_iters):
156
+ bits6, bits7 = llr[CV6idx] > 0, llr[CV7idx] > 0
157
+ parity6, parity7 = np.sum(bits6, axis=1) & 1, np.sum(bits7, axis=1) & 1
158
+ ncheck = int(np.sum(parity7) + np.sum(parity6))
159
+ if n_its == 0 and ncheck > max_ncheck0:
160
+ return None, -1, []
161
+ if ncheck == 0:
162
+ msg_tuple, bits77_int = crc_unpack91(llr[:91])
163
+ if msg_tuple:
164
+ return msg_tuple, n_its, bits77_int
165
+ else:
166
+ update_collector = np.zeros_like(llr)
167
+ mC2V_prev6 = pass_ldpc_messages(llr, CV6idx, mC2V_prev6, update_collector)
168
+ mC2V_prev7 = pass_ldpc_messages(llr, CV7idx, mC2V_prev7, update_collector)
169
+ llr += update_collector
170
+
171
+ return None, -1, llr
172
+
173
+ #============== OSD ===========================================================
174
+ 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"]
175
+ kGEN = np.array([int(row,16)>>1 for row in generator_matrix_rows])
176
+ A = np.zeros((83, 91), dtype=np.uint8)
177
+ for i, row in enumerate(kGEN):
178
+ for j in range(91):
179
+ A[i, 90 - j] = (row >> j) & 1
180
+ G0 = np.concatenate([np.eye(91, dtype=np.uint8), A.T],axis=1)
181
+
182
+ def osd(llr):
183
+ chbits174 = (llr>0).astype(np.uint8)
184
+ chvals174 = np.abs(llr)
185
+
186
+ rowperm = np.arange(91)
187
+ colperm = np.argsort(-np.abs(llr))
188
+ curr_row = 0
189
+ G = G0.copy()
190
+ for curr_col in range(174):
191
+ ones_below = np.where(G[rowperm[curr_row:], colperm[curr_col]] == 1)[0]
192
+ if ones_below.size > 0:
193
+ swap_row = curr_row + ones_below[0]
194
+ rowperm[[curr_row, swap_row]] = rowperm[[swap_row, curr_row]]
195
+ r_curr = rowperm[curr_row]
196
+ c_curr = colperm[curr_col]
197
+ g_c_curr = G[:, c_curr].copy()
198
+ g_c_curr[r_curr] = 0
199
+ rows_to_xor = np.where(g_c_curr == 1)[0]
200
+ G[rows_to_xor, :] ^= G[r_curr, :]
201
+ colperm[[curr_row, curr_col]] = colperm[[curr_col, curr_row]]
202
+ curr_row += 1
203
+ if curr_row > 90:
204
+ break
205
+
206
+ chbits91 = chbits174[colperm][:91]
207
+ chbits91[rowperm] = chbits91
208
+
209
+ base_cw = ((chbits91 @ G) & 1)
210
+ msg_tuple, bits77_int = crc_unpack91(base_cw[:91])
211
+ if msg_tuple:
212
+ return msg_tuple, bits77_int
213
+
214
+ fliplist = rowperm[::-1]
215
+ current_best_distance = 1e20
216
+ cw_out91 = None
217
+ for i in range(91):
218
+ # Single flip
219
+ cw = base_cw ^ G[fliplist[i]]
220
+ # Double flips with every j < i
221
+ if i:
222
+ cw2 = base_cw ^ G[fliplist[i]] ^ G[fliplist[:i]]
223
+ candidates = np.vstack((cw, cw2))
224
+ else:
225
+ candidates = cw[None, :]
226
+
227
+ # np.dot maybe?
228
+ distances = np.sum(np.abs(llr)[None, :] * (candidates != chbits174), axis=1)
229
+ best_idx = np.argmin(distances)
230
+ if distances[best_idx] < current_best_distance:
231
+ current_best_distance = distances[best_idx]
232
+ cw_out91 = candidates[best_idx, :91].copy()
233
+
234
+ if cw_out91 is not None:
235
+ msg_tuple, bits77_int = crc_unpack91(cw_out91)
236
+ if msg_tuple:
237
+ return msg_tuple, bits77_int
238
+
239
+ return None, None
240
+
241
+
242
+
243
+
244
+
245
+
246
+
@@ -132,11 +132,12 @@ class Panel:
132
132
 
133
133
  class Gui:
134
134
  def __init__(self, myCall, myGrid, console_print, qso_manager, history,
135
- band_frequencies, set_receiver_band, waterfall_data, hearing_me_since_mins, geo_units):
135
+ band_frequencies, set_receiver_band, waterfall_data, hearing_me_since_mins, geo_units, nodisplay = False):
136
136
  self.hearing_me_since_mins = hearing_me_since_mins
137
137
  self.waterfall_data = waterfall_data
138
138
  self.qso_manager = qso_manager
139
139
  self.history = history
140
+ self.nodisplay = nodisplay
140
141
  self.set_receiver_band = set_receiver_band
141
142
 
142
143
  self.myCall, self.myGrid = myCall, myGrid
@@ -187,11 +188,11 @@ class Gui:
187
188
  threading.Thread(target = self._slow_loop, args = (testing,), daemon = True).start()
188
189
  self._fast_loop()
189
190
 
190
- def _fast_loop(self):
191
+ def _fast_loop(self, target_update_time = 0.15):
191
192
  self.plt.show(block = False)
192
193
  while True:
193
- self.fig.canvas.flush_events() # for clicks
194
194
  tstart_wf_redraw = time_utils.time()
195
+ self.fig.canvas.flush_events() # for clicks
195
196
  if self.needs_redraw:
196
197
  self.needs_redraw = False
197
198
  plt.pause(0.01)
@@ -205,40 +206,35 @@ class Gui:
205
206
  self.fig.canvas.flush_events() # for clicks
206
207
  self.fig.canvas.blit(self.ax_wf.bbox) #self.fig.canvas.update()
207
208
  self.fig.canvas.flush_events()
208
- self._do_timed_checks(tstart_wf_redraw)
209
-
210
- def _do_timed_checks(self, tstart_wf_redraw, target_update_time = 0.15):
211
- ct = time_utils.cycle_time()
212
- if ct < self.cycle_time_prev:
213
- self.new_cycle = True
214
- self._refresh_home_panel()
215
- self._refresh_band_buttons()
216
- self._refresh_hearing()
217
- self.needs_redraw = True
218
- self.cycle_time_prev = ct
219
- if ct > 10 and self.new_cycle:
220
- for mb in self.msg_boxes:
221
- mb.hide()
222
- self.needs_redraw = True
223
- self.new_cycle = False
224
- tnow = time_utils.time()
225
- tdelay = target_update_time - (tnow - tstart_wf_redraw)
226
- if tdelay > 0.01:
227
- time_utils.sleep(tdelay)
209
+ tnow = time_utils.time()
210
+ tdelay = target_update_time - (tnow - tstart_wf_redraw)
211
+ if tdelay > 0.01:
212
+ time_utils.sleep(tdelay)
228
213
 
229
214
  def _slow_loop(self, testing):
230
215
  while True:
231
216
  time_utils.sleep(0.25)
217
+
218
+ ct = time_utils.cycle_time()
219
+ if ct < self.cycle_time_prev:
220
+ self._refresh_panels()
221
+ self.new_cycle = True
222
+ self.cycle_time_prev = ct
223
+
224
+ if ct > 10 and self.new_cycle:
225
+ self._hide_msg_boxes()
226
+ self.new_cycle = False
227
+
232
228
  while not self.message_queue_non_time_critical.empty():
233
- time_utils.sleep(0.01)
229
+ time_utils.sleep(0.05)
234
230
  m = self.message_queue_non_time_critical.get()
235
231
  if not testing and m['msg_tuple'][1] != 'not':
236
- self.history.process_message_for_history(m, self.band_info, self.myCall)
232
+ self.history.process_message_for_history(m, self.myCall)
237
233
 
238
234
  if not m['priority']:
239
235
  self._display_message(m)
240
236
 
241
- if m['message_type'] == 'CQ':
237
+ if m['message_type'] == 'CQ' and self.history:
242
238
  for mb in self.msg_boxes:
243
239
  if mb.patch.get_visible():
244
240
  if mb.message['msg_tuple'] == m['msg_tuple']:
@@ -251,7 +247,17 @@ class Gui:
251
247
  new_text = f"{' '.join(m['msg_tuple'])} {hearing_me} {wb_text} {geo_text}"
252
248
  mb.set_text(new_text)
253
249
 
250
+ def _refresh_panels(self):
251
+ self._refresh_home_panel()
252
+ self._refresh_band_buttons()
253
+ self._refresh_hearing()
254
+ self.needs_redraw = True
254
255
 
256
+ def _hide_msg_boxes(self):
257
+ for mb in self.msg_boxes:
258
+ mb.hide()
259
+ self.needs_redraw = True
260
+
255
261
  def process_message(self, m):
256
262
  hail, their_call, _ = m['msg_tuple']
257
263
  message_type_val = 0 + 1*(their_call == self.myCall) + 2*(hail == self.myCall) + 3*(their_call != self.myCall and hail.startswith('CQ'))
@@ -260,14 +266,16 @@ class Gui:
260
266
  m.update( {'message_type':message_type, 'priority':priority} )
261
267
  if priority:
262
268
  self._display_message(m)
263
- self.qso_manager.process_message(m)
269
+ if self.qso_manager:
270
+ self.qso_manager.process_message(m)
264
271
  self.message_queue_non_time_critical.put(m)
265
272
 
266
273
  def _display_message(self, m):
267
- mb = self._get_message_box()
268
- x = int(m['tsec'] / self.waterfall_data['dt'] + m['their_tx_cycle'] * self.waterfall_data['pixels_per_cycle'])
269
- y = int(m['fHz'] / self.waterfall_data['df'])
270
- mb.set_props(x, y, m)
274
+ if not self.nodisplay:
275
+ mb = self._get_message_box()
276
+ x = int(m['tsec'] / self.waterfall_data['dt'] + m['their_tx_cycle'] * self.waterfall_data['pixels_per_cycle'])
277
+ y = int(m['fHz'] / self.waterfall_data['df'])
278
+ mb.set_props(x, y, m)
271
279
 
272
280
  def set_bandstats_title(self, txt):
273
281
  self.home_panel.ax.set_title(txt, fontsize = 10)
@@ -329,13 +337,14 @@ class Gui:
329
337
  self.home_panel.print_row(f"{n_spotted:<7} {rx_lead[1]:<7}", 4, color = '#b6f0c6' )
330
338
 
331
339
  def _refresh_band_buttons(self):
332
- current_band = self.band_info['current_band']
333
- grd = self.myGrid[:4]
334
- for bb in self.button_boxes:
335
- bb.set_state(bb.id == current_band)
336
- if bb.id in self.history.home_activity:
337
- cnts = self.history.home_activity[bb.id]
338
- bb.set_info_text(f"{cnts[0]}Tx, {cnts[1]}Rx")
340
+ if self.history is not None:
341
+ current_band = self.band_info['current_band']
342
+ grd = self.myGrid[:4]
343
+ for bb in self.button_boxes:
344
+ bb.set_state(bb.id == current_band)
345
+ if bb.id in self.history.home_activity:
346
+ cnts = self.history.home_activity[bb.id]
347
+ bb.set_info_text(f"{cnts[0]}Tx, {cnts[1]}Rx")
339
348
 
340
349
  def _refresh_hearing(self):
341
350
  current_band = self.band_info['current_band']
@@ -10,7 +10,7 @@ from PyFT8.databases import History
10
10
  from PyFT8.qso_manager import QSO_manager
11
11
  from PyFT8.databases import History
12
12
 
13
- VER = '3.7.3'
13
+ VER = '3.9.0'
14
14
  PSKR_REFRESH_MINS = 20
15
15
 
16
16
  def get_config(config_folder):
@@ -39,6 +39,7 @@ def get_config(config_folder):
39
39
  return config
40
40
 
41
41
  def cli():
42
+ n_decodes = 0
42
43
 
43
44
  def console_print(text, color = 'white'):
44
45
  text = f"{time_utils.cycle_time():4.1f} {text}"
@@ -48,6 +49,7 @@ def cli():
48
49
  print(text)
49
50
 
50
51
  def process_message(m):
52
+ nonlocal n_decodes
51
53
  if gui:
52
54
  gui.process_message(m)
53
55
 
@@ -58,8 +60,8 @@ def cli():
58
60
  fHz = float(m['fHz'])
59
61
  pskr_upload.add_report(their_call, int(1000000*fMHz + fHz),
60
62
  m['their_snr'], 'FT8', 1, int(time_utils.time()))
61
-
62
- print(f"{m['all_txt_format']:50s} decoded@ {m['decode_completed']%15 :5.1f}s, dec = {m['decode_status']}")
63
+ n_decodes += 1
64
+ print(f"{n_decodes:4d} {m['all_txt_format']:50s} decoded@ {m['decode_completed']%15 :5.1f}s, dec = {m['decode_notes']}")
63
65
 
64
66
  parser = argparse.ArgumentParser(prog='PyFT8rx', description = 'Command Line FT8 decoder')
65
67
  parser.add_argument('-c', '--config_folder', help = 'Location of config folder e.g. C:/Users/drala/Documents/Projects/GitHub/G1OJS/PyFT8_cfg', default = './')
@@ -132,8 +134,8 @@ def cli():
132
134
  pskr_upload = PSKR_upload(myCall, myGrid, software = f"PyFT8 v{VER}", console_print = console_print)
133
135
 
134
136
  # Set up for receiving with or without Gui
135
- receiver = Receiver(args.inputcard_keywords, process_message, sync_score_min = 90, max_cands = 100,
136
- search_freq_range = [100, 3000], search_timerange = [-1, 3.5])
137
+ receiver = Receiver(args.inputcard_keywords, process_message, sync_score_min = 100, max_cands = 150,
138
+ search_freq_range = [100, 3000], search_timerange = [-2, 3])
137
139
  if not receiver.audio_in.input_device_idx:
138
140
  time_utils.tlog(f"[Audio] No input audio device found matching {input_device_keywords}", verbose = True)
139
141
  sys.exit(1)
@@ -34,13 +34,10 @@ class QSO_manager:
34
34
  history.add_bidirectional_hearing_heard_spot(band, their_call, time_on)
35
35
 
36
36
  def _find_clear_freq(self, fmax):
37
- from numpy.lib.stride_tricks import sliding_window_view
38
37
  import numpy as np
39
- fbin_sum = np.sum(self.waterfall_data['data'], axis = 0)
40
- windows = sliding_window_view(fbin_sum, self.waterfall_data['sig_h'])
41
- busy_profile = windows.max(axis=1)
38
+ fbin_sum = np.sum(self.waterfall_data['data'], axis = 1)
42
39
  f0_idx, fn_idx = int(500/self.waterfall_data['df']), int(fmax/self.waterfall_data['df'])
43
- idx = np.argmin(busy_profile[f0_idx:fn_idx])
40
+ idx = np.argmin(fbin_sum[f0_idx:fn_idx])
44
41
  clearest_frequency = (f0_idx + idx) * self.waterfall_data['df']
45
42
  return clearest_frequency
46
43
 
@@ -122,6 +119,7 @@ class QSO_manager:
122
119
  message_type = m['message_type']
123
120
  if message_type in ['CQ','to_me'] and m['band'] == self.band_info['current_band']:
124
121
  _ , their_call, grid_rpt = m['msg_tuple']
122
+ their_call = their_call.replace('<','').replace('>','')
125
123
  their_snr = m['their_snr']
126
124
  their_tx_cycle = m['their_tx_cycle']
127
125
  if their_call != self.in_qso_with: