| Philipp Le | 8618421 | 2022-05-10 20:46:23 +0200 | [diff] [blame] | 1 | # SPDX-License-Identifier: MPL-2.0 |
| 2 | # Copyright (c) 2022 Philipp Le <philipp@philipple.de>. |
| 3 | # This Source Code Form is subject to the terms of the Mozilla Public |
| 4 | # License, v. 2.0. If a copy of the MPL was not distributed with this |
| 5 | # file, You can obtain one at https://mozilla.org/MPL/2.0/. |
| 6 | |
| 7 | from __future__ import annotations |
| 8 | |
| 9 | from tkinter import ttk, LEFT, BOTH, BOTTOM |
| 10 | from pydantic import confloat, conint |
| 11 | from dcs.config import default_store, ConfigObject, ui_create, ConfigControlFrame |
| 12 | import numpy as np |
| 13 | import scipy.signal |
| 14 | from dcs.frames.base import BaseFrame, Window |
| 15 | from dcs.frames.groups import Ch05Group |
| 16 | from dcs.utils.filter import create_lp_filter_class |
| 17 | from dcs.utils.iq_mixer import create_iq_oscillator_class, IqUpMixer, IqDownMixer, IqRfBandGenerator, IqRxBasebandGenerator |
| 18 | from dcs.utils.qam_modulation import ModulationMethod, QamBasebandModulator, QamBasebandGenerator |
| 19 | from dcs.utils.symbols import MessageBinarySerializer |
| 20 | from typing import Dict |
| 21 | |
| 22 | import matplotlib |
| 23 | matplotlib.use('TkAgg') |
| 24 | from matplotlib.pyplot import Circle, Text |
| 25 | from matplotlib.figure import Figure |
| 26 | from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg, NavigationToolbar2Tk |
| 27 | |
| 28 | |
| 29 | SAMPLE_RATE = 10000 |
| 30 | FFT_SAMPLES_PER_SYM = 64 |
| 31 | |
| 32 | |
| 33 | IqOscillator = create_iq_oscillator_class(sample_rate=SAMPLE_RATE, imbalance_wdg=True) |
| 34 | |
| 35 | TxBasebandFilter = create_lp_filter_class(SAMPLE_RATE, 'TX Baseband') |
| 36 | RxBasebandFilter = create_lp_filter_class(SAMPLE_RATE, 'RX Baseband') |
| 37 | |
| 38 | |
| 39 | class ConfigCh05QamFrame(ConfigControlFrame): |
| 40 | wdg_msg_update_btn: ttk.Button |
| 41 | |
| 42 | |
| 43 | @ui_create |
| 44 | class ConfigCh05Qam(ConfigObject): |
| 45 | _KEY = 'ch05_qam' |
| 46 | |
| 47 | message: str = 'DCS' |
| 48 | symbol_rate: confloat(ge=-SAMPLE_RATE/4, lt=SAMPLE_RATE/4, multiple_of=(4.0/SAMPLE_RATE)) = 10.0 |
| 49 | modulation: ModulationMethod = ModulationMethod.QPSK |
| 50 | tx_lp: TxBasebandFilter = TxBasebandFilter() |
| 51 | tx_osc: IqOscillator = IqOscillator(freq=100.0) |
| 52 | noise_dBc: confloat(ge=-160.0, le=40.0, multiple_of=0.1) = -60.0 |
| 53 | random_seed: conint(ge=0, lt=10000) = 1000 |
| 54 | rx_osc: IqOscillator = IqOscillator(freq=100.0) |
| 55 | rx_lp: RxBasebandFilter = RxBasebandFilter() |
| 56 | |
| 57 | def _mod_frame(self, parent: ConfigCh05QamFrame) -> ConfigControlFrame: |
| 58 | frm = ConfigControlFrame(parent, borderwidth=1, relief='raised') |
| 59 | |
| 60 | ttk.Label(frm, text='Message:').grid(row=0, column=0) |
| 61 | # NOTE: Recalculating the message is slow. So only update on click on the Update button. |
| 62 | self.ui_create_message(frm).grid(row=0, column=1) |
| 63 | parent.wdg_msg_update_btn = ttk.Button(frm, text='Update') |
| 64 | parent.wdg_msg_update_btn.grid(row=0, column=2) |
| 65 | |
| 66 | ttk.Label(frm, text='Symbol Rate:').grid(row=1, column=0) |
| 67 | w = self.ui_create_symbol_rate(frm) |
| 68 | frm.add_widget(w) |
| 69 | w.grid(row=1, column=1) |
| 70 | ttk.Label(frm, text='Hz').grid(row=1, column=2) |
| 71 | |
| 72 | ttk.Label(frm, text='Modulation:').grid(row=2, column=0) |
| 73 | w = self.ui_create_modulation_dropdown(frm) |
| 74 | frm.add_widget(w) |
| 75 | w.grid(row=2, column=1) |
| 76 | |
| 77 | ttk.Label(frm, text='TX Baseband Low Pass Cut-Off:').grid(row=3, column=0) |
| 78 | w = self.tx_lp.ui_create_cutoff(frm) |
| 79 | frm.add_widget(w) |
| 80 | w.grid(row=3, column=1) |
| 81 | ttk.Label(frm, text='Hz').grid(row=3, column=2) |
| 82 | |
| 83 | return frm |
| 84 | |
| 85 | def _noise_frame(self, parent: ttk.Widget) -> ConfigControlFrame: |
| 86 | frm = ConfigControlFrame(parent, borderwidth=1, relief='raised') |
| 87 | |
| 88 | ttk.Label(frm, text='Signal-Noise Ratio:').grid(row=0, column=0) |
| 89 | w = self.ui_create_noise_dBc(frm) |
| 90 | frm.add_widget(w) |
| 91 | w.grid(row=0, column=1) |
| 92 | ttk.Label(frm, text='dBc').grid(row=0, column=2) |
| 93 | |
| 94 | ttk.Label(frm, text='Random Seed:').grid(row=1, column=0) |
| 95 | w = self.ui_create_random_seed(frm) |
| 96 | frm.add_widget(w) |
| 97 | w.grid(row=1, column=1) |
| 98 | |
| 99 | return frm |
| 100 | |
| 101 | def _demod_frame(self, parent: ttk.Widget) -> ConfigControlFrame: |
| 102 | frm = ConfigControlFrame(parent, borderwidth=1, relief='raised') |
| 103 | |
| 104 | frm1 = self.rx_lp.make_config_widget(frm) |
| 105 | frm1.pack() |
| 106 | for w in frm1.ctrl_widgets: |
| 107 | frm.add_widget(w) |
| 108 | |
| 109 | return frm |
| 110 | |
| 111 | def make_config_widget(self, parent: ttk.Widget) -> ConfigCh05QamFrame: |
| 112 | frm = ConfigCh05QamFrame(parent, borderwidth=1, relief='raised') |
| 113 | |
| 114 | sym_frm = self._mod_frame(frm) |
| 115 | sym_frm.pack() |
| 116 | for w in sym_frm.ctrl_widgets: |
| 117 | frm.add_widget(w) |
| 118 | |
| 119 | tx_frm = ttk.Frame(frm, borderwidth=1, relief='raised') |
| 120 | tx_frm.pack() |
| 121 | ttk.Label(tx_frm, text='TX Oscillator:').pack() |
| 122 | tx_ctrl_frm = self.tx_osc.make_config_widget(tx_frm) |
| 123 | tx_ctrl_frm.pack() |
| 124 | for w in tx_ctrl_frm.ctrl_widgets: |
| 125 | frm.add_widget(w) |
| 126 | |
| 127 | noise_frm = self._noise_frame(frm) |
| 128 | noise_frm.pack() |
| 129 | for w in noise_frm.ctrl_widgets: |
| 130 | frm.add_widget(w) |
| 131 | |
| 132 | rx_frm = ttk.Frame(frm, borderwidth=1, relief='raised') |
| 133 | rx_frm.pack() |
| 134 | ttk.Label(rx_frm, text='RX Oscillator:').pack() |
| 135 | rx_ctrl_frm = self.tx_osc.make_config_widget(rx_frm) |
| 136 | rx_ctrl_frm.pack() |
| 137 | for w in rx_ctrl_frm.ctrl_widgets: |
| 138 | frm.add_widget(w) |
| 139 | |
| 140 | demod_frm = self._demod_frame(frm) |
| 141 | demod_frm.pack() |
| 142 | for w in demod_frm.ctrl_widgets: |
| 143 | frm.add_widget(w) |
| 144 | |
| 145 | return frm |
| 146 | |
| 147 | |
| 148 | class Ch05QamFrame(BaseFrame): |
| 149 | def __init__(self, *args, **kwargs): |
| 150 | super().__init__(*args, **kwargs) |
| 151 | |
| 152 | self._config: ConfigCh05Qam = default_store().get_config(ConfigCh05Qam) |
| 153 | |
| 154 | ctrl_frm = self._create_control() |
| 155 | ctrl_frm.pack(side=LEFT) |
| 156 | |
| 157 | signal_frm = self._create_signal_tabs() |
| 158 | signal_frm.pack(expand=True, fill=BOTH) |
| 159 | |
| 160 | def _create_control(self) -> ConfigControlFrame: |
| 161 | frm = self._config.make_config_widget(self) |
| 162 | frm.widgets_on_change(self._on_change) |
| 163 | frm.wdg_msg_update_btn.configure(command=lambda: self._on_change(0, 0, 0)) |
| 164 | return frm |
| 165 | |
| 166 | def _on_change(self, _, __, ___): |
| 167 | default_store().save() |
| 168 | self.refresh() |
| 169 | |
| 170 | def _create_signal_tabs(self) -> ttk.Widget: |
| 171 | tabs = ttk.Notebook(self) |
| 172 | |
| 173 | tx_frm = ttk.Frame(tabs) |
| 174 | tabs.add(tx_frm, text='TX Baseband') |
| 175 | self._tx_fig = Figure(figsize=(12, 6), dpi=100) |
| 176 | self._tx_canvas = FigureCanvasTkAgg(self._tx_fig, tx_frm) |
| 177 | self._tx_canvas.get_tk_widget().pack(expand=True, fill=BOTH) |
| 178 | tx_tb = NavigationToolbar2Tk(self._tx_canvas, tx_frm, pack_toolbar=False) |
| 179 | tx_tb.pack(side=BOTTOM) |
| 180 | |
| 181 | self._data_bytes = ttk.Label(tx_frm, text='') |
| 182 | self._data_bytes.pack(side=BOTTOM) |
| 183 | self._data_syms = ttk.Label(tx_frm, text='') |
| 184 | self._data_syms.pack(side=BOTTOM) |
| 185 | |
| 186 | hf_frm = ttk.Frame(tabs) |
| 187 | tabs.add(hf_frm, text='HF Band') |
| 188 | self._hf_fig = Figure(figsize=(12, 6), dpi=100) |
| 189 | self._hf_canvas = FigureCanvasTkAgg(self._hf_fig, hf_frm) |
| 190 | self._hf_canvas.get_tk_widget().pack(expand=True, fill=BOTH) |
| 191 | hf_tb = NavigationToolbar2Tk(self._hf_canvas, hf_frm, pack_toolbar=False) |
| 192 | hf_tb.pack(side=BOTTOM) |
| 193 | |
| 194 | rx_frm = ttk.Frame(tabs) |
| 195 | tabs.add(rx_frm, text='RX Baseband') |
| 196 | self._rx_fig = Figure(figsize=(12, 6), dpi=100) |
| 197 | self._rx_canvas = FigureCanvasTkAgg(self._rx_fig, rx_frm) |
| 198 | self._rx_canvas.get_tk_widget().pack(expand=True, fill=BOTH) |
| 199 | rx_tb = NavigationToolbar2Tk(self._rx_canvas, rx_frm, pack_toolbar=False) |
| 200 | rx_tb.pack(side=BOTTOM) |
| 201 | |
| 202 | self.refresh() |
| 203 | |
| 204 | return tabs |
| 205 | |
| 206 | def refresh(self): |
| 207 | # Make constellation |
| 208 | constellation = self._config.modulation.make_constellation_map_str() |
| 209 | |
| 210 | # Make symbols vector |
| 211 | bytes_symbols = MessageBinarySerializer(self._config.message).to_symbols() |
| 212 | symbols = bytes_symbols.reencode(self._config.modulation.bits_per_symbol()) |
| 213 | |
| 214 | # Update symbol output widgets |
| 215 | data_bytes = ', '.join(bytes_symbols.to_bit_str()) |
| 216 | self._data_bytes.configure(text=f'Data = [{data_bytes}]') |
| 217 | data_syms = ', '.join(symbols.to_bit_str()) |
| 218 | self._data_syms.configure(text=f'Symbols = [{data_syms}]') |
| 219 | |
| 220 | # Create signal generators |
| 221 | baseband_gen = QamBasebandGenerator( |
| 222 | symbols, |
| 223 | QamBasebandModulator( |
| 224 | method=self._config.modulation, |
| 225 | baseband_filter=self._config.tx_lp, |
| 226 | symbol_rate=self._config.symbol_rate |
| 227 | ) |
| 228 | ) |
| 229 | iq_mod = baseband_gen.make_rf_band_generator( |
| 230 | IqUpMixer( |
| 231 | tx_osc=self._config.tx_osc, |
| 232 | noise_dBc=self._config.noise_dBc, |
| 233 | random_seed=self._config.random_seed |
| 234 | ) |
| 235 | ) |
| 236 | iq_demod = iq_mod.make_iq_down_mixer( |
| 237 | IqDownMixer( |
| 238 | rx_osc=self._config.rx_osc, |
| 239 | baseband_filter=self._config.rx_lp |
| 240 | ) |
| 241 | ) |
| 242 | |
| 243 | # Draw |
| 244 | self.draw_tx(constellation, baseband_gen) |
| 245 | self.draw_hf(iq_mod) |
| 246 | self.draw_rx(constellation, iq_demod) |
| 247 | |
| 248 | def draw_tx(self, constellation: Dict[str, complex], baseband_gen: QamBasebandGenerator): |
| 249 | self._tx_fig.clear() |
| 250 | |
| 251 | ax_const = self._tx_fig.add_subplot(2, 1, 1) |
| 252 | ax_const.set_xlim(-1.2, 1.2) |
| 253 | ax_const.set_ylim(-1.2, 1.2) |
| 254 | ax_const.set_xlabel('real') |
| 255 | ax_const.set_ylabel('imaginary') |
| 256 | ax_const.set_title('Ideal Constellation Diagram') |
| 257 | ax_const.set_aspect(1) |
| 258 | ax_base = self._tx_fig.add_subplot(2, 1, 2) |
| 259 | ax_base.set_xlabel('time') |
| 260 | ax_base.set_ylabel('value') |
| 261 | ax_base.set_title('TX Baseband Signal') |
| 262 | |
| 263 | for sym_str, sym_val in constellation.items(): |
| 264 | circ = Circle((sym_val.real, sym_val.imag), 0.03, alpha=0.4, color='blue', label=sym_str) |
| 265 | ax_const.add_artist(circ) |
| 266 | txt = Text(sym_val.real, sym_val.imag, sym_str, color='orange', fontsize=8) |
| 267 | ax_const.add_artist(txt) |
| 268 | |
| 269 | sig_base_tx = baseband_gen.generate_tx_baseband_signal(SAMPLE_RATE) |
| 270 | ax_base.set_xlim(np.min(sig_base_tx.t), np.max(sig_base_tx.t)) |
| 271 | ax_base.plot(sig_base_tx.t, np.real(sig_base_tx.signal), label='Baseband I', linestyle='solid', color='blue', linewidth=1) |
| 272 | ax_base.plot(sig_base_tx.t, np.imag(sig_base_tx.signal), label='Baseband Q', linestyle='solid', color='red', linewidth=1) |
| 273 | ax_base.legend() |
| 274 | |
| 275 | self._tx_fig.tight_layout() |
| 276 | self._tx_canvas.draw() |
| 277 | |
| 278 | def draw_hf(self, rf_gen: IqRfBandGenerator): |
| 279 | self._hf_fig.clear() |
| 280 | |
| 281 | ax_td = self._hf_fig.add_subplot(2, 1, 1) |
| 282 | ax_td.set_xlabel('time') |
| 283 | ax_td.set_ylabel('value') |
| 284 | ax_td.set_title('HF Signal Time-Domain') |
| 285 | ax_fd = self._hf_fig.add_subplot(2, 1, 2) |
| 286 | ax_fd.set_ylim(-140, 40) |
| 287 | ax_fd.set_xlabel('frequency') |
| 288 | ax_fd.set_ylabel('value (logarithmic)') |
| 289 | ax_fd.set_title('HF Signal Frequency-Domain') |
| 290 | |
| 291 | sig_hf_normal = rf_gen.generate_rf_signal(SAMPLE_RATE) |
| 292 | ax_td.set_xlim(np.min(sig_hf_normal.t), np.max(sig_hf_normal.t)) |
| 293 | ax_td.plot(sig_hf_normal.t, np.real(sig_hf_normal.signal), label='HF', linestyle='solid', color='brown', linewidth=1) |
| 294 | ax_td.legend() |
| 295 | |
| 296 | fd_smp_rate = FFT_SAMPLES_PER_SYM * self._config.symbol_rate |
| 297 | sig_hf_ovs = rf_gen.generate_rf_signal(fd_smp_rate) |
| 298 | f_fd, X_hf_fd = sig_hf_ovs.fft(window_fn=scipy.signal.windows.blackman) |
| 299 | |
| 300 | ax_fd.set_xlim(0, np.max(f_fd)) |
| 301 | ax_fd.plot(f_fd, X_hf_fd, label='HF', linestyle='solid', color='brown', linewidth=1) |
| 302 | ax_fd.legend() |
| 303 | |
| 304 | self._hf_fig.tight_layout() |
| 305 | self._hf_canvas.draw() |
| 306 | |
| 307 | def draw_rx(self, constellation: Dict[str, complex], rx_gen: IqRxBasebandGenerator): |
| 308 | self._rx_fig.clear() |
| 309 | ax_base = self._rx_fig.add_subplot(2, 1, 1) |
| 310 | ax_base.set_xlabel('time') |
| 311 | ax_base.set_ylabel('value') |
| 312 | ax_base.set_title('RX Baseband Signal') |
| 313 | ax_const = self._rx_fig.add_subplot(2, 1, 2) |
| 314 | ax_const.set_xlim(-1.2, 1.2) |
| 315 | ax_const.set_ylim(-1.2, 1.2) |
| 316 | ax_const.set_xlabel('real') |
| 317 | ax_const.set_ylabel('imaginary') |
| 318 | ax_const.set_title('RX Constellation Diagram') |
| 319 | ax_const.set_aspect(1) |
| 320 | |
| 321 | sig_base_rx = rx_gen.generate_rx_baseband_signal(SAMPLE_RATE) |
| 322 | ax_base.set_xlim(np.min(sig_base_rx.t), np.max(sig_base_rx.t)) |
| 323 | ax_base.plot(sig_base_rx.t, np.real(sig_base_rx.signal), label='Baseband I', linestyle='solid', color='blue', linewidth=1) |
| 324 | ax_base.plot(sig_base_rx.t, np.imag(sig_base_rx.signal), label='Baseband Q', linestyle='solid', color='red', linewidth=1) |
| 325 | ax_base.legend() |
| 326 | |
| 327 | ax_const.plot(np.real(sig_base_rx.signal), np.imag(sig_base_rx.signal), label='Baseband', linestyle='solid', color='purple', linewidth=1) |
| 328 | ax_const.legend() |
| 329 | |
| 330 | for sym_str, sym_val in constellation.items(): |
| 331 | circ = Circle((sym_val.real, sym_val.imag), 0.05, alpha=0.4, color='blue', label=sym_str) |
| 332 | ax_const.add_artist(circ) |
| 333 | txt = Text(sym_val.real, sym_val.imag, sym_str, color='orange', fontsize=8) |
| 334 | ax_const.add_artist(txt) |
| 335 | |
| 336 | self._rx_fig.tight_layout() |
| 337 | self._rx_canvas.draw() |
| 338 | |
| 339 | |
| 340 | class Ch05QamWindow(Window): |
| 341 | GROUP = Ch05Group |
| 342 | TITLE = 'Quadrature Amplitude Modulation' |
| 343 | FRAME = Ch05QamFrame |
| 344 | |
| 345 | |
| 346 | if __name__ == '__main__': |
| 347 | Ch05QamWindow.main() |
| 348 | |