Skip to content

Commit cfae883

Browse files
authored
use costas loop for PSK demod (#807)
1 parent 1fcf4ae commit cfae883

File tree

12 files changed

+249
-99
lines changed

12 files changed

+249
-99
lines changed

data/azure-pipelines.yml

+7-9
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ jobs:
1919
python.version: '3.7'
2020
Python38:
2121
python.version: '3.8'
22+
Python39:
23+
python.version: '3.9'
2224

2325
steps:
2426
- task: UsePythonVersion@0
@@ -114,7 +116,7 @@ jobs:
114116

115117
- job: 'Windows'
116118
pool:
117-
vmImage: 'vs2017-win2016'
119+
vmImage: 'windows-2019'
118120
strategy:
119121
matrix:
120122
Python37-64:
@@ -240,16 +242,12 @@ jobs:
240242
displayName: "download and unpack SDR drivers"
241243
242244
- script: |
243-
python -m pip install --upgrade pip
245+
python -m pip install --upgrade pip wheel setuptools
244246
python -m pip install --upgrade -r data/requirements.txt
245-
python -m pip install --upgrade pytest pytest-faulthandler
246-
displayName: 'Install dependencies'
247-
248-
- script: |
249-
brew install airspy hackrf librtlsdr portaudio uhd
250-
python -m pip install --upgrade wheel twine six appdirs packaging setuptools pyinstaller pyaudio
247+
HOMEBREW_NO_INSTALL_CLEANUP=TRUE brew install airspy hackrf librtlsdr portaudio uhd
248+
python -m pip install --upgrade pytest pytest-faulthandler twine six appdirs packaging pyinstaller pyaudio
251249
python -c "import tempfile, os; open(os.path.join(tempfile.gettempdir(), 'urh_releasing'), 'w').close()"
252-
displayName: "Install build dependencies"
250+
displayName: 'Install dependencies'
253251
254252
- script: python src/urh/cythonext/build.py
255253
displayName: "Build extensions"

src/urh/ainterpretation/AutoInterpretation.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -382,11 +382,11 @@ def estimate(iq_array: IQArray, noise: float = None, modulation: str = None) ->
382382
message_indices = merge_message_segments_for_ook(message_indices)
383383

384384
if modulation == "OOK" or modulation == "ASK":
385-
data = signal_functions.afp_demod(iq_array.data, noise, "ASK")
385+
data = signal_functions.afp_demod(iq_array.data, noise, "ASK", 2)
386386
elif modulation == "FSK":
387-
data = signal_functions.afp_demod(iq_array.data, noise, "FSK")
387+
data = signal_functions.afp_demod(iq_array.data, noise, "FSK", 2)
388388
elif modulation == "PSK":
389-
data = signal_functions.afp_demod(iq_array.data, noise, "PSK")
389+
data = signal_functions.afp_demod(iq_array.data, noise, "PSK", 2)
390390
else:
391391
raise ValueError("Unsupported Modulation")
392392

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
from PyQt5.QtCore import Qt, pyqtSlot
2+
from PyQt5.QtWidgets import QDialog
3+
4+
from urh.ui.ui_costa import Ui_DialogCosta
5+
6+
7+
class CostaOptionsDialog(QDialog):
8+
def __init__(self, loop_bandwidth, parent=None):
9+
super().__init__(parent)
10+
self.ui = Ui_DialogCosta()
11+
self.ui.setupUi(self)
12+
self.setAttribute(Qt.WA_DeleteOnClose)
13+
self.setWindowFlags(Qt.Window)
14+
15+
self.costas_loop_bandwidth = loop_bandwidth
16+
self.ui.doubleSpinBoxLoopBandwidth.setValue(self.costas_loop_bandwidth)
17+
18+
self.create_connects()
19+
20+
def create_connects(self):
21+
self.ui.buttonBox.accepted.connect(self.accept)
22+
self.ui.buttonBox.rejected.connect(self.reject)
23+
self.ui.doubleSpinBoxLoopBandwidth.valueChanged.connect(self.on_spinbox_loop_bandwidth_value_changed)
24+
25+
@pyqtSlot(float)
26+
def on_spinbox_loop_bandwidth_value_changed(self, value):
27+
self.costas_loop_bandwidth = value

src/urh/controller/widgets/SignalFrame.py

+24-4
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
from urh import settings
1212
from urh.controller.dialogs.AdvancedModulationOptionsDialog import AdvancedModulationOptionsDialog
13+
from urh.controller.dialogs.CostaOptionsDialog import CostaOptionsDialog
1314
from urh.controller.dialogs.FilterDialog import FilterDialog
1415
from urh.controller.dialogs.SendDialog import SendDialog
1516
from urh.controller.dialogs.SignalDetailsDialog import SignalDetailsDialog
@@ -276,7 +277,7 @@ def refresh_signal_information(self, block=True):
276277
self.ui.spinBoxSamplesPerSymbol.setValue(self.signal.samples_per_symbol)
277278
self.ui.spinBoxNoiseTreshold.setValue(self.signal.noise_threshold_relative)
278279
self.ui.cbModulationType.setCurrentText(self.signal.modulation_type)
279-
self.ui.btnAdvancedModulationSettings.setVisible(self.ui.cbModulationType.currentText() == "ASK")
280+
self.ui.btnAdvancedModulationSettings.setVisible(self.ui.cbModulationType.currentText() in ("ASK", "PSK"))
280281
self.ui.spinBoxCenterSpacing.setValue(self.signal.center_spacing)
281282
self.ui.spinBoxBitsPerSymbol.setValue(self.signal.bits_per_symbol)
282283

@@ -524,7 +525,10 @@ def update_protocol(self):
524525
self.ui.txtEdProto.setText("Demodulating...")
525526
qApp.processEvents()
526527

527-
self.proto_analyzer.get_protocol_from_signal()
528+
try:
529+
self.proto_analyzer.get_protocol_from_signal()
530+
except Exception as e:
531+
Errors.exception(e)
528532

529533
def show_protocol(self, old_view=-1, refresh=False):
530534
if not self.proto_analyzer:
@@ -1093,7 +1097,7 @@ def on_combobox_modulation_type_text_changed(self, txt: str):
10931097
self.scene_manager.init_scene()
10941098
self.on_slider_y_scale_value_changed()
10951099

1096-
self.ui.btnAdvancedModulationSettings.setVisible(self.ui.cbModulationType.currentText() == "ASK")
1100+
self.ui.btnAdvancedModulationSettings.setVisible(self.ui.cbModulationType.currentText() in ("ASK", "PSK"))
10971101

10981102
@pyqtSlot()
10991103
def on_signal_data_changed_before_save(self):
@@ -1301,9 +1305,25 @@ def get_advanced_modulation_settings_dialog(self):
13011305
dialog.message_length_divisor_edited.connect(self.on_message_length_divisor_edited)
13021306
return dialog
13031307

1308+
def get_costas_dialog(self):
1309+
dialog = CostaOptionsDialog(self.signal.costas_loop_bandwidth, parent=self)
1310+
dialog.accepted.connect(self.on_costas_dialog_accepted)
1311+
return dialog
1312+
1313+
@pyqtSlot()
1314+
def on_costas_dialog_accepted(self):
1315+
sender = self.sender()
1316+
assert isinstance(sender, CostaOptionsDialog)
1317+
self.signal.costas_loop_bandwidth = sender.costas_loop_bandwidth
1318+
13041319
@pyqtSlot()
13051320
def on_btn_advanced_modulation_settings_clicked(self):
1306-
dialog = self.get_advanced_modulation_settings_dialog()
1321+
if self.ui.cbModulationType.currentText() == "ASK":
1322+
dialog = self.get_advanced_modulation_settings_dialog()
1323+
elif self.ui.cbModulationType.currentText() == "PSK":
1324+
dialog = self.get_costas_dialog()
1325+
else:
1326+
raise ValueError("No additional settings available")
13071327
dialog.exec_()
13081328

13091329
@pyqtSlot()

src/urh/cythonext/build.py

+3-2
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@ def main():
1010
cur_dir = os.path.realpath(__file__)
1111
os.chdir(os.path.realpath(os.path.join(cur_dir, "..", "..", "..", "..")))
1212
# call([sys.executable, "setup.py", "clean", "--all"])
13-
call([sys.executable, "setup.py", "build_ext", "--inplace", "-j{}".format(os.cpu_count())])
13+
rc = call([sys.executable, "setup.py", "build_ext", "--inplace", "-j{}".format(os.cpu_count())])
14+
return rc
1415

1516

1617
if __name__ == "__main__":
17-
main()
18+
sys.exit(main())

src/urh/cythonext/signal_functions.pyx

+71-67
Original file line numberDiff line numberDiff line change
@@ -242,17 +242,27 @@ cdef np.ndarray[np.float32_t, ndim=1] gauss_fir(float sample_rate, uint32_t samp
242242
-(((np.sqrt(2) * np.pi) / np.sqrt(np.log(2)) * bt * k / samples_per_symbol) ** 2))
243243
return h / h.sum()
244244

245-
cdef void phase_demod(IQ samples, float[::1] result, float noise_sqrd, bool qam, long long num_samples):
246-
cdef long long i = 0
247-
cdef float real = 0, imag = 0, magnitude = 0
245+
cdef float clamp(float x) nogil:
246+
if x < -1.0:
247+
x = -1.0
248+
elif x > 1.0:
249+
x = 1.0
250+
return x
251+
252+
cdef float[::1] costa_demod(IQ samples, float noise_sqrd, int loop_order, float bandwidth=0.1, float damping=sqrt(2.0) / 2.0):
253+
cdef float alpha = (4 * damping * bandwidth) / (1.0 + 2.0 * damping * bandwidth + bandwidth * bandwidth)
254+
cdef float beta = (4 * bandwidth * bandwidth) / (1.0 + 2.0 * damping * bandwidth + bandwidth * bandwidth)
255+
256+
cdef long long i = 0, num_samples = len(samples)
257+
cdef float real = 0, imag = 0
248258

249259
cdef float scale, shift, real_float, imag_float, ref_real, ref_imag
250260

251-
cdef float phi = 0, current_arg = 0, f_curr = 0, f_prev = 0
261+
cdef float f1, f2, costa_freq = 0, costa_error = 0, costa_phase = 1.5
252262

253-
cdef float complex current_sample, conj_previous_sample, current_nco
263+
cdef float complex current_sample, nco_out, nco_times_sample
254264

255-
cdef float alpha = 0.1
265+
cdef float[::1] result = np.empty(num_samples, dtype=np.float32)
256266

257267
if str(cython.typeof(samples)) == "char[:, ::1]":
258268
scale = 127.5
@@ -272,62 +282,64 @@ cdef void phase_demod(IQ samples, float[::1] result, float noise_sqrd, bool qam,
272282
else:
273283
raise ValueError("Unsupported dtype")
274284

285+
if loop_order > 4:
286+
# TODO: Adapt this when PSK demodulation with order > 4 shall be supported
287+
loop_order = 4
288+
275289
for i in range(1, num_samples):
276290
real = samples[i, 0]
277291
imag = samples[i, 1]
278-
279-
magnitude = real * real + imag * imag
280-
if magnitude <= noise_sqrd:
292+
293+
if real * real + imag * imag <= noise_sqrd:
281294
result[i] = NOISE_FSK_PSK
282295
continue
283296

284297
real_float = (real + shift) / scale
285298
imag_float = (imag + shift) / scale
286299

287300
current_sample = real_float + imag_unit * imag_float
288-
conj_previous_sample = (samples[i-1, 0] + shift) / scale - imag_unit * ((samples[i-1, 1] + shift) / scale)
289-
f_curr = arg(current_sample * conj_previous_sample)
301+
nco_out = cosf(-costa_phase) + imag_unit * sinf(-costa_phase)
302+
nco_times_sample = nco_out * current_sample
290303

291-
if abs(f_curr) < M_PI / 4: # TODO: For PSK with order > 4 this needs to be adapted
292-
f_prev = f_curr
293-
current_arg += f_curr
294-
else:
295-
current_arg += f_prev
304+
if loop_order == 2:
305+
costa_error = nco_times_sample.imag * nco_times_sample.real
306+
elif loop_order == 4:
307+
f1 = 1.0 if nco_times_sample.real > 0.0 else -1.0
308+
f2 = 1.0 if nco_times_sample.imag > 0.0 else -1.0
309+
costa_error = f1 * nco_times_sample.imag - f2 * nco_times_sample.real
296310

297-
# Reference oscillator cos(current_arg) + j * sin(current_arg)
298-
current_nco = cosf(current_arg) + imag_unit * sinf(current_arg)
299-
phi = arg(current_sample * conj(current_nco))
311+
costa_error = clamp(costa_error)
312+
313+
# advance the loop
314+
costa_freq += beta * costa_error
315+
costa_phase += costa_freq + alpha * costa_error
316+
317+
# wrap the phase
318+
while costa_phase > (2 * M_PI):
319+
costa_phase -= 2 * M_PI
320+
while costa_phase < (-2 * M_PI):
321+
costa_phase += 2 * M_PI
322+
323+
costa_freq = clamp(costa_freq)
324+
325+
if loop_order == 2:
326+
result[i] = nco_times_sample.real
327+
elif loop_order == 4:
328+
result[i] = 2 * nco_times_sample.real + nco_times_sample.imag
329+
330+
return result
300331

301-
if qam:
302-
result[i] = phi * magnitude
303-
else:
304-
result[i] = phi
305332

306-
cpdef np.ndarray[np.float32_t, ndim=1] afp_demod(IQ samples, float noise_mag, str mod_type):
333+
cpdef np.ndarray[np.float32_t, ndim=1] afp_demod(IQ samples, float noise_mag,
334+
str mod_type, int mod_order, float costas_loop_bandwidth=0.1):
307335
if len(samples) <= 2:
308336
return np.zeros(len(samples), dtype=np.float32)
309337

310338
cdef long long i = 0, ns = len(samples)
311-
cdef float current_arg = 0
312-
cdef float noise_sqrd = 0
313-
cdef float complex_phase = 0
314-
cdef float prev_phase = 0
315-
cdef float NOISE = 0
316-
cdef float real = 0
317-
cdef float imag = 0
318-
319-
cdef float[::1] result = np.zeros(ns, dtype=np.float32, order="C")
320-
cdef float costa_freq = 0
321-
cdef float costa_phase = 0
322-
cdef complex nco_out = 0
339+
cdef float NOISE = get_noise_for_mod_type(mod_type)
340+
cdef float noise_sqrd = noise_mag * noise_mag, real = 0, imag = 0, magnitude = 0, max_magnitude
323341
cdef float complex tmp
324-
cdef float phase_error = 0
325-
cdef float costa_alpha = 0
326-
cdef float costa_beta = 0
327-
cdef complex nco_times_sample = 0
328-
cdef float magnitude = 0
329342

330-
cdef float max_magnitude # ensure all magnitudes of ASK demod between 0 and 1
331343
if str(cython.typeof(samples)) == "char[:, ::1]":
332344
max_magnitude = sqrt(127*127 + 128*128)
333345
elif str(cython.typeof(samples)) == "unsigned char[:, ::1]":
@@ -341,35 +353,27 @@ cpdef np.ndarray[np.float32_t, ndim=1] afp_demod(IQ samples, float noise_mag, st
341353
else:
342354
raise ValueError("Unsupported dtype")
343355

344-
# Atan2 yields values from -Pi to Pi
345-
# We use the Magic Constant NOISE_FSK_PSK to cut off noise
346-
noise_sqrd = noise_mag * noise_mag
347-
NOISE = get_noise_for_mod_type(mod_type)
348-
result[0] = NOISE
349-
350-
cdef bool qam = False
351356

352-
if mod_type in ("PSK", "QAM", "OQPSK"):
353-
if mod_type == "QAM":
354-
qam = True
357+
if mod_type == "PSK":
358+
return np.asarray(costa_demod(samples, noise_sqrd, mod_order, bandwidth=costas_loop_bandwidth))
355359

356-
phase_demod(samples, result, noise_sqrd, qam, ns)
360+
cdef float[::1] result = np.zeros(ns, dtype=np.float32, order="C")
361+
result[0] = NOISE
357362

358-
else:
359-
for i in prange(1, ns, nogil=True, schedule="static"):
360-
real = samples[i, 0]
361-
imag = samples[i, 1]
362-
magnitude = real * real + imag * imag
363-
if magnitude <= noise_sqrd: # |c| <= mag_treshold
364-
result[i] = NOISE
365-
continue
363+
for i in prange(1, ns, nogil=True, schedule="static"):
364+
real = samples[i, 0]
365+
imag = samples[i, 1]
366+
magnitude = real * real + imag * imag
367+
if magnitude <= noise_sqrd: # |c| <= mag_treshold
368+
result[i] = NOISE
369+
continue
366370

367-
if mod_type == "ASK":
368-
result[i] = sqrt(magnitude) / max_magnitude
369-
elif mod_type == "FSK":
370-
#tmp = samples[i - 1].conjugate() * c
371-
tmp = (samples[i-1, 0] - imag_unit * samples[i-1, 1]) * (real + imag_unit * imag)
372-
result[i] = atan2(tmp.imag, tmp.real) # Freq
371+
if mod_type == "ASK":
372+
result[i] = sqrt(magnitude) / max_magnitude
373+
elif mod_type == "FSK":
374+
#tmp = samples[i - 1].conjugate() * c
375+
tmp = (samples[i-1, 0] - imag_unit * samples[i-1, 1]) * (real + imag_unit * imag)
376+
result[i] = atan2(tmp.imag, tmp.real) # Freq
373377

374378
return np.asarray(result)
375379

src/urh/signalprocessing/Signal.py

+20-2
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ def __init__(self, filename: str, name="Signal", modulation: str = None, sample_
4343
self.__samples_per_symbol = 100
4444
self.__pause_threshold = 8
4545
self.__message_length_divisor = 1
46+
self.__costas_loop_bandwidth = 0.1
4647
self._qad = None
4748
self.__center = 0
4849
self._noise_threshold = 0
@@ -255,6 +256,18 @@ def pause_threshold(self, value: int):
255256
if not self.block_protocol_update:
256257
self.protocol_needs_update.emit()
257258

259+
@property
260+
def costas_loop_bandwidth(self):
261+
return self.__costas_loop_bandwidth
262+
263+
@costas_loop_bandwidth.setter
264+
def costas_loop_bandwidth(self, value: float):
265+
if self.__costas_loop_bandwidth != value:
266+
self.__costas_loop_bandwidth = value
267+
self._qad = None
268+
if not self.block_protocol_update:
269+
self.protocol_needs_update.emit()
270+
258271
@property
259272
def message_length_divisor(self) -> int:
260273
return self.__message_length_divisor
@@ -362,7 +375,9 @@ def save_as(self, filename: str):
362375
QApplication.instance().restoreOverrideCursor()
363376

364377
def quad_demod(self):
365-
return signal_functions.afp_demod(self.iq_array.data, self.noise_threshold, self.modulation_type)
378+
return signal_functions.afp_demod(self.iq_array.data, self.noise_threshold,
379+
self.modulation_type, self.modulation_order,
380+
self.costas_loop_bandwidth)
366381

367382
def calc_relative_noise_threshold_from_range(self, noise_start: int, noise_end: int):
368383
num_digits = 4
@@ -501,7 +516,10 @@ def crop_to_range(self, start: int, end: int):
501516
def filter_range(self, start: int, end: int, fir_filter: Filter):
502517
self.iq_array[start:end] = fir_filter.work(self.iq_array[start:end])
503518
self._qad[start:end] = signal_functions.afp_demod(self.iq_array[start:end],
504-
self.noise_threshold, self.modulation_type)
519+
self.noise_threshold,
520+
self.modulation_type,
521+
self.modulation_order,
522+
self.costas_loop_bandwidth)
505523
self.__invalidate_after_edit()
506524

507525
def __invalidate_after_edit(self):

0 commit comments

Comments
 (0)