PyFT8 3.7.1__tar.gz → 3.7.4__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 (44) hide show
  1. {pyft8-3.7.1 → pyft8-3.7.4}/PKG-INFO +5 -4
  2. {pyft8-3.7.1 → pyft8-3.7.4}/PyFT8/databases.py +148 -127
  3. {pyft8-3.7.1 → pyft8-3.7.4}/PyFT8/gui.py +151 -120
  4. {pyft8-3.7.1 → pyft8-3.7.4}/PyFT8/pyft8.py +48 -38
  5. {pyft8-3.7.1 → pyft8-3.7.4}/PyFT8/qso_manager.py +67 -29
  6. {pyft8-3.7.1 → pyft8-3.7.4}/PyFT8/receiver.py +55 -114
  7. {pyft8-3.7.1 → pyft8-3.7.4}/PyFT8/time_utils.py +3 -4
  8. {pyft8-3.7.1 → pyft8-3.7.4}/PyFT8.egg-info/PKG-INFO +5 -4
  9. {pyft8-3.7.1 → pyft8-3.7.4}/PyFT8.egg-info/SOURCES.txt +1 -5
  10. {pyft8-3.7.1 → pyft8-3.7.4}/README.md +4 -3
  11. {pyft8-3.7.1 → pyft8-3.7.4}/pyproject.toml +1 -1
  12. pyft8-3.7.4/tests/live test.py +175 -0
  13. pyft8-3.7.4/tests/plot_decode_times.py +41 -0
  14. pyft8-3.7.1/PyFT8/message_broker.py +0 -69
  15. pyft8-3.7.1/tests/play_wavs.py +0 -54
  16. pyft8-3.7.1/tests/plot_decode_times.py +0 -26
  17. pyft8-3.7.1/tests/spare.py +0 -52
  18. pyft8-3.7.1/tests/spare2.py +0 -37
  19. pyft8-3.7.1/tests/test_batch_and_live.py +0 -160
  20. {pyft8-3.7.1 → pyft8-3.7.4}/LICENSE +0 -0
  21. {pyft8-3.7.1 → pyft8-3.7.4}/MANIFEST.in +0 -0
  22. {pyft8-3.7.1 → pyft8-3.7.4}/PyFT8/__init__.py +0 -0
  23. {pyft8-3.7.1 → pyft8-3.7.4}/PyFT8/osd.py +0 -0
  24. {pyft8-3.7.1 → pyft8-3.7.4}/PyFT8/pskreporter.py +0 -0
  25. {pyft8-3.7.1 → pyft8-3.7.4}/PyFT8/rigctrl.py +0 -0
  26. {pyft8-3.7.1 → pyft8-3.7.4}/PyFT8/transmitter.py +0 -0
  27. {pyft8-3.7.1 → pyft8-3.7.4}/PyFT8.egg-info/dependency_links.txt +0 -0
  28. {pyft8-3.7.1 → pyft8-3.7.4}/PyFT8.egg-info/entry_points.txt +0 -0
  29. {pyft8-3.7.1 → pyft8-3.7.4}/PyFT8.egg-info/requires.txt +0 -0
  30. {pyft8-3.7.1 → pyft8-3.7.4}/PyFT8.egg-info/top_level.txt +0 -0
  31. {pyft8-3.7.1 → pyft8-3.7.4}/setup.cfg +0 -0
  32. {pyft8-3.7.1 → pyft8-3.7.4}/tests/misc/CQ AAAA.py +0 -0
  33. {pyft8-3.7.1 → pyft8-3.7.4}/tests/misc/animation.py +0 -0
  34. {pyft8-3.7.1 → pyft8-3.7.4}/tests/misc/bits_loopback.py +0 -0
  35. {pyft8-3.7.1 → pyft8-3.7.4}/tests/misc/bits_loopback_short.py +0 -0
  36. {pyft8-3.7.1 → pyft8-3.7.4}/tests/misc/osd.py +0 -0
  37. {pyft8-3.7.1 → pyft8-3.7.4}/tests/misc/symbols_loopback.py +0 -0
  38. {pyft8-3.7.1 → pyft8-3.7.4}/tests/misc/test_generate_wav.py +0 -0
  39. {pyft8-3.7.1 → pyft8-3.7.4}/tests/misc/test_loopback_performance.py +0 -0
  40. {pyft8-3.7.1 → pyft8-3.7.4}/tests/misc/text.py +0 -0
  41. {pyft8-3.7.1 → pyft8-3.7.4}/tests/misc/view_worked_before.py +0 -0
  42. {pyft8-3.7.1 → pyft8-3.7.4}/tests/pipeline/Fine tuning compare IOS_PC.py +0 -0
  43. {pyft8-3.7.1 → pyft8-3.7.4}/tests/pipeline/Targetted tests.py +0 -0
  44. {pyft8-3.7.1 → pyft8-3.7.4}/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.1
3
+ Version: 3.7.4
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 [![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/)
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 very close to that of WSJT-x 2.7.0 running in FAST mode.
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,10 @@ 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 the same 10 minutes of busy 20m audio that is used to test ft8_lib. On this dataset, WSJT-x performs almost the same on DEEP mode as it does on NORM, and both beat PyFT8. However, PyFT8 gets very close to WSJT-x's performance on FAST 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 90% of the decodes that WSJT-x gets on FAST mode, and about 78% of those it gets on DEEP mode.
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" />
116
118
 
117
- <img width="900" height="900" alt="live playback vs wsjtx_v2 7 0" src="https://github.com/user-attachments/assets/853e98b2-5b9b-4241-b5f3-181fe193642e" />
118
119
 
119
120
  ## Decoding approach
120
121
  The approach to decoding, as at v3.7.0, is as follows.
@@ -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
- def grid_to_latlong(grid, centre = True):
26
- lat, lon = -90, -180
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 = '', geo_units = 'km', pskr_refresh_mins = 20):
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 start_collect_new(self):
109
- mqtt = PSKR_MQTT_listener(self.home_square_lev4, self.add_mqtt_spot)
110
- threading.Thread(target = self.count_activity, daemon = True).start()
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.parse_all_txt()
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
- TxRx = 'Tx' if (r['TxRx'] == 'Tx' or r['call_b'] == self.my_call) else 'Rx'
137
- call = r['call_b'] if TxRx == 'Rx' else r['call_a']
138
- if len(call) > 3:
139
- data = self.heard_by_me.data if TxRx == 'Rx' else self.hearing_me.data
140
- self._add_myspots_record(data, None, band, call, 0, 0)
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 parse_all_txt(self):
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['cyclestart_string'], float(band_info['fMHz']), 'Rx', 'FT8',
161
- m['their_snr'], m['dt'], m['fHz'], m['hail'], m['their_call'], m['grid_rpt'])
162
- self._add_myspots_record(self.heard_by_me.data, self.heard_by_me_new,
163
- band_info['current_band'], m['their_call'], int(time_utils.time()), m['their_snr'])
164
- if m['hail'] == myCall: # They're calling me, so they're hearing me
165
- rpt = m['grid_rpt'][-3:]
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, self.hearing_me_new,
168
- band_info['current_band'], m['their_call'], int(time_utils.time()), int(rpt))
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, cyclestart_string, fMHz, TxRx, mode, snr_str, dt, fHz, hail, their_call, grid_rpt):
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"{row}\n")
172
+ f.write(f"{row_text}\n")
176
173
 
177
- def add_mqtt_spot(self, d):
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.store_best_grid(call, grid)
179
+ self._store_best_grid(call, grid)
183
180
  if self.home_square_lev4 in grid:
184
- self.add_homespots_record(f"{d['b']}_{iTxRx}_{call}", tnow)
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, self.hearing_me_new, d['b'], d['rc'], tnow, d['rp'])
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, self.heard_by_me_new, d['b'], d['sc'], tnow, d['rp'])
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 store_best_grid(self, call, grid):
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 _add_myspots_record(self, historic_data, new_alert_data, band, call, t, rp):
201
- self._update_new_alert(band, call, historic_data, new_alert_data)
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
- historic_data[band][call] = {'t': int(t),'rp':int(rp)}
223
+ if t > 0:
224
+ historic_data[band][call] = {'t': int(t),'rp':int(rp)}
207
225
 
208
- def _update_new_alert(self, band, call, historic_data, new_alert_data):
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 is_in_new_alert(self, band, call, new_alert_data):
219
- result = False
220
- if band in new_alert_data:
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
- def is_hearing_me(self, band, call, since_seconds = 120):
225
- result = False
226
- if band in self.hearing_me.data:
227
- if call in self.hearing_me.data[band]:
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
- def get_spot_counts(self, band, call):
266
- tx_reports = self.band_TxRx_homecall_recent_L4grid.data.get(f"{band}_0_{call}", [])
267
- rx_reports = self.band_TxRx_homecall_recent_L4grid.data.get(f"{band}_1_{call}", [])
268
- n_spotting = len(tx_reports) if tx_reports else 0
269
- n_spotted = len(rx_reports) if rx_reports else 0
270
- return n_spotted, n_spotting
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
- def get_message_extra_info(self, their_call, current_band):
277
- geo_text = self.get_geo_text(their_call)
278
- wb_time = self.log_cache.get(their_call,'')
279
- wb_text = f"wb: {time_utils.format_duration(time_utils.time() - float(wb_time))}" if wb_time else ''
280
- hearing_me = '# ' if self.is_hearing_me(current_band, their_call) else ' '
281
- return hearing_me, wb_text, geo_text
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
- def get_geo_text(self, call):
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):