From cbd94c8fe0dbb406766035912b5f5c9a29a87f6e Mon Sep 17 00:00:00 2001 From: madrigal Date: Thu, 11 Dec 2025 11:13:27 -0500 Subject: [PATCH] Added signal package --- .../ria_toolkit_oss/discover.py | 2 +- .../ria_toolkit_oss/generate.py | 11 +- src/ria_toolkit_oss/signal/__init__.py | 7 + .../signal/basic_signal_generator.py | 398 ++++++++++++++++++ .../signal/block_generator/README.md | 63 +++ .../signal/block_generator/__init__.py | 88 ++++ .../signal/block_generator/basic/__init__.py | 6 + .../signal/block_generator/basic/add.py | 69 +++ .../block_generator/basic/frequency_shift.py | 56 +++ .../basic/multiply_constant.py | 41 ++ .../block_generator/basic/phase_shift.py | 40 ++ .../signal/block_generator/block.py | 122 ++++++ .../continuous_modulation/__init__.py | 0 .../coherent_correlator.py | 112 +++++ .../cpfsk_demodulator.py | 218 ++++++++++ .../cpfsk_demodulator_fc.py | 140 ++++++ .../continuous_modulation/cpfsk_modulator.py | 104 +++++ .../cpfsk_modulator_fc.py | 108 +++++ .../continuous_modulation/demodulator.py | 11 + .../continuous_modulation/fsk_demodulator.py | 141 +++++++ .../fsk_demodulator_fc.py | 155 +++++++ .../continuous_modulation/fsk_modulator.py | 133 ++++++ .../continuous_modulation/fsk_modulator_fc.py | 143 +++++++ .../continuous_modulation/modulator.py | 11 + .../signal/block_generator/data_types.py | 34 ++ .../frequency_translation/__init__.py | 4 + .../frequency_translation/downconversion.py | 57 +++ .../frequency_translation/upconversion.py | 55 +++ .../block_generator/generators/__init__.py | 34 ++ .../generators/pam_generator.py | 55 +++ .../generators/psk_generator.py | 55 +++ .../generators/qam_generator.py | 55 +++ .../generators/signal_generator.py | 36 ++ .../signal/block_generator/io.py | 20 + .../block_generator/mapping/__init__.py | 27 ++ .../block_generator/mapping/apsk_mapper.py | 74 ++++ .../mapping/constellation_mapper.py | 186 ++++++++ .../mapping/cross_qam_mapper.py | 64 +++ .../signal/block_generator/mapping/mapper.py | 159 +++++++ .../block_generator/mapping/pam_mapper.py | 46 ++ .../block_generator/mapping/psk_mapper.py | 49 +++ .../block_generator/mapping/qam_mapper.py | 119 ++++++ .../mapping/symbol_demapper.py | 141 +++++++ .../block_generator/multirate/__init__.py | 28 ++ .../block_generator/multirate/downsampling.py | 60 +++ .../block_generator/multirate/upsampling.py | 66 +++ .../signal/block_generator/process_block.py | 87 ++++ .../block_generator/pulse_shaping/__init__.py | 52 +++ .../pulse_shaping/gaussian_filter.py | 95 +++++ .../pulse_shaping/pulse_shaping_filter.py | 200 +++++++++ .../pulse_shaping/raised_cosine_filter.py | 110 +++++ .../pulse_shaping/rect_filter.py | 53 +++ .../root_raised_cosine_filter.py | 111 +++++ .../pulse_shaping/sinc_filter.py | 73 ++++ .../pulse_shaping/upsampling.py | 75 ++++ .../block_generator/recordable_block.py | 30 ++ .../block_generator/recording_gen_wrapper.py | 141 +++++++ .../block_generator/siso_channel/__init__.py | 30 ++ .../siso_channel/awgn_channel.py | 61 +++ .../siso_channel/flat_rayleigh.py | 41 ++ .../siso_channel/siso_channel.py | 54 +++ .../signal/block_generator/source/__init__.py | 19 + .../block_generator/source/awgn_source.py | 47 +++ .../block_generator/source/binary_source.py | 87 ++++ .../block_generator/source/constant_source.py | 43 ++ .../source/lfm_chirp_source.py | 107 +++++ .../source/recording_source.py | 47 +++ .../block_generator/source/sawtooth_source.py | 66 +++ .../block_generator/source/sine_source.py | 64 +++ .../block_generator/source/square_source.py | 70 +++ .../signal/block_generator/source_block.py | 37 ++ .../symbol_modulation/__init__.py | 5 + .../symbol_modulation/gmsk_modulator.py | 65 +++ .../symbol_modulation/ook_modulator.py | 40 ++ .../symbol_modulation/oqpsk_modulator.py | 70 +++ src/ria_toolkit_oss/signal/recordable.py | 17 + 76 files changed, 5593 insertions(+), 7 deletions(-) create mode 100644 src/ria_toolkit_oss/signal/__init__.py create mode 100644 src/ria_toolkit_oss/signal/basic_signal_generator.py create mode 100644 src/ria_toolkit_oss/signal/block_generator/README.md create mode 100644 src/ria_toolkit_oss/signal/block_generator/__init__.py create mode 100644 src/ria_toolkit_oss/signal/block_generator/basic/__init__.py create mode 100644 src/ria_toolkit_oss/signal/block_generator/basic/add.py create mode 100644 src/ria_toolkit_oss/signal/block_generator/basic/frequency_shift.py create mode 100644 src/ria_toolkit_oss/signal/block_generator/basic/multiply_constant.py create mode 100644 src/ria_toolkit_oss/signal/block_generator/basic/phase_shift.py create mode 100644 src/ria_toolkit_oss/signal/block_generator/block.py create mode 100644 src/ria_toolkit_oss/signal/block_generator/continuous_modulation/__init__.py create mode 100644 src/ria_toolkit_oss/signal/block_generator/continuous_modulation/coherent_correlator.py create mode 100644 src/ria_toolkit_oss/signal/block_generator/continuous_modulation/cpfsk_demodulator.py create mode 100644 src/ria_toolkit_oss/signal/block_generator/continuous_modulation/cpfsk_demodulator_fc.py create mode 100644 src/ria_toolkit_oss/signal/block_generator/continuous_modulation/cpfsk_modulator.py create mode 100644 src/ria_toolkit_oss/signal/block_generator/continuous_modulation/cpfsk_modulator_fc.py create mode 100644 src/ria_toolkit_oss/signal/block_generator/continuous_modulation/demodulator.py create mode 100644 src/ria_toolkit_oss/signal/block_generator/continuous_modulation/fsk_demodulator.py create mode 100644 src/ria_toolkit_oss/signal/block_generator/continuous_modulation/fsk_demodulator_fc.py create mode 100644 src/ria_toolkit_oss/signal/block_generator/continuous_modulation/fsk_modulator.py create mode 100644 src/ria_toolkit_oss/signal/block_generator/continuous_modulation/fsk_modulator_fc.py create mode 100644 src/ria_toolkit_oss/signal/block_generator/continuous_modulation/modulator.py create mode 100644 src/ria_toolkit_oss/signal/block_generator/data_types.py create mode 100644 src/ria_toolkit_oss/signal/block_generator/frequency_translation/__init__.py create mode 100644 src/ria_toolkit_oss/signal/block_generator/frequency_translation/downconversion.py create mode 100644 src/ria_toolkit_oss/signal/block_generator/frequency_translation/upconversion.py create mode 100644 src/ria_toolkit_oss/signal/block_generator/generators/__init__.py create mode 100644 src/ria_toolkit_oss/signal/block_generator/generators/pam_generator.py create mode 100644 src/ria_toolkit_oss/signal/block_generator/generators/psk_generator.py create mode 100644 src/ria_toolkit_oss/signal/block_generator/generators/qam_generator.py create mode 100644 src/ria_toolkit_oss/signal/block_generator/generators/signal_generator.py create mode 100644 src/ria_toolkit_oss/signal/block_generator/io.py create mode 100644 src/ria_toolkit_oss/signal/block_generator/mapping/__init__.py create mode 100644 src/ria_toolkit_oss/signal/block_generator/mapping/apsk_mapper.py create mode 100644 src/ria_toolkit_oss/signal/block_generator/mapping/constellation_mapper.py create mode 100644 src/ria_toolkit_oss/signal/block_generator/mapping/cross_qam_mapper.py create mode 100644 src/ria_toolkit_oss/signal/block_generator/mapping/mapper.py create mode 100644 src/ria_toolkit_oss/signal/block_generator/mapping/pam_mapper.py create mode 100644 src/ria_toolkit_oss/signal/block_generator/mapping/psk_mapper.py create mode 100644 src/ria_toolkit_oss/signal/block_generator/mapping/qam_mapper.py create mode 100644 src/ria_toolkit_oss/signal/block_generator/mapping/symbol_demapper.py create mode 100644 src/ria_toolkit_oss/signal/block_generator/multirate/__init__.py create mode 100644 src/ria_toolkit_oss/signal/block_generator/multirate/downsampling.py create mode 100644 src/ria_toolkit_oss/signal/block_generator/multirate/upsampling.py create mode 100644 src/ria_toolkit_oss/signal/block_generator/process_block.py create mode 100644 src/ria_toolkit_oss/signal/block_generator/pulse_shaping/__init__.py create mode 100644 src/ria_toolkit_oss/signal/block_generator/pulse_shaping/gaussian_filter.py create mode 100644 src/ria_toolkit_oss/signal/block_generator/pulse_shaping/pulse_shaping_filter.py create mode 100644 src/ria_toolkit_oss/signal/block_generator/pulse_shaping/raised_cosine_filter.py create mode 100644 src/ria_toolkit_oss/signal/block_generator/pulse_shaping/rect_filter.py create mode 100644 src/ria_toolkit_oss/signal/block_generator/pulse_shaping/root_raised_cosine_filter.py create mode 100644 src/ria_toolkit_oss/signal/block_generator/pulse_shaping/sinc_filter.py create mode 100644 src/ria_toolkit_oss/signal/block_generator/pulse_shaping/upsampling.py create mode 100644 src/ria_toolkit_oss/signal/block_generator/recordable_block.py create mode 100644 src/ria_toolkit_oss/signal/block_generator/recording_gen_wrapper.py create mode 100644 src/ria_toolkit_oss/signal/block_generator/siso_channel/__init__.py create mode 100644 src/ria_toolkit_oss/signal/block_generator/siso_channel/awgn_channel.py create mode 100644 src/ria_toolkit_oss/signal/block_generator/siso_channel/flat_rayleigh.py create mode 100644 src/ria_toolkit_oss/signal/block_generator/siso_channel/siso_channel.py create mode 100644 src/ria_toolkit_oss/signal/block_generator/source/__init__.py create mode 100644 src/ria_toolkit_oss/signal/block_generator/source/awgn_source.py create mode 100644 src/ria_toolkit_oss/signal/block_generator/source/binary_source.py create mode 100644 src/ria_toolkit_oss/signal/block_generator/source/constant_source.py create mode 100644 src/ria_toolkit_oss/signal/block_generator/source/lfm_chirp_source.py create mode 100644 src/ria_toolkit_oss/signal/block_generator/source/recording_source.py create mode 100644 src/ria_toolkit_oss/signal/block_generator/source/sawtooth_source.py create mode 100644 src/ria_toolkit_oss/signal/block_generator/source/sine_source.py create mode 100644 src/ria_toolkit_oss/signal/block_generator/source/square_source.py create mode 100644 src/ria_toolkit_oss/signal/block_generator/source_block.py create mode 100644 src/ria_toolkit_oss/signal/block_generator/symbol_modulation/__init__.py create mode 100644 src/ria_toolkit_oss/signal/block_generator/symbol_modulation/gmsk_modulator.py create mode 100644 src/ria_toolkit_oss/signal/block_generator/symbol_modulation/ook_modulator.py create mode 100644 src/ria_toolkit_oss/signal/block_generator/symbol_modulation/oqpsk_modulator.py create mode 100644 src/ria_toolkit_oss/signal/recordable.py diff --git a/src/ria_toolkit_oss/ria_toolkit_oss_cli/ria_toolkit_oss/discover.py b/src/ria_toolkit_oss/ria_toolkit_oss_cli/ria_toolkit_oss/discover.py index f94f4ef..962876e 100644 --- a/src/ria_toolkit_oss/ria_toolkit_oss_cli/ria_toolkit_oss/discover.py +++ b/src/ria_toolkit_oss/ria_toolkit_oss_cli/ria_toolkit_oss/discover.py @@ -31,7 +31,7 @@ def load_sdr_drivers(verbose: bool = False) -> Tuple[List[str], List[str], Dict[ # Try to import each SDR driver drivers = { - "pluto": "ria_toolkit_oss.sdr.pluto", + "pluto": "ria_toolkit_oss.sdr.pluto", "hackrf": "ria_toolkit_oss.sdr.hackrf", "bladerf": "ria_toolkit_oss.sdr.bladerf", "usrp": "ria_toolkit_oss.sdr.usrp", diff --git a/src/ria_toolkit_oss/ria_toolkit_oss_cli/ria_toolkit_oss/generate.py b/src/ria_toolkit_oss/ria_toolkit_oss_cli/ria_toolkit_oss/generate.py index 9a10d0f..c7dddde 100644 --- a/src/ria_toolkit_oss/ria_toolkit_oss_cli/ria_toolkit_oss/generate.py +++ b/src/ria_toolkit_oss/ria_toolkit_oss_cli/ria_toolkit_oss/generate.py @@ -5,9 +5,8 @@ from typing import Optional import click import numpy as np -import yaml - import utils.signal.basic_signal_generator as basic_gen +import yaml from utils.data import Recording from utils.signal.block_gen.continuous_modulation.fsk_modulator import FSKModulator from utils.signal.block_generator.basic import FrequencyShift @@ -26,7 +25,7 @@ from utils.signal.block_generator.pulse_shaping import ( Upsampling, ) from utils.signal.block_generator.source import ( - LFMJammingSource, + LFMChirpSource, RandomBinarySource, RecordingSource, SawtoothSource, @@ -338,12 +337,12 @@ def apply_post_processing( @click.group() def generate(): """Generate synthetic signals. - + \b Examples: utils synth chirp -b 1e6 -p 0.01 -s 10e6 -o chirp_basic.sigmf utils synth fsk -M 2 -r 100e3 -s 2e6 -o fsk2_basic.sigmf - + """ pass @@ -554,7 +553,7 @@ def chirp( echo_progress(f"Generating {chirp_type} chirp ({format_frequency(bandwidth)}, {period}s)...", quiet) - source = LFMJammingSource(sample_rate=sample_rate, bandwidth=bandwidth, chirp_period=period, chirp_type=chirp_type) + source = LFMChirpSource(sample_rate=sample_rate, bandwidth=bandwidth, chirp_period=period, chirp_type=chirp_type) recording = source.record(ns) diff --git a/src/ria_toolkit_oss/signal/__init__.py b/src/ria_toolkit_oss/signal/__init__.py new file mode 100644 index 0000000..cc29543 --- /dev/null +++ b/src/ria_toolkit_oss/signal/__init__.py @@ -0,0 +1,7 @@ +""" +The Signal Package provides a comprehensive suite of tools for signal generation and processing. +""" + +from .recordable import Recordable + +__all__ = ["Recordable"] diff --git a/src/ria_toolkit_oss/signal/basic_signal_generator.py b/src/ria_toolkit_oss/signal/basic_signal_generator.py new file mode 100644 index 0000000..067d85a --- /dev/null +++ b/src/ria_toolkit_oss/signal/basic_signal_generator.py @@ -0,0 +1,398 @@ +""" +.. todo:: Need to add some information here about signal generation and the signal generators in this module. +""" + +from typing import Optional + +import numpy as np +import scipy.signal +from scipy.signal import butter +from scipy.signal import chirp as sci_chirp +from scipy.signal import hilbert, lfilter + +from ria_toolkit_oss.datatypes.recording import Recording + + +def sine( + sample_rate: Optional[int] = 1000, + length: Optional[int] = 1000, + frequency: Optional[float] = 1000, + amplitude: Optional[float] = 1, + baseband_phase: Optional[float] = 0, + rf_phase: Optional[float] = 0, + dc_offset: Optional[float] = 0, +) -> Recording: + """Generate a basic sine wave signal. + + :param sample_rate: The number of samples per second (Hz). Defaults to 1,000. + :type sample_rate: int, optional + :param length: Number of samples in the recording. Defaults to 1,000. + :type length: int, optional + :param frequency: The frequency of the sine wave (Hz). Defaults to 1,000. + :type frequency: float, optional + :param amplitude: Amplitude of the sine wave. Defaults to 1. + :type amplitude: float, optional + :param baseband_phase: Phase offset in radians, relative to the sine wave frequency. Defaults to 0. + :type baseband_phase: float, optional + :param rf_phase: Phase offset in radians of the complex samples. Defaults to 0. + :type rf_phase: float, optional + :param dc_offset: DC offset (average of the sine wave). Defaults to 0. + :type dc_offset: float, optional + + :return: A Recording object containing the generated sine wave signal. + :rtype: Recording + + Examples: + + .. todo:: Usage examples coming soon! + """ + + if sample_rate < 1: + raise ValueError("sample_rate must be > 1") + + total_time = length / sample_rate + t = np.linspace(0, total_time, length, endpoint=False) + sine_wave = amplitude * np.sin(2 * np.pi * frequency * t + baseband_phase) + dc_offset + complex_sine_wave = sine_wave * np.exp(1j * rf_phase) + + metadata = { + "signal": "sine", + "source": "synth", + "sample_rate": sample_rate, + "length": length, + "signal_frequency": frequency, + "amplitude": amplitude, + "baseband_phase": baseband_phase, + "rf_phase": rf_phase, + "dc_offset": dc_offset, + } + + return Recording(data=complex_sine_wave, metadata=metadata) + + +def square( + sample_rate: Optional[int] = 1000, + length: Optional[int] = 1000, + frequency: Optional[float] = 1, + amplitude: Optional[float] = 1, + duty_cycle: Optional[float] = 0.5, + baseband_phase: Optional[float] = 0, + rf_phase: Optional[float] = 0, + dc_offset: Optional[float] = 0, +) -> Recording: + """Generate a square wave signal. + + :param sample_rate: The number of samples per second (Hz). Defaults to 1,000. + :type sample_rate: int, optional + :param length: Number of samples in the recording. Defaults to 1,000. + :type length: int, optional + :param frequency: The frequency of the square wave (Hz). Defaults to 1. + :type frequency: float, optional + :param amplitude: The amplitude of the square wave. Defaults to 1. + :type amplitude: float, optional + :param duty_cycle: The duty cycle of the square wave as a decimal in the range [0, 1]. Defaults to 0.5. + :param baseband_phase: Phase offset in radians, relative to the square wave frequency. Defaults to 0. + :type baseband_phase: float, optional + :param rf_phase: Phase offset in radians of the complex samples. Defaults to 0. + :type rf_phase: float, optional + :param dc_offset: DC offset. If dc_offset is 0 but duty_cycle is not 0.5, the actual dc offset may not be + exactly 0. Defaults to 0. + :type dc_offset: float, optional + + :return: A Recording object containing the generated square wave signal. + :rtype: Recording + + Examples: + + .. todo:: Usage examples coming soon! + """ + + if sample_rate < 1: + raise ValueError("sample_rate must be > 1") + + t = np.arange(length) + square_wave = amplitude * scipy.signal.square( + 2 * np.pi * frequency * (t / sample_rate - (baseband_phase / (2 * np.pi))), duty=duty_cycle + ) + square_wave = square_wave + dc_offset + complex_square_wave = square_wave * np.exp(1j * rf_phase) + + metadata = { + "signal": "square", + "source": "synth", + "sample_rate": sample_rate, + "length": length, + "signal_frequency": frequency, + "amplitude": amplitude, + "baseband_phase": baseband_phase, + "duty_cycle": duty_cycle, + "rf_phase": rf_phase, + "dc_offset": dc_offset, + } + + return Recording(data=complex_square_wave, metadata=metadata) + + +def sawtooth( + sample_rate: Optional[int] = 1000, + length: Optional[int] = 1000, + frequency: Optional[float] = 1, + amplitude: Optional[float] = 1, + baseband_phase: Optional[float] = 0, + rf_phase: Optional[float] = 0, + dc_offset: Optional[float] = 0, +) -> Recording: + """Generate a sawtooth wave signal. + + :param sample_rate: The number of samples per second (Hz). Defaults to 1,000. + :type sample_rate: int, optional + :param length: Number of samples in the recording. Defaults to 1,000. + :type length: int, optional + :param frequency: The frequency of the sawtooth wave (Hz). Defaults to 1. + :type frequency: float, optional + :param amplitude: Amplitude of the sawtooth wave. Defaults to 1. + :type amplitude: float, optional + :param baseband_phase: Phase offset in radians, relative to the wave frequency. Defaults to 0. + :type baseband_phase: float, optional + :param rf_phase: Phase offset in radians of the complex samples. Defaults to 0. + :type rf_phase: float, optional + :param dc_offset: DC offset (average of the wave). Defaults to 0. + :type dc_offset: float, optional + + :return: A Recording object containing the generated sawtooth signal. + :rtype: Recording + + Examples: + + .. todo:: Usage examples coming soon! + """ + + if sample_rate < 1: + raise ValueError("sample_rate must be > 1") + + t = np.arange(length) + + saw_wave = amplitude * scipy.signal.sawtooth( + 2 * np.pi * frequency * (t / sample_rate - (baseband_phase / (2 * np.pi))) + ) + saw_wave = saw_wave + dc_offset + complex_sine_wave = saw_wave * np.exp(1j * rf_phase) + + metadata = { + "signal": "sawtooth", + "source": "synth", + "sample_rate": sample_rate, + "length": length, + "signal_frequency": frequency, + "amplitude": amplitude, + "baseband_phase": baseband_phase, + "rf_phase": rf_phase, + "dc_offset": dc_offset, + } + + return Recording(data=complex_sine_wave, metadata=metadata) + + +def noise( + sample_rate: Optional[int] = 1000, + length: Optional[int] = 1000, + rms_power: Optional[float] = 0.2, + dc_offset: Optional[float] = 0, +) -> Recording: + """Generate a Gaussian white noise (GWN) wave signal. + + :param sample_rate: The number of samples per second (Hz). Defaults to 1,000. + :type sample_rate: int, optional + :param length: Number of samples in the recording. Defaults to 1,000. + :type length: int, optional + :param rms_power: Root-Mean-Square power of the generated signal. Defaults to 0.2. + :type rms_power: float, optional + :param dc_offset: DC offset (average of the wave). Defaults to 0. + :type dc_offset: float, optional + + :return: A Recording object containing the generated noise signal. + :rtype: Recording + + Examples: + + .. todo:: Usage examples coming soon! + """ + + if sample_rate < 1: + raise ValueError("sample_rate must be > 1") + + variance = rms_power**2 + magnitude = np.random.normal(loc=0, scale=np.sqrt(variance), size=length) + magnitude2 = np.clip(magnitude, -1, 1) + + # TODO figure out a better way to make it conform to [-1,1] + if not np.array_equal(magnitude, magnitude2): + print("Warning: clipping in basic_signal_generator.noise") + + phase = np.random.uniform(low=0, high=2 * np.pi, size=length) + complex_awgn = magnitude2 * np.exp(1j * phase) + complex_awgn = complex_awgn + dc_offset + metadata = { + "signal": "awgn", + "source": "synth", + "sample_rate": sample_rate, + "length": length, + "amplitude": np.max(np.abs(complex_awgn)), + "dc_offset": dc_offset, + } + + return Recording(data=complex_awgn, metadata=metadata) + + +def chirp(sample_rate: int, num_samples: int, center_frequency: Optional[float] = 0) -> Recording: + """Generator a sinusoidal waveform with a linear frequency sweep. + + Start and end frequencies are chosen based on the maximum frequency range that can be covered without aliasing, + which is determined by the sample rate. To chirp over a larger frequency range, increase the sample rate. + + Chirps are often used in radar, sonar, and communication systems because they can effectively cover a wide + frequency range and are useful for testing and measurement purposes. + + :param sample_rate: The number of samples per second (Hz). + :type sample_rate: int + :param num_samples: The number of samples in the chirp. + :type num_samples: int + :param center_frequency: The center frequency of the chirp. + :type center_frequency: float, optional + + :return: A Recording object containing the generated noise signal. + :rtype: Recording + + Examples: + + .. todo:: Usage examples coming soon! + """ + # Ensure that the generated chirp signal remains within a safe frequency range to avoid aliasing. + chirp_start_frequency = center_frequency - sample_rate / 4 + chirp_end_frequency = center_frequency + sample_rate / 4 + + t = np.arange(num_samples) / int(sample_rate) + + f_t = chirp_start_frequency + (chirp_end_frequency - chirp_start_frequency) * t / t[-1] + complex_samples = np.exp(2.0j * np.pi * f_t * t) + + metadata = {"sample_rate": sample_rate, "num_samples:": num_samples} + + return Recording(data=complex_samples, metadata=metadata) + + +def lfm_chirp_complex( + sample_rate: int, width: int, chirp_period: float, sigfc: int | float, total_time: float, chirp_type: str +): + """ + Generate a complex linearly frequency modulated chirp signal. + + :param sample_rate: + """ + # Time vector for one chirp + chirp_length = int(chirp_period * sample_rate) + t_chirp = np.linspace(0, chirp_period, chirp_length) + if len(t_chirp) > chirp_length: + t_chirp = t_chirp[:chirp_length] + # Generate one chirp from 0 Hz to the full width + if chirp_type == "up": + baseband_chirp = sci_chirp(t_chirp, f0=0, f1=width, t1=chirp_period, method="linear") + elif chirp_type == "down": + baseband_chirp = sci_chirp(t_chirp, f0=width, f1=0, t1=chirp_period, method="linear") + elif chirp_type == "up_down": + half_duration = chirp_period / 2 + t_up_half, t_down_half = np.array_split(t_chirp, 2) + + up_part = sci_chirp(t_up_half, f0=0, t1=half_duration, f1=width, method="linear") + down_part = np.flip(up_part) + baseband_chirp = np.concatenate([up_part, down_part]) + + # Generate the full signal by tiling the windowed chirp + num_chirps = round(total_time / chirp_period) + full_signal = np.tile(baseband_chirp, num_chirps) + # Create an analytic signal (complex with no negative frequency components) + analytic_signal = hilbert(full_signal) + # Shift the chirp to the signal center frequency + t_full = np.linspace(0, total_time, len(analytic_signal)) + complex_chirp = analytic_signal * np.exp(1j * 2 * np.pi * (sigfc - width / 2) * t_full) + + nyquist = 0.5 * sample_rate # Nyquist frequency + normal_cutoff = width / nyquist # Normalize cutoff + b, a = butter(8, normal_cutoff, btype="low", analog=False) + filtered_chirp = lfilter(b, a, complex_chirp) + + metadata = { + "source": "basic_signal_generator", + "sample_rate": sample_rate, + "width": width, + "chirp_period": chirp_period, + "chirp_center_frequency": sigfc, + "total_time": total_time, + "filter": "low_pass", + } + + return Recording(data=filtered_chirp, metadata=metadata) + + +def complex_sine(sample_rate, length, frequency): + """ + Generates a complex sine wave. + + :param sample_rate: The number of samples per second (Hz). Defaults to 1,000. + :type sample_rate: int, optional + :param length: Number of samples in the recording. Defaults to 1,000. + :type length: int, optional + :param frequency: The frequency of the square wave (Hz). Defaults to 1. + :type frequency: float, optional + """ + + if sample_rate < 1: + raise ValueError("sample_rate must be > 1") + + total_time = length / sample_rate + t = np.linspace(0, total_time, length, endpoint=False) + power_factor = np.random.uniform(-8, 0) + complex_sine_wave = (10**power_factor) * np.exp(1j * 2 * np.pi * frequency * t) + + metadata = { + "signal": "complex_sine", + "source": "synth", + "sample_rate": sample_rate, + "length": length, + "signal_frequency": frequency, + "power_factor": power_factor, + } + + return Recording(data=complex_sine_wave, metadata=metadata) + + +def birdie(sample_rate, length, frequency): + """ + Generates a complex sine wave for birdies in demos. + + :param sample_rate: The number of samples per second (Hz). Defaults to 1,000. + :type sample_rate: int, optional + :param length: Number of samples in the recording. Defaults to 1,000. + :type length: int, optional + :param frequency: The frequency of the square wave (Hz). Defaults to 1. + :type frequency: float, optional + """ + + if sample_rate < 1: + raise ValueError("sample_rate must be > 1") + + total_time = length / sample_rate + t = np.linspace(0, total_time, length, endpoint=False) + power_factor = np.random.uniform(-2.5, -0.5) + complex_sine_wave = (10**power_factor) * np.exp(1j * 2 * np.pi * frequency * t) + + metadata = { + "signal": "complex_sine", + "source": "synth", + "sample_rate": sample_rate, + "length": length, + "signal_frequency": frequency, + "power_factor": power_factor, + } + + return Recording(data=complex_sine_wave, metadata=metadata) diff --git a/src/ria_toolkit_oss/signal/block_generator/README.md b/src/ria_toolkit_oss/signal/block_generator/README.md new file mode 100644 index 0000000..79334aa --- /dev/null +++ b/src/ria_toolkit_oss/signal/block_generator/README.md @@ -0,0 +1,63 @@ +# RIA Block Signal Generator +Welcome to the RIA block generator! These modular signal processing blocks can be used together to create synthetic radio signals, and it is easy to add new blocks. + +These instructions apply to using the block system within python, and not to the front end GUI. + +# Overview +A block can be a SourceBlock or a ProcessBlock. Either of these can also be a RecordableBlock, or not. +SourceBlocks produce samples, and have no input. +ProcessBlocks process samples. They also provide a .process() method that can be used to directly operate on samples without using the block system. + +RecordableBlocks provide a .record() method to create a recording. Some blocks, such as the RandomBinarySource produce non IQ sample formats such as bits, which is why they are not recordable. + +Blocks are connected in a tree structure terminating in a final RecordableBlock. Blocks may have multiple inputs but can only have one output, and this output cannot be connected to the inputs of more than one block. + +# Getting Started +Let's create a block flow tree to create a QPSK signal, add a LFM jamming signal, and add some noise. + +First, imports: +``` +from ria_toolkit_oss.signal.block_generator import RandomBinarySource, Mapper, Upsampling, RaisedCosineFilter, FrequencyShift, LFMChirpSource, Add, AWGNSource\ + +sample_rate = 1000000 +``` + +Create the random binary source block: +``` +source = RandomBinarySource() +``` + +Create a constellation mapper block to convert bits to QPSK symbols, connecting its input to the source block. +``` +mapper = Mapper(input=[source], constellation_type="PSK", num_bits_per_symbol=2) +``` + +Add an upsampling block and a raised cosine filter for pulse shaping: +``` +upsampler = Upsampling(input = [mapper], factor = 4) +filter = RaisedCosineFilter(input=[upsampler], span_in_symbols=100, upsampling_factor=4, beta=0.1) +``` + +Create another branch of the block tree for the LFM jamming source and frequency shifter: +``` +jammer=LFMChirpSource(sample_rate=sample_rate, bandwidth=sample_rate/2, chirp_period=0.01, chirp_type='up') +f_shift = FrequencyShift(input = [jammer], shift_frequency=100000, sampling_rate=sample_rate) +``` + +Sum the two signals with an Add block: +``` +adder = Add(input=[filter, f_shift]) +``` + +Add another branch to create noise: +``` +awgn_source = AWGNSource(variance = 0.05) +adder2 = Add(input = [adder, awgn_source]) +``` + +Finally create a recording at the terminal block in the tree: +``` +recording = mapper.record(100000) +recording.view() +recording.to_sigmf() +``` \ No newline at end of file diff --git a/src/ria_toolkit_oss/signal/block_generator/__init__.py b/src/ria_toolkit_oss/signal/block_generator/__init__.py new file mode 100644 index 0000000..8f4145e --- /dev/null +++ b/src/ria_toolkit_oss/signal/block_generator/__init__.py @@ -0,0 +1,88 @@ +""" +RIA Block-Based Signal Generator Module + +This module provides a flexible framework for simulating communication systems using configurable blocks. It includes: + +- Various block types: filters, mappers, modulators, demodulators, and channels +- Easy-to-use classes for creating custom signal processing chains +- Pre-configured generators for common use cases + +Key features: + +- Modular design for building complex systems +- Customizable block parameters +- Ready-to-use generators for quick prototyping + +Usage: + +1. Import desired blocks +2. Configure block parameters +3. Connect blocks to create a processing chain +4. Run simulations with custom or provided input signals + +For detailed examples and API reference, see the documentation. +""" + +from .basic import Add, FrequencyShift, MultiplyConstant, PhaseShift +from .generators import ( + PAMGenerator, + PSKGenerator, + QAMGenerator, + SignalGenerator, +) +from .mapping import Mapper, SymbolDemapper +from .process_block import ProcessBlock +from .pulse_shaping import ( + GaussianFilter, + RaisedCosineFilter, + RectFilter, + RootRaisedCosineFilter, + SincFilter, + Upsampling, +) +from .recordable_block import RecordableBlock +from .siso_channel import AWGNChannel, FlatRayleigh +from .source import ( + AWGNSource, + BinarySource, + ConstantSource, + LFMChirpSource, + RecordingSource, + SawtoothSource, + SineSource, + SquareSource, +) +from .source_block import SourceBlock +from .symbol_modulation import GMSKModulator, OOKModulator, OQPSKModulator + +__all__ = [ + "Add", + "FrequencyShift", + "MultiplyConstant", + "PhaseShift", + "PAMGenerator", + "PSKGenerator", + "QAMGenerator", + "SignalGenerator", + "Mapper", + "SymbolDemapper", + "GMSKModulator", + "OOKModulator", + "OQPSKModulator", + "RaisedCosineFilter", + "RootRaisedCosineFilter", + "SincFilter", + "RectFilter", + "GaussianFilter", + "Upsampling", + "AWGNChannel", + "FlatRayleigh", + "AWGNSource", + "ConstantSource", + "LFMChirpSource", + "BinarySource", + "RecordingSource", + "SawtoothSource", + "SineSource", + "SquareSource", +] diff --git a/src/ria_toolkit_oss/signal/block_generator/basic/__init__.py b/src/ria_toolkit_oss/signal/block_generator/basic/__init__.py new file mode 100644 index 0000000..1811e66 --- /dev/null +++ b/src/ria_toolkit_oss/signal/block_generator/basic/__init__.py @@ -0,0 +1,6 @@ +from .add import Add +from .frequency_shift import FrequencyShift +from .multiply_constant import MultiplyConstant +from .phase_shift import PhaseShift + +__all__ = ["Add", "FrequencyShift", "MultiplyConstant", "PhaseShift"] diff --git a/src/ria_toolkit_oss/signal/block_generator/basic/add.py b/src/ria_toolkit_oss/signal/block_generator/basic/add.py new file mode 100644 index 0000000..9830f7b --- /dev/null +++ b/src/ria_toolkit_oss/signal/block_generator/basic/add.py @@ -0,0 +1,69 @@ +import numpy as np + +from ria_toolkit_oss.signal.block_generator.data_types import DataType +from ria_toolkit_oss.signal.block_generator.process_block import ProcessBlock +from ria_toolkit_oss.signal.block_generator.recordable_block import RecordableBlock + + +class Add(RecordableBlock, ProcessBlock): + """ + Add Block + + Sums the input from two blocks. + + Input type: [BASEBAND_SIGNAL, BASEBAND_SIGNAL] + + Output type: BASEBAND_SIGNAL + """ + + def __init__(self): + super().__init__() + + def connect_input(self, input): + datatype = input[0].output_type + for input_block in input: + if input_block.output_type != datatype: + print(input_block.output_type) + raise ValueError( + f"'Add' block requires inputs to have the same datatype but got \ + {'[' + ',' .join(f'{block.__class__.__name__}({block.output_type()})' for block in input) + ']'}" + ) # TODO make this print the strings not numbers + return super().connect_input(input) + + def _get_input_samples(self, block, num_samples): + """ + Request n samples from a block and validate the correct shape of CxN samples was received. + """ + + samples = block.get_samples(num_samples) + if len(samples) != num_samples: + raise ValueError( + f"Block {self.__class__.__name__} requested {num_samples} \ + from block {block.__class__.__name__} but got {len(samples)}." + ) + + return samples + + @property + def input_type(self): + return [DataType.BASEBAND_SIGNAL, DataType.BASEBAND_SIGNAL] + + @property + def output_type(self): + return DataType.BASEBAND_SIGNAL + + def __call__(self, samples: list[np.array]): + """ + Add two signals together. + + :param samples: A list containing two sample arrays of the same length. + :type samples: list of np.array + + :returns: An array of output samples. + :rtype: np.array""" + + if len(samples) != 2: + raise ValueError("Input must be a list of two input arrays.") + if len(samples[0]) != len(samples[1]): + raise ValueError(f"Input arrays must be equal length but were {len(samples[0])} and {len(samples[1])}") + return samples[0] + samples[1] diff --git a/src/ria_toolkit_oss/signal/block_generator/basic/frequency_shift.py b/src/ria_toolkit_oss/signal/block_generator/basic/frequency_shift.py new file mode 100644 index 0000000..a65c288 --- /dev/null +++ b/src/ria_toolkit_oss/signal/block_generator/basic/frequency_shift.py @@ -0,0 +1,56 @@ +from typing import Optional + +import numpy as np + +from ria_toolkit_oss.signal.block_generator.data_types import DataType +from ria_toolkit_oss.signal.block_generator.process_block import ProcessBlock +from ria_toolkit_oss.signal.block_generator.recordable_block import RecordableBlock + + +class FrequencyShift(ProcessBlock, RecordableBlock): + """ + Frequency Shift Block + + Applies a frequency shift the input signal. + + Input type: BASEBAND_SIGNAL + Output type: BASEBAND_SIGNAL + + :param shift_frequency: The frequency to shift the signal by. + :type shift_frequency: float + :param sample_rate: The sample rate to use in frequency calculations. + :type sample_rate: float. + + WARNING: This block does not include any anti-aliasing filters. + It is the responsiblity of the user to ensure proper + filtering is performed before/after this block to prevent aliasing. + """ + + def __init__(self, shift_frequency: Optional[float] = 100000, sampling_rate: Optional[float] = 1000000): + self.shift_frequency = shift_frequency + self.sampling_rate = sampling_rate + super().__init__() + + @property + def input_type(self) -> DataType: + return [DataType.BASEBAND_SIGNAL] + + @property + def output_type(self) -> DataType: + return DataType.BASEBAND_SIGNAL + + def __call__(self, samples: list[np.array]): + """ + Frequency shift input samples by the previously intialized shift frequency. + + :param samples: A list containing a single array of complex samples. + :type samples: list of np.array + + :returns: Processed samples. + :rtype: np.array + """ + signal = samples[0] + num_samples = len(signal) + t = np.arange(num_samples) / self.sampling_rate + carrier = np.exp(1j * 2 * np.pi * self.shift_frequency * t) + return signal * carrier diff --git a/src/ria_toolkit_oss/signal/block_generator/basic/multiply_constant.py b/src/ria_toolkit_oss/signal/block_generator/basic/multiply_constant.py new file mode 100644 index 0000000..2172c5d --- /dev/null +++ b/src/ria_toolkit_oss/signal/block_generator/basic/multiply_constant.py @@ -0,0 +1,41 @@ +from typing import Optional + +from ria_toolkit_oss.signal.block_generator.data_types import DataType +from ria_toolkit_oss.signal.block_generator.process_block import ProcessBlock +from ria_toolkit_oss.signal.block_generator.recordable_block import RecordableBlock + + +class MultiplyConstant(ProcessBlock, RecordableBlock): + """ + MultiplyConstant Block + + Multiply the input samples by a constant. + + Input Type: BASEBAND_SIGNAL + Output Type: BASEBAND_SIGNAL + + :param multiplier: The value to multiply the samples by. + :type multiplier: float. + """ + + def __init__(self, multiplier: Optional[float] = 0.5): + self.multiplier = multiplier + + @property + def input_type(self): + return [DataType.BASEBAND_SIGNAL] + + @property + def output_type(self): + return DataType.BASEBAND_SIGNAL + + def __call__(self, samples): + """ + Multiply an array of complex samples by the previously initialised value. + + :param samples: A list containing a single array of complex samples. + :type samples: list of np.array + + :returns: Processed samples. + :rtype: np.array""" + return samples[0] * self.multiplier diff --git a/src/ria_toolkit_oss/signal/block_generator/basic/phase_shift.py b/src/ria_toolkit_oss/signal/block_generator/basic/phase_shift.py new file mode 100644 index 0000000..e6db272 --- /dev/null +++ b/src/ria_toolkit_oss/signal/block_generator/basic/phase_shift.py @@ -0,0 +1,40 @@ +from typing import Optional + +import numpy as np + +from ria_toolkit_oss.signal.block_generator.data_types import DataType +from ria_toolkit_oss.signal.block_generator.process_block import ProcessBlock +from ria_toolkit_oss.signal.block_generator.recordable_block import RecordableBlock + + +class PhaseShift(ProcessBlock, RecordableBlock): + """ + PhaseShift Block + + Apply a complex phase shift to the input signal. + + :param phase: The complex phase shift in radians. + :type phase: float.""" + + def __init__(self, phase: Optional[float] = 0): + self.phase = phase + super().__init__() + + @property + def input_type(self): + return [DataType.BASEBAND_SIGNAL] + + @property + def output_type(self): + return DataType.BASEBAND_SIGNAL + + def __call__(self, samples): + """ + Phase shift an array of complex samples by the previously initialised phase. + + :param samples: A list containing a single array of complex samples. + :type samples: list of np.array + + :returns: Processed samples. + :rtype: np.array""" + return samples[0] * np.exp(1j * self.phase) diff --git a/src/ria_toolkit_oss/signal/block_generator/block.py b/src/ria_toolkit_oss/signal/block_generator/block.py new file mode 100644 index 0000000..956c1b7 --- /dev/null +++ b/src/ria_toolkit_oss/signal/block_generator/block.py @@ -0,0 +1,122 @@ +import json +from abc import ABC, abstractmethod + +import numpy as np + +from ria_toolkit_oss.signal.block_generator.data_types import DataType + + +class Block(ABC): + """ + Abstract base class for signal processing blocks. + + This class defines the interface for all signal processing blocks, + including input and output data types and the call method for processing. + """ + + @property + @abstractmethod + def input_type(self) -> DataType: + """ + Get the input data type for the block. + + :return: The input data type. + :rtype: DataType + """ + pass + + @property + @abstractmethod + def output_type(self) -> DataType: + """ + Get the output data type for the block. + + :return: The output data type. + :rtype: DataType + """ + pass + + @abstractmethod + def get_samples(self, num_samples) -> np.ndarray: + """ + Process the input data and produce output. + + :param args: Positional arguments for the processing method. + :param kwargs: Keyword arguments for the processing method. + :return: The processed output data. + :rtype: numpy array + """ + pass + + def _get_metadata(self): + metadata = {} + for key, value in vars(self).items(): + try: + # Try to serialize the value to check if it's JSON serializable + json.dumps(value) + metadata[f"BlockGenerator:{self.__class__.__name__}:{key}"] = value + except (TypeError, ValueError): + # If the value is not JSON serializable, skip it + continue + + for block in self.input: + metadata = self._combine_dicts_and_handle_double_keys(block._get_metadata(), metadata) + + return metadata + + # TODO improve this + def _combine_dicts_and_handle_double_keys(self, source_dict, other_dict): + for key, value in source_dict.items(): + # Find the last colon in the key + last_colon_index = key.rfind(":") + + # Ensure there's at least one colon in the key + if last_colon_index == -1: + # If no colon, just append "(1)" + new_key = f"{key}(1)" + else: + # Extract the prefix and the part after the last colon + prefix = key[:last_colon_index] + suffix = key[last_colon_index + 1 :] + + # Check if the suffix has a number inside parentheses + if suffix.startswith("(") and suffix.endswith(")") and suffix[1:-1].isdigit(): + # Extract the number inside the parentheses and increment it + number = int(suffix[1:-1]) + 1 + new_key = f"{prefix}({number})" + else: + # No number at the end, so just append "(1)" + new_key = f"{key}(1)" + + # Ensure the new key is unique in both dictionaries + while new_key in other_dict: + # Find the last parentheses to extract the current number + last_paren_index = new_key.rfind(")") + prefix = new_key[:last_paren_index] + suffix = new_key[last_paren_index + 1 :] + + # Extract the number in parentheses and increment it + if suffix.startswith("(") and suffix.endswith(")") and suffix[1:-1].isdigit(): + number = int(suffix[1:-1]) + 1 + else: + number = 1 # Default to 1 if no number in parentheses + + # Create the new key with the incremented number + new_key = f"{prefix}({number})" + + # Update the other dictionary with the new key + other_dict[new_key] = value + + return other_dict + + @abstractmethod + def __call__(self, *args, **kwargs) -> np.ndarray: + """ + Process the input data and produce output. + + :param args: Positional arguments for the processing method. + :param kwargs: Keyword arguments for the processing method. + :return: The processed output data. + :rtype: numpy array + """ + pass diff --git a/src/ria_toolkit_oss/signal/block_generator/continuous_modulation/__init__.py b/src/ria_toolkit_oss/signal/block_generator/continuous_modulation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/ria_toolkit_oss/signal/block_generator/continuous_modulation/coherent_correlator.py b/src/ria_toolkit_oss/signal/block_generator/continuous_modulation/coherent_correlator.py new file mode 100644 index 0000000..e96ef8d --- /dev/null +++ b/src/ria_toolkit_oss/signal/block_generator/continuous_modulation/coherent_correlator.py @@ -0,0 +1,112 @@ +import numpy as np + +from ria_toolkit_oss.signal.block_generator.continuous_modulation.demodulator import ( + Demodulator, +) +from ria_toolkit_oss.signal.block_generator.data_types import DataType + + +class CoherentCorrelator(Demodulator): + """ + A correlator for coherent detection that performs frequency downconversion via correlation. + + This class implements a coherent correlator by multiplying the received passband signal + with a reference carrier and integrating (or convolving with an optional matched filter) + over one symbol period. The reference carrier can be generated in one of two ways: + - If 'per_symbol' is True, the carrier reference is generated for each symbol separately + (i.e. a time vector that resets to zero for every symbol). + - If 'per_symbol' is False, a continuous time vector is used over the entire signal. + + Optionally, a pulse-shaping filter (subclass of PulseShapingFilter) can be provided. When set, + each symbol's downconverted product is first convolved with the matched filter (via its + `apply_matched_filter` method) before integration. If not provided, a simple integration (sum) + is performed. + + :param carrier_frequency: The carrier frequency (Hz) used for demodulation. + :param symbol_duration: The duration (seconds) of one symbol period. + :param sampling_rate: The sampling rate (Hz) of the received signal. + :param per_symbol: If True, uses a per-symbol time vector; if False, uses a continuous time vector. + """ + + def __init__( + self, + carrier_frequency: float, + symbol_duration: float, + sampling_rate: float, + per_symbol: bool = True, + ): + self.carrier_frequency = carrier_frequency + self.symbol_duration = symbol_duration + self.sampling_rate = sampling_rate + self.samples_per_symbol = int(self.symbol_duration * self.sampling_rate) + self.per_symbol = per_symbol + + @property + def input_type(self) -> DataType: + """The correlator expects a passband signal as input.""" + return DataType.PASSBAND_SIGNAL + + @property + def output_type(self) -> DataType: + """The correlator produces decision statistics (typically complex or real values).""" + return DataType.BITS + + def __call__(self, signal: np.ndarray) -> np.ndarray: + """ + Correlate the input passband signal with a reference carrier to produce decision statistics. + + The input signal is assumed to be a 2D numpy array of shape (batch_size, total_samples), + where total_samples is an integer multiple of the number of samples per symbol. + + Depending on the 'per_symbol' flag, the reference carrier is generated as: + - If True: a per-symbol time vector (from 0 to symbol_duration) is used. + - If False: a continuous time vector for the entire signal is used. + + If a pulse shaping filter is provided (self.filter is not None), the symbol's product + (signal multiplied by the reference carrier) is convolved with the filter via its + `apply_matched_filter` method before integration. + + :param signal: The input passband signal (shape: (batch_size, total_samples)). + :return: A 2D numpy array of decision statistics with shape (batch_size, num_symbols). + :raises ValueError: If the total number of samples is not an integer multiple of samples_per_symbol. + """ + batch_size, total_samples = signal.shape + samples_per_symbol = self.samples_per_symbol + + if total_samples % samples_per_symbol != 0: + raise ValueError( + "The total number of samples in the signal must be an integer multiple of the samples per symbol." + ) + + num_symbols = total_samples // samples_per_symbol + # Reshape the signal into symbols: shape (batch_size, num_symbols, samples_per_symbol) + symbols = signal.reshape(batch_size, num_symbols, samples_per_symbol) + + if self.per_symbol: + # Generate per-symbol time vector (from 0 to symbol_duration) + t_symbol = np.arange(samples_per_symbol) / self.sampling_rate + if np.iscomplexobj(signal): + reference = np.exp(-1j * 2 * np.pi * self.carrier_frequency * t_symbol) + else: + reference = np.cos(2 * np.pi * self.carrier_frequency * t_symbol) + # Multiply each symbol with the reference (broadcasted) to obtain the product. + product = symbols * reference[None, None, :] + else: + # Use a continuous time vector for the entire signal. + t_full = np.arange(total_samples) / self.sampling_rate + if np.iscomplexobj(signal): + reference_full = np.exp(-1j * 2 * np.pi * self.carrier_frequency * t_full) + else: + reference_full = np.cos(2 * np.pi * self.carrier_frequency * t_full) + reference_full = reference_full.reshape(1, num_symbols, samples_per_symbol) + product = symbols * reference_full + + decision_stats = np.sum(product, axis=2) + return decision_stats + + def __str__(self) -> str: + """Return a string representation of the CoherentCorrelator.""" + return ( + f"CoherentCorrelator(carrier_frequency={self.carrier_frequency}, " + f"symbol_duration={self.symbol_duration}, sampling_rate={self.sampling_rate} " + ) diff --git a/src/ria_toolkit_oss/signal/block_generator/continuous_modulation/cpfsk_demodulator.py b/src/ria_toolkit_oss/signal/block_generator/continuous_modulation/cpfsk_demodulator.py new file mode 100644 index 0000000..ecdef00 --- /dev/null +++ b/src/ria_toolkit_oss/signal/block_generator/continuous_modulation/cpfsk_demodulator.py @@ -0,0 +1,218 @@ +import itertools +import warnings +from typing import List, Tuple + +import numpy as np + +from ria_toolkit_oss.signal.block_generator.continuous_modulation.demodulator import ( + Demodulator, +) +from ria_toolkit_oss.signal.block_generator.data_types import DataType +from ria_toolkit_oss.signal.block_generator.mapping.mapper import Mapper +from ria_toolkit_oss.signal.block_generator.mapping.symbol_demapper import ( + SymbolDemapper, +) +from ria_toolkit_oss.signal.block_generator.pulse_shaping.gaussian_filter import ( + GaussianFilter, +) +from ria_toolkit_oss.signal.block_generator.pulse_shaping.rect_filter import RectFilter + + +class CPFSKDemodulator(Demodulator): + """ + M-ary CPFSK demodulator. + + Two operating modes + ------------------- + • symbol_by_symbol = True ⇢ identical to your original code + • symbol_by_symbol = False ⇢ runs an L-memory Viterbi detector + (L set by `va_memory`) + + The Viterbi detector models the residual ISI introduced by the Gaussian/ + rectangular pulse as a *linear* partial-response channel whose taps are + extracted automatically from the matched-filter output of an impulse. + """ + + def __init__( + self, + num_bits_per_symbol: int, + frequency_spacing: float, + symbol_duration: float, + sampling_frequency: float, + gaussian: bool = False, + bt: float = 0.3, + symbol_by_symbol: bool = False, + ): + super().__init__() + self.M_bits = num_bits_per_symbol + self.M = 1 << num_bits_per_symbol # 2,4,8,… + self.freq_sep = frequency_spacing + self.Ts = symbol_duration + self.Fs = sampling_frequency + self.sps = int(self.Fs * self.Ts) # samples / symbol + if self.sps % 2 == 0: # keep it odd + self.sps += 1 + self.symbol_by_symbol = symbol_by_symbol + + # ------------------------------------------------------------------ # + # front‑end filter (same as transmitter) and matched‑filter partner # + # ------------------------------------------------------------------ # + if gaussian: + self.filter = GaussianFilter(3, upsampling_factor=self.sps, bt=bt, normalize=False) + else: + self.filter = RectFilter(1, upsampling_factor=self.sps) + self.va_mem = self.filter.span_in_symbols + # Mapper / Demapper (PAM levels are −(M−1), …, +(M−1)) + self.mapper = Mapper("pam", num_bits_per_symbol, normalize=False) + self.const = self.mapper.get_constellation() # (M,) + self.bit_map = self.mapper.get_bit_mapping() # dict: sym→bits + self.demapper = SymbolDemapper(self.const, self.bit_map) + + # ------------------------------------------------------------------ # + # Pre‑compute symbol‑rate channel taps for the Viterbi branch # + # ------------------------------------------------------------------ # + self.taps = self._symbol_rate_taps(self.va_mem) # (L,) + # NOTE: taps[0] is always 1 because of matched filtering normalisation + + # Build state mapping once (for VA) + self._states, self._prev_lookup = self._enumerate_states() + + @property + def input_type(self) -> DataType: + return DataType.BASEBAND_SIGNAL + + @property + def output_type(self) -> DataType: + return DataType.BITS + + def __call__(self, signal: np.ndarray) -> np.ndarray: + + batches, total = signal.shape + n_sym = total // self.sps + if total % self.sps: + signal = signal[:, : n_sym * self.sps] + warnings.warn("Input truncated to an integer number of symbols.") + + # -------------------------------------------------------------- # + # Phase → freq → matched‑filter (identical to your original) # + # -------------------------------------------------------------- # + # phase = np.angle(signal) + # phase_unwrap = np.unwrap(phase, axis=1) + # diff_phase = np.diff(phase_unwrap, axis=1) + dtheta = np.angle(signal[:, 1:] * np.conj(signal[:, :-1])) # length N‑1 + freq_est = dtheta * self.Fs / (2 * np.pi) # Hz + u_est = freq_est / (self.freq_sep / 2) + # freq_est = diff_phase * self.Fs / (2 * np.pi) # Hz + # u_est = freq_est / (self.freq_sep / 2) # ±1,±3,±5… + u_matched = self.filter.apply_matched_filter(u_est) + + start = self.filter.span_in_symbols * self.sps + soft = u_matched[:, start :: self.sps][:, :n_sym] # (B, K) + + if self.symbol_by_symbol or self.va_mem == 1: + # ---------- legacy: slice & direct PAM demap -------------- + return self._pam_slice_demod(soft) + + # ---------- new: sequence detector on each burst -------------- + + # Viterbi: iterate over bursts + out = np.empty((batches, n_sym * self.M_bits), dtype=np.uint8) + for b in range(batches): + out[b] = self._viterbi_one_burst(soft[b]) + return out + + # ---------------------------------------------------------------------- # + # Helpers # + # ---------------------------------------------------------------------- # + def _pam_slice_demod(self, soft_symbols: np.ndarray) -> np.ndarray: + """Your original “single-symbol” flow.""" + return self.demapper(soft_symbols.astype(np.complex128)) + + # ---- 1. obtain channel taps at symbol rate --------------------------- # + def _symbol_rate_taps(self, L: int) -> np.ndarray: + """ + Send a delta through the matched filter and sample once / symbol. + Gives the *discrete partial-response channel* h[0..L-1]. + """ + span = self.filter.span_in_symbols + N = (span + 1) * self.sps + 1 + delta = np.zeros(N) + delta[span * self.sps] = 1.0 # impulse at t=0 + mf_out = self.filter.apply_matched_filter(delta[None, :])[0] + taps = mf_out[span * self.sps : span * self.sps + L * self.sps : self.sps] + taps /= taps[0] # normalise so h[0]=1 + return taps # shape (L,) + + # ---- 2. build state book for Viterbi --------------------------------- # + def _enumerate_states(self) -> Tuple[List[Tuple[int, ...]], dict]: + """ + Returns + ------- + states : list of tuples of symbol indices (len = M^{L-1}) + State #i is a tuple of the (L-1) previous symbol *indices*. + prev_lookup : dict[state_index] → list[(prev_state_index, sym_index)] + For fast VA branch generation. + """ + if self.va_mem == 1: + return [()], {0: [(0, m) for m in range(self.M)]} + + states = list(itertools.product(range(self.M), repeat=self.va_mem - 1)) # (L-1)-tuple + to_idx = {s: i for i, s in enumerate(states)} + + prev_lookup = {i: [] for i in range(len(states))} + for i, s in enumerate(states): + for m in range(self.M): + new_s = (m,) + s[:-1] # push current sym in, drop last + prev_lookup[to_idx[new_s]].append((i, m)) + return states, prev_lookup + + # ---- 3. Viterbi over real partial‑response channel ------------------- # + def _viterbi_one_burst(self, soft: np.ndarray) -> np.ndarray: + """ + soft : shape (K,) real matched-filter samples for one burst + Returns hard-bit array length = K * M_bits + """ + K = len(soft) + L = self.va_mem + h = self.taps # (L,) + + n_states = self.M ** (L - 1) if L > 1 else 1 + big = 1e12 + metric = np.zeros(n_states) + big + metric[0] = 0.0 # start from “all zeros” + + # For traceback + surv_state = np.zeros((K, n_states), dtype=np.int32) + surv_symbol = np.zeros((K, n_states), dtype=np.int32) + + const = self.const # symbol amplitudes + + for k in range(K): + yk = soft[k] + mnew = np.zeros_like(metric) + big + for s_cur in range(n_states): + for s_prev, sym_idx in self._prev_lookup[s_cur]: + # predicted sample = h0 * a_k + Σ_{i=1}^{L-1} h_i * a_{k-i} + pred = h[0] * const[sym_idx] + if L > 1: + prev_syms = self._states[s_prev] + for d, a_prev_idx in enumerate(prev_syms, 1): + pred += h[d] * const[a_prev_idx] + br_metric = (yk - pred) ** 2 + cost = metric[s_prev] + br_metric + if cost < mnew[s_cur]: + mnew[s_cur] = cost + surv_state[k, s_cur] = s_prev + surv_symbol[k, s_cur] = sym_idx + metric = mnew + + # ---------- traceback ---------- + s_hat = int(np.argmin(metric)) + sym_hat = np.zeros(K, dtype=int) + for k in range(K - 1, -1, -1): + sym_hat[k] = surv_symbol[k, s_hat] + s_hat = surv_state[k, s_hat] + + # map to bits with your existing SymbolDemapper + sym_amp = np.atleast_2d(const[sym_hat].astype(np.complex128)) # make it complex + return self.demapper(sym_amp) diff --git a/src/ria_toolkit_oss/signal/block_generator/continuous_modulation/cpfsk_demodulator_fc.py b/src/ria_toolkit_oss/signal/block_generator/continuous_modulation/cpfsk_demodulator_fc.py new file mode 100644 index 0000000..96f2700 --- /dev/null +++ b/src/ria_toolkit_oss/signal/block_generator/continuous_modulation/cpfsk_demodulator_fc.py @@ -0,0 +1,140 @@ +import warnings + +import numpy as np +from scipy.signal import hilbert + +from ria_toolkit_oss.signal.block_generator.continuous_modulation.demodulator import ( + Demodulator, +) +from ria_toolkit_oss.signal.block_generator.data_types import DataType +from ria_toolkit_oss.signal.block_generator.mapping.mapper import Mapper +from ria_toolkit_oss.signal.block_generator.mapping.symbol_demapper import ( + SymbolDemapper, # or implement your own +) +from ria_toolkit_oss.signal.block_generator.pulse_shaping.gaussian_filter import ( + GaussianFilter, +) +from ria_toolkit_oss.signal.block_generator.pulse_shaping.rect_filter import RectFilter + + +class CPFSKDemodulator(Demodulator): + """ + A basic CPFSK demodulator that attempts to invert the CPFSKModulator logic: + + 1) Convert real passband to complex baseband (Hilbert transform + mix down). + 2) Unwrap phase and differentiate to estimate instantaneous frequency offset. + 3) Match-filter that offset using the same shape (Rect or Gaussian). + 4) Sample once per symbol and map back to bits with an inverse of your PAM mapper. + + Note: For strongly filtered CPFSK/GFSK, a sequence detector (like Viterbi) is often + required for best performance. This simple approach treats each symbol independently. + """ + + def __init__( + self, + num_bits_per_symbol: int, + center_frequency: float, + frequency_spacing: float, + symbol_duration: float, + sampling_frequency: float, + gaussian: bool = False, + ): + self.num_bits_per_symbol = num_bits_per_symbol + self.center_frequency = center_frequency + self.frequency_spacing = frequency_spacing + self.symbol_duration = symbol_duration + self.sampling_frequency = sampling_frequency + self.samples_per_symbol = int(round(self.symbol_duration * self.sampling_frequency)) + + # Use the same filter type/params as the modulator for matched filtering in freq-domain + if gaussian: + self.filter = GaussianFilter(1, upsampling_factor=self.samples_per_symbol, bt=0.3, normalize=False) + else: + self.filter = RectFilter(1, upsampling_factor=self.samples_per_symbol, normalize=False) + + self.mapper = Mapper("pam", num_bits_per_symbol, normalize=False) + constellation = self.mapper.get_constellation() + bit_mapping = self.mapper.get_bit_mapping() + self.demapper = SymbolDemapper(constellation, bit_mapping) + + @property + def input_type(self) -> DataType: + return DataType.PASSBAND_SIGNAL + + @property + def output_type(self) -> DataType: + return DataType.BITS + + def mixed_difference_derivative(self, x): + """ + Computes the numerical derivative of multiple 1D signals x, + where x has shape (num_signals, num_samples). + + The sampling period is computed as 1 / self.sampling_frequency. + + Derivative is returned in the same shape (num_signals, num_samples), + using: + - Forward difference at the first sample + - Central difference for interior samples + - Backward difference at the last sample + """ + dt = 1.0 / self.sampling_frequency + + # Expect x to have shape (num_signals, num_samples) + num_signals, num_samples = x.shape + + # If not enough samples to take a difference, just return zeros + if num_samples < 2: + return np.zeros_like(x) + + # Allocate output array + dx_dt = np.zeros_like(x) + + # Forward difference at n=0 + # shape: (num_signals,) + dx_dt[:, 0] = (x[:, 1] - x[:, 0]) / dt + + # Central difference for n in [1 ... num_samples-2] + # shape: (num_signals, num_samples-2) + dx_dt[:, 1:-1] = (x[:, 2:] - x[:, :-2]) / (2.0 * dt) + + # Backward difference at n = num_samples - 1 + dx_dt[:, -1] = (x[:, -1] - x[:, -2]) / dt + + return dx_dt + + def __call__(self, signal: np.ndarray) -> np.ndarray: + """ + :param signal: Real passband CPFSK waveforms, shape (batch_size, total_samples). + :return: Recovered bits, shape (batch_size, num_bits). + """ + batch_size, total_samples = signal.shape + num_symbols = total_samples // self.samples_per_symbol + # Ensure total_samples is multiple of samples_per_symbol + if total_samples % self.samples_per_symbol != 0: + # Just truncate if needed + excess = total_samples % self.samples_per_symbol + signal = signal[:, : total_samples - excess] + total_samples = signal.shape[1] + warnings.warn("Truncated input signal to be multiple of samples_per_symbol.") + + # 1) Make an analytic signal along axis=1 (time axis) + analytic = hilbert(signal, axis=1) + + # 2) Instantaneous phase in [-pi, +pi] + phase = np.angle(analytic) # shape => (batch_size, total_samples) + + # 3) Unwrap in time to remove 2*pi jumps + phase_unwrapped = np.unwrap(phase, axis=1) + + # 4) Numerical derivative of phase -> ~ phi'(t) + # Because discrete difference is ~ [phi(n+1)-phi(n)] * fs + diff_phase = np.diff(phase_unwrapped, axis=1) # shape => (batch_size, total_samples-1) + freq_est = (diff_phase * self.sampling_frequency) / (2 * np.pi) + u_est = (freq_est - self.center_frequency) / (self.frequency_spacing / 2) + u_matched = self.filter.apply_matched_filter(u_est) / self.filter.energy + u_matched_ds = u_matched[ + :, self.samples_per_symbol : (num_symbols + 1) * self.samples_per_symbol : self.samples_per_symbol + ] + bits = self.demapper(u_matched_ds) + return bits diff --git a/src/ria_toolkit_oss/signal/block_generator/continuous_modulation/cpfsk_modulator.py b/src/ria_toolkit_oss/signal/block_generator/continuous_modulation/cpfsk_modulator.py new file mode 100644 index 0000000..e7c7868 --- /dev/null +++ b/src/ria_toolkit_oss/signal/block_generator/continuous_modulation/cpfsk_modulator.py @@ -0,0 +1,104 @@ +import warnings +from typing import Optional + +import numpy as np + +from ria_toolkit_oss.signal.block_generator.continuous_modulation.modulator import ( + Modulator, +) +from ria_toolkit_oss.signal.block_generator.data_types import DataType +from ria_toolkit_oss.signal.block_generator.mapping.mapper import Mapper +from ria_toolkit_oss.signal.block_generator.multirate.upsampling import Upsampling +from ria_toolkit_oss.signal.block_generator.pulse_shaping.gaussian_filter import ( + GaussianFilter, +) +from ria_toolkit_oss.signal.block_generator.pulse_shaping.rect_filter import RectFilter + + +class CPFSKModulator(Modulator): + def __init__( + self, + num_bits_per_symbol: int, + frequency_spacing: float, + symbol_duration: float, + sampling_frequency: float, + gaussian: Optional[bool] = False, + ): + # Assert that the frequency spacing and symbol duration are sufficient + # to maintain orthogonality for coherent FSK. + assert frequency_spacing * symbol_duration >= 0.5, ( + "For orthogonal coherent FSK, frequency_spacing * symbol_duration must be at least 0.5. " + f"Received frequency_spacing={frequency_spacing} and symbol_duration={symbol_duration}" + ) + + # Calculate the largest possible carrier frequency from the candidate mapping. + largest_carrier = (2**num_bits_per_symbol - 1) / 2 * frequency_spacing + if sampling_frequency < 2 * largest_carrier: + warnings.warn( + f"Sampling frequency ({sampling_frequency} Hz) is less than twice the largest carrier frequency " + f"({largest_carrier} Hz). This may violate the Nyquist criterion and cause aliasing.", + UserWarning, + ) + + self.num_bits_per_symbol = num_bits_per_symbol + self.frequency_spacing = frequency_spacing + self.symbol_duration = symbol_duration + self.sampling_frequency = sampling_frequency + self.samples_per_symbol = int(self.sampling_frequency * self.symbol_duration) + if self.samples_per_symbol % 2 == 0: + self.samples_per_symbol += 1 + self.pam_mapper = Mapper("pam", num_bits_per_symbol, normalize=False) + self.us = Upsampling(self.samples_per_symbol) + if gaussian: + self.filter = GaussianFilter(3, upsampling_factor=self.samples_per_symbol, bt=0.3, normalize=False) + else: + self.filter = RectFilter(1, upsampling_factor=self.samples_per_symbol, normalize=False) + # self.filter = RootRaisedCosineFilter( + # 1, upsampling_factor=self.samples_per_symbol, beta=0.25, normalize=False) + + @property + def input_type(self) -> DataType: + return DataType.BITS + + @property + def output_type(self) -> DataType: + return DataType.PASSBAND_SIGNAL + + def get_samples(self, num_samples): + raise NotImplementedError + + def __call__(self, bits: np.ndarray) -> np.ndarray: + batch_size, num_bits = bits.shape + + # Validate bit length + if num_bits % self.num_bits_per_symbol != 0: + raise ValueError( + f"The number of bits per row ({num_bits}) must be a multiple of " + f"num_bits_per_symbol ({self.num_bits_per_symbol})." + ) + + # 1) Map bits to symbols (e.g., PAM), shape -> (batch_size, num_symbols) + symbols = np.real(self.pam_mapper(bits)) + + # 2) Upsample each row by 'samples_per_symbol', shape -> (batch_size, num_symbols * samples_per_symbol) + x_upsampled = self.us(symbols) + + # 3) Filter (Rect or Gaussian), shape still -> (batch_size, total_samples) + x_shaped = self.filter(x_upsampled) + + # For CPFSK, interpret x_shaped as a frequency offset around center_frequency. + # A common convention is to let freq_dev = frequency_spacing / 2 if you want ± frequency_spacing/2 offset, + # but you can also set freq_dev = frequency_spacing if that suits your design. + freq_dev = self.frequency_spacing / 2.0 + + # Compute the instantaneous frequency for all samples and all batches + freq_inst = freq_dev * x_shaped # shape: (batch_size, total_samples) + + # Compute the phase increment per sample and perform a cumulative sum along axis=1 (time axis) + phase = np.cumsum(2 * np.pi * freq_inst / self.sampling_frequency, axis=1) + + # Generate the CPFSK waveform by taking the cosine of the phase + total_samples = num_bits // self.num_bits_per_symbol * self.samples_per_symbol + waveform = np.exp(1j * phase)[:, :total_samples] + + return waveform diff --git a/src/ria_toolkit_oss/signal/block_generator/continuous_modulation/cpfsk_modulator_fc.py b/src/ria_toolkit_oss/signal/block_generator/continuous_modulation/cpfsk_modulator_fc.py new file mode 100644 index 0000000..3c8330f --- /dev/null +++ b/src/ria_toolkit_oss/signal/block_generator/continuous_modulation/cpfsk_modulator_fc.py @@ -0,0 +1,108 @@ +import warnings +from typing import Optional + +import numpy as np + +from ria_toolkit_oss.signal.block_generator.continuous_modulation.modulator import ( + Modulator, +) +from ria_toolkit_oss.signal.block_generator.data_types import DataType +from ria_toolkit_oss.signal.block_generator.mapping.mapper import Mapper +from ria_toolkit_oss.signal.block_generator.multirate.upsampling import Upsampling +from ria_toolkit_oss.signal.block_generator.pulse_shaping.gaussian_filter import ( + GaussianFilter, +) +from ria_toolkit_oss.signal.block_generator.pulse_shaping.rect_filter import RectFilter + + +class CPFSKModulator(Modulator): + def __init__( + self, + num_bits_per_symbol: int, + center_frequency: float, + frequency_spacing: float, + symbol_duration: float, + sampling_frequency: float, + gaussian: Optional[bool] = False, + ): + # Assert that the frequency spacing and symbol duration are sufficient + # to maintain orthogonality for coherent FSK. + assert frequency_spacing * symbol_duration >= 0.5, ( + "For orthogonal coherent FSK, frequency_spacing * symbol_duration must be at least 0.5. " + f"Received frequency_spacing={frequency_spacing} and symbol_duration={symbol_duration}" + ) + # Ensure that the lowest frequency (when mapping symbols symmetrically about the center) is positive. + assert center_frequency - ((2**num_bits_per_symbol - 1) / 2) * frequency_spacing > 0, ( + f"With center_frequency={center_frequency} Hz, frequency_spacing={frequency_spacing} Hz, " + f"and num_bits_per_symbol={num_bits_per_symbol}, the lowest frequency would be " + f"{center_frequency - ((2**num_bits_per_symbol - 1) / 2) * frequency_spacing} Hz, which must be positive." + ) + + # Calculate the largest possible carrier frequency from the candidate mapping. + largest_carrier = center_frequency + ((2**num_bits_per_symbol - 1) / 2) * frequency_spacing + if sampling_frequency < 2 * largest_carrier: + warnings.warn( + f"Sampling frequency ({sampling_frequency} Hz) is less than twice the largest carrier frequency " + f"({largest_carrier} Hz). This may violate the Nyquist criterion and cause aliasing.", + UserWarning, + ) + + self.num_bits_per_symbol = num_bits_per_symbol + self.center_frequency = center_frequency + self.frequency_spacing = frequency_spacing + self.symbol_duration = symbol_duration + self.sampling_frequency = sampling_frequency + self.samples_per_symbol = int(self.sampling_frequency * self.symbol_duration) + self.pam_mapper = Mapper("pam", num_bits_per_symbol, normalize=False) + self.us = Upsampling(self.samples_per_symbol) + if gaussian: + self.filter = GaussianFilter(1, upsampling_factor=self.samples_per_symbol, bt=0.3, normalize=False) + else: + self.filter = RectFilter(1, upsampling_factor=self.samples_per_symbol, normalize=False) + + @property + def input_type(self) -> DataType: + return DataType.BITS + + @property + def output_type(self) -> DataType: + return DataType.PASSBAND_SIGNAL + + def get_samples(self, num_samples): + raise NotImplementedError + + def __call__(self, bits: np.ndarray) -> np.ndarray: + batch_size, num_bits = bits.shape + + # Validate bit length + if num_bits % self.num_bits_per_symbol != 0: + raise ValueError( + f"The number of bits per row ({num_bits}) must be a multiple of " + f"num_bits_per_symbol ({self.num_bits_per_symbol})." + ) + + # 1) Map bits to symbols (e.g., PAM), shape -> (batch_size, num_symbols) + symbols = np.real(self.pam_mapper(bits)) + + # 2) Upsample each row by 'samples_per_symbol', shape -> (batch_size, num_symbols * samples_per_symbol) + x_upsampled = self.us(symbols) + + # 3) Filter (Rect or Gaussian), shape still -> (batch_size, total_samples) + x_shaped = self.filter(x_upsampled) + + # For CPFSK, interpret x_shaped as a frequency offset around center_frequency. + # A common convention is to let freq_dev = frequency_spacing / 2 if you want ± frequency_spacing/2 offset, + # but you can also set freq_dev = frequency_spacing if that suits your design. + freq_dev = self.frequency_spacing / 2.0 + + # Compute the instantaneous frequency for all samples and all batches + freq_inst = self.center_frequency + freq_dev * x_shaped # shape: (batch_size, total_samples) + + # Compute the phase increment per sample and perform a cumulative sum along axis=1 (time axis) + phase = np.cumsum(2 * np.pi * freq_inst / self.sampling_frequency, axis=1) + + # Generate the CPFSK waveform by taking the cosine of the phase + total_samples = num_bits // self.num_bits_per_symbol * self.samples_per_symbol + waveform = np.cos(phase)[:, :total_samples] + + return waveform diff --git a/src/ria_toolkit_oss/signal/block_generator/continuous_modulation/demodulator.py b/src/ria_toolkit_oss/signal/block_generator/continuous_modulation/demodulator.py new file mode 100644 index 0000000..7c391f3 --- /dev/null +++ b/src/ria_toolkit_oss/signal/block_generator/continuous_modulation/demodulator.py @@ -0,0 +1,11 @@ +from abc import ABC, abstractmethod + +import numpy as np + +from ria_toolkit_oss.signal.block_generator.block import Block + + +class Demodulator(Block, ABC): + @abstractmethod + def __call__(self, *args, **kwargs) -> np.ndarray: + raise NotImplementedError diff --git a/src/ria_toolkit_oss/signal/block_generator/continuous_modulation/fsk_demodulator.py b/src/ria_toolkit_oss/signal/block_generator/continuous_modulation/fsk_demodulator.py new file mode 100644 index 0000000..1a34aa2 --- /dev/null +++ b/src/ria_toolkit_oss/signal/block_generator/continuous_modulation/fsk_demodulator.py @@ -0,0 +1,141 @@ +import warnings + +import numpy as np + +from ria_toolkit_oss.signal.block_generator.continuous_modulation.coherent_correlator import ( + CoherentCorrelator, +) +from ria_toolkit_oss.signal.block_generator.continuous_modulation.demodulator import ( + Demodulator, +) +from ria_toolkit_oss.signal.block_generator.data_types import DataType + + +class FSKDemodulator(Demodulator): + """ + A coherent FSK demodulator that uses a bank of correlators for symbol detection. + + The received baseband signal (assumed to be a 2D array of shape (batch_size, total_samples)) + is segmented into symbol intervals. Each correlator processes the signal over each symbol, + returning a decision statistic. For each symbol period, the demodulator selects the candidate + with the maximum absolute correlation output, converts that candidate index into a bit sequence, + and outputs the recovered bit stream. + + Parameter constraints: + - frequency_spacing * symbol_duration must be at least 0.5 (for coherent detection). + - The lowest candidate frequency (when mapping symmetrically about center_frequency) + must be positive. + + :param num_bits_per_symbol: Number of bits per symbol. + :type num_bits_per_symbol: int + :param frequency_spacing: The frequency spacing (Hz) between adjacent symbols. + Note: Effective frequency offsets are (frequency_spacing/2) times the + mapped odd integers. + :type frequency_spacing: float + :param symbol_duration: The duration (seconds) of one symbol period. + :type symbol_duration: float + :param sampling_frequency: The sampling frequency (Hz) of the received signal. + :type sampling_frequency: float + + :raises AssertionError: If frequency_spacing * symbol_duration < 1, or if the lowest candidate frequency + is not positive. + """ + + def __init__( + self, num_bits_per_symbol: int, frequency_spacing: float, symbol_duration: float, sampling_frequency: float + ): + # Assert that the frequency spacing and symbol duration are sufficient + # to maintain orthogonality for coherent FSK. + assert frequency_spacing * symbol_duration >= 0.5, ( + "For orthogonal coherent FSK, frequency_spacing * symbol_duration must be at least 0.5. " + f"Received frequency_spacing={frequency_spacing} and symbol_duration={symbol_duration}" + ) + + # Calculate the largest possible carrier frequency from the candidate mapping. + largest_carrier = (2**num_bits_per_symbol - 1) / 2 * frequency_spacing + if sampling_frequency < 2 * largest_carrier: + warnings.warn( + f"Sampling frequency ({sampling_frequency} Hz) is less than twice the largest carrier frequency " + f"({largest_carrier} Hz). This may violate the Nyquist criterion and cause aliasing.", + UserWarning, + ) + + self.num_bits_per_symbol = num_bits_per_symbol + self.frequency_spacing = frequency_spacing + self.symbol_duration = symbol_duration + self.sampling_frequency = sampling_frequency + + # Number of candidate symbols. + self.num_candidates = 2**self.num_bits_per_symbol + # Map candidate indices to odd integers: + # For example, if num_candidates=4, candidate_indices = [-3, -1, 1, 3]. + self.candidate_indices = 2 * np.arange(self.num_candidates) - (self.num_candidates - 1) + # Compute the candidate carrier frequencies. + self.candidate_frequencies = (self.frequency_spacing / 2) * self.candidate_indices + # Create a bank of correlators for each candidate frequency. + self.correlators = [ + CoherentCorrelator(f_c, self.symbol_duration, self.sampling_frequency, False) + for f_c in self.candidate_frequencies + ] + + @property + def input_type(self) -> DataType: + """The demodulator expects a passband signal as input.""" + return DataType.PASSBAND_SIGNAL + + @property + def output_type(self) -> DataType: + """The demodulator produces a bit stream as output.""" + return DataType.BITS + + def __call__(self, signal: np.ndarray) -> np.ndarray: + """ + Demodulate the received FSK signal using a bank of coherent correlators. + + The received signal is assumed to be a 2D numpy array of shape + (batch_size, total_samples), where total_samples is an integer multiple of the + number of samples per symbol (samples_per_symbol = symbol_duration * sampling_frequency). + + For each candidate frequency, the corresponding correlator processes the signal and + returns decision statistics (one per symbol). The demodulator then selects, for each symbol, + the candidate with the maximum absolute correlation value, and converts that candidate index + into its corresponding bit representation. + + :param signal: The received passband signal (shape: (batch_size, total_samples)). + :type signal: np.ndarray + :return: A 2D numpy array of shape (batch_size, num_bits), where + num_bits = (total_samples / samples_per_symbol) * num_bits_per_symbol. + :rtype: np.ndarray + :raises ValueError: If total_samples is not an integer multiple of samples_per_symbol. + """ + batch_size, total_samples = signal.shape + samples_per_symbol = int(self.symbol_duration * self.sampling_frequency) + excess_samples = total_samples % samples_per_symbol + if excess_samples != 0: + signal = signal[:, : total_samples - excess_samples] + + # Process the signal with each correlator in the bank. + # Each correlator returns an array of shape (batch_size, num_symbols). + stats = [corr(signal) for corr in self.correlators] + # Stack along a new axis: shape (num_candidates, batch_size, num_symbols) + stats = np.stack(stats, axis=0) + # For each symbol (per batch), select the candidate with the maximum absolute correlation. + # decision_indices: shape (batch_size, num_symbols) with values in {0, ..., num_candidates - 1}. + decision_indices = np.argmax(np.abs(stats), axis=0) + + # Convert candidate indices to bit sequences. + # Each candidate index is in the range [0, num_candidates - 1] and is represented with num_bits_per_symbol bits + # We convert each decision index into its binary representation. + bits = ((decision_indices[..., None] >> np.arange(self.num_bits_per_symbol - 1, -1, -1)) & 1).astype(np.int32) + # Reshape the bits to produce a bit stream of shape (batch_size, num_symbols * num_bits_per_symbol). + bits = bits.reshape(batch_size, -1) + return bits + + def __str__(self) -> str: + """Return a string representation of the FSKDemodulator.""" + return ( + f"FSKDemodulator(num_bits_per_symbol={self.num_bits_per_symbol}, " + f"frequency_spacing={self.frequency_spacing}, " + f"symbol_duration={self.symbol_duration}, " + f"sampling_frequency={self.sampling_frequency})" + ) diff --git a/src/ria_toolkit_oss/signal/block_generator/continuous_modulation/fsk_demodulator_fc.py b/src/ria_toolkit_oss/signal/block_generator/continuous_modulation/fsk_demodulator_fc.py new file mode 100644 index 0000000..a11ebda --- /dev/null +++ b/src/ria_toolkit_oss/signal/block_generator/continuous_modulation/fsk_demodulator_fc.py @@ -0,0 +1,155 @@ +import warnings + +import numpy as np + +from ria_toolkit_oss.signal.block_generator.continuous_modulation.coherent_correlator import ( + CoherentCorrelator, +) +from ria_toolkit_oss.signal.block_generator.continuous_modulation.demodulator import ( + Demodulator, +) +from ria_toolkit_oss.signal.block_generator.data_types import DataType + + +class FSKDemodulator(Demodulator): + """ + A coherent FSK demodulator that uses a bank of correlators for symbol detection. + + The received passband signal (assumed to be a 2D array of shape (batch_size, total_samples)) + is segmented into symbol intervals. Each correlator processes the signal over each symbol, + returning a decision statistic. For each symbol period, the demodulator selects the candidate + with the maximum absolute correlation output, converts that candidate index into a bit sequence, + and outputs the recovered bit stream. + + Parameter constraints: + - frequency_spacing * symbol_duration must be at least 0.5 (for coherent detection). + - The lowest candidate frequency (when mapping symmetrically about center_frequency) + must be positive. + + :param num_bits_per_symbol: Number of bits per symbol. + :type num_bits_per_symbol: int + :param center_frequency: The center frequency (Hz) about which the candidate carrier frequencies are distributed. + :type center_frequency: float + :param frequency_spacing: The frequency spacing (Hz) between adjacent symbols. + Note: Effective frequency offsets are (frequency_spacing/2) times the mapped odd integers + :type frequency_spacing: float + :param symbol_duration: The duration (seconds) of one symbol period. + :type symbol_duration: float + :param sampling_frequency: The sampling frequency (Hz) of the received signal. + :type sampling_frequency: float + :param per_symbol: Optional boolean flag. If True, uses per-symbol carrier sampling; if False, + uses a continuous time vector over the whole signal + + :raises AssertionError: If frequency_spacing * symbol_duration < 1, or if the lowest candidate frequency is not + positive + """ + + def __init__( + self, + num_bits_per_symbol: int, + center_frequency: float, + frequency_spacing: float, + symbol_duration: float, + sampling_frequency: float, + ): + # Assert that the frequency spacing and symbol duration are sufficient + # to maintain orthogonality for coherent FSK. + assert frequency_spacing * symbol_duration >= 0.5, ( + "For orthogonal coherent FSK, frequency_spacing * symbol_duration must be at least 1. " + f"Received frequency_spacing={frequency_spacing} and symbol_duration={symbol_duration}" + ) + # Ensure that the lowest frequency (when mapping symbols symmetrically about the center) is positive. + assert center_frequency - ((2**num_bits_per_symbol - 1) / 2) * frequency_spacing > 0, ( + f"With center_frequency={center_frequency} Hz, frequency_spacing={frequency_spacing} Hz, " + f"and num_bits_per_symbol={num_bits_per_symbol}, the lowest candidate frequency would be " + f"{center_frequency - ((2**num_bits_per_symbol - 1) / 2) * frequency_spacing} Hz, which must be positive." + ) + + # Calculate the largest possible carrier frequency from the candidate mapping. + largest_carrier = center_frequency + ((2**num_bits_per_symbol - 1) / 2) * frequency_spacing + if sampling_frequency < 2 * largest_carrier: + warnings.warn( + f"Sampling frequency ({sampling_frequency} Hz) is less than twice the largest carrier frequency " + f"({largest_carrier} Hz). This may violate the Nyquist criterion and cause aliasing.", + UserWarning, + ) + + self.num_bits_per_symbol = num_bits_per_symbol + self.center_frequency = center_frequency + self.frequency_spacing = frequency_spacing + self.symbol_duration = symbol_duration + self.sampling_frequency = sampling_frequency + + # Number of candidate symbols. + self.num_candidates = 2**self.num_bits_per_symbol + # Map candidate indices to odd integers: + # For example, if num_candidates=4, candidate_indices = [-3, -1, 1, 3]. + self.candidate_indices = 2 * np.arange(self.num_candidates) - (self.num_candidates - 1) + # Compute the candidate carrier frequencies. + self.candidate_frequencies = self.center_frequency + (self.frequency_spacing / 2) * self.candidate_indices + # Create a bank of correlators for each candidate frequency. + self.correlators = [ + CoherentCorrelator(f_c, self.symbol_duration, self.sampling_frequency, False) + for f_c in self.candidate_frequencies + ] + + @property + def input_type(self) -> DataType: + """The demodulator expects a passband signal as input.""" + return DataType.PASSBAND_SIGNAL + + @property + def output_type(self) -> DataType: + """The demodulator produces a bit stream as output.""" + return DataType.BITS + + def __call__(self, signal: np.ndarray) -> np.ndarray: + """ + Demodulate the received FSK signal using a bank of coherent correlators. + + The received signal is assumed to be a 2D numpy array of shape + (batch_size, total_samples), where total_samples is an integer multiple of the + number of samples per symbol (samples_per_symbol = symbol_duration * sampling_frequency). + + For each candidate frequency, the corresponding correlator processes the signal and + returns decision statistics (one per symbol). The demodulator then selects, for each symbol, + the candidate with the maximum absolute correlation value, and converts that candidate index + into its corresponding bit representation. + + :param signal: The received passband signal (shape: (batch_size, total_samples)). + :type signal: np.ndarray + :return: A 2D numpy array of shape (batch_size, num_bits), where + num_bits = (total_samples / samples_per_symbol) * num_bits_per_symbol. + :rtype: np.ndarray + :raises ValueError: If total_samples is not an integer multiple of samples_per_symbol. + """ + batch_size, total_samples = signal.shape + samples_per_symbol = int(self.symbol_duration * self.sampling_frequency) + excess_samples = total_samples % samples_per_symbol + if excess_samples != 0: + signal = signal[:, : total_samples - excess_samples] + + # Process the signal with each correlator in the bank. + # Each correlator returns an array of shape (batch_size, num_symbols). + stats = [corr(signal) for corr in self.correlators] + # Stack along a new axis: shape (num_candidates, batch_size, num_symbols) + stats = np.stack(stats, axis=0) + # For each symbol (per batch), select the candidate with the maximum absolute correlation. + # decision_indices: shape (batch_size, num_symbols) with values in {0, ..., num_candidates - 1}. + decision_indices = np.argmax(np.abs(stats), axis=0) + + # Convert candidate indices to bit sequences. + # Each candidate index is in the range [0, num_candidates - 1] and is represented with num_bits_per_symbol bits + # We convert each decision index into its binary representation. + bits = ((decision_indices[..., None] >> np.arange(self.num_bits_per_symbol - 1, -1, -1)) & 1).astype(np.int32) + # Reshape the bits to produce a bit stream of shape (batch_size, num_symbols * num_bits_per_symbol). + bits = bits.reshape(batch_size, -1) + return bits + + def __str__(self) -> str: + """Return a string representation of the FSKDemodulator.""" + return ( + f"FSKDemodulator(num_bits_per_symbol={self.num_bits_per_symbol}, " + f"center_frequency={self.center_frequency}, frequency_spacing={self.frequency_spacing}, " + f"symbol_duration={self.symbol_duration}, sampling_frequency={self.sampling_frequency})" + ) diff --git a/src/ria_toolkit_oss/signal/block_generator/continuous_modulation/fsk_modulator.py b/src/ria_toolkit_oss/signal/block_generator/continuous_modulation/fsk_modulator.py new file mode 100644 index 0000000..dfb25ef --- /dev/null +++ b/src/ria_toolkit_oss/signal/block_generator/continuous_modulation/fsk_modulator.py @@ -0,0 +1,133 @@ +import warnings + +import numpy as np + +from ria_toolkit_oss.signal.block_generator.continuous_modulation.modulator import ( + Modulator, +) +from ria_toolkit_oss.signal.block_generator.data_types import DataType + + +class FSKModulator(Modulator): + """ + A modulator for Frequency Shift Keying (FSK) signals that converts binary sequences into + baseband waveforms with frequencies mapped symmetrically about a given center frequency. + + This design yields carrier frequencies that are symmetrically distributed around the + `fc=0`. A sinusoidal waveform at the corresponding frequency is generated over + the symbol duration, and the complete modulated signal is obtained by concatenating the + waveforms for all symbols. + + The modulator also enforces parameter constraints: + - The product of `frequency_spacing` and `symbol_duration` must be at least 0.5 to ensure + sufficient frequency separation for coherent FSK. + - The lowest frequency, when mapping symbols symmetrically about the center, must be positive. + + :param num_bits_per_symbol: Number of bits per symbol. + :type num_bits_per_symbol: int + :param frequency_spacing: The frequency spacing (Hz) between adjacent symbols. Effective spacing + is half of this value when using the odd integer mapping. + :type frequency_spacing: float + :param symbol_duration: The duration (seconds) of each symbol. + :type symbol_duration: float + :param sampling_frequency: The sampling frequency (Hz) used to generate the waveform. + :type sampling_frequency: float + + :raises AssertionError: If frequency_spacing * symbol_duration is less than 1, or if the + computed lowest frequency is not positive. + """ + + def __init__( + self, + num_bits_per_symbol: int, + frequency_spacing: float, + symbol_duration: float, + sampling_frequency: float, + ): + # Assert that the frequency spacing and symbol duration are sufficient + # to maintain orthogonality for coherent FSK. + assert frequency_spacing * symbol_duration >= 0.5, ( + "For orthogonal discontinuous phase FSK, frequency_spacing * symbol_duration must be at least 0.5. " + f"Received frequency_spacing={frequency_spacing} and symbol_duration={symbol_duration}" + ) + + # Calculate the largest possible carrier frequency from the candidate mapping. + largest_carrier = ((2**num_bits_per_symbol - 1) / 2) * frequency_spacing + if sampling_frequency < 2 * largest_carrier: + warnings.warn( + f"Sampling frequency ({sampling_frequency} Hz) is less than twice the largest carrier frequency " + f"({largest_carrier} Hz). This may violate the Nyquist criterion and cause aliasing.", + UserWarning, + ) + self.num_bits_per_symbol = num_bits_per_symbol + self.frequency_spacing = frequency_spacing + self.symbol_duration = symbol_duration + self.sampling_frequency = sampling_frequency + + @property + def input_type(self) -> DataType: + return DataType.BITS + + @property + def output_type(self) -> DataType: + return DataType.PASSBAND_SIGNAL + + def get_samples(self, num_samples): + raise NotImplementedError + + def __call__(self, bits: np.ndarray) -> np.ndarray: + """ + Modulate a batch of binary sequences into FSK waveforms in a vectorized manner. + + Each row of the input 2D numpy array is treated as an independent bit stream. + The bits are grouped into symbols of length `num_bits_per_symbol`, converted to integer + symbol indices using MSB-first ordering, and then mapped to odd integer values centered around zero. + These symbol indices are used to compute the carrier frequencies for each symbol as: + + frequency = (frequency_spacing / 2) * symbol_indices + + A sinusoidal waveform is generated for each symbol over the symbol duration, + and the waveforms for all symbols are concatenated to form the final modulated signal. + + :param bits: A 2D numpy array of shape (batch_size, num_bits), where each row is a separate bit stream. + :type bits: np.ndarray + :return: A 2D numpy array of shape (batch_size, total_samples) representing the modulated baseband signal, + where total_samples = (num_bits // num_bits_per_symbol) * (symbol_duration * sampling_frequency). + :rtype: np.ndarray + :raises ValueError: If the number of bits per row is not a multiple of num_bits_per_symbol. + """ + batch_size, num_bits = bits.shape + + if num_bits % self.num_bits_per_symbol != 0: + raise ValueError( + f"The number of bits per row ({num_bits}) must be a multiple of " + f"num_bits_per_symbol ({self.num_bits_per_symbol})." + ) + + # Calculate the number of symbols per bit stream. + num_symbols = num_bits // self.num_bits_per_symbol + + # Reshape to (batch_size, num_symbols, num_bits_per_symbol) and convert bits to integers. + bits_reshaped = bits.reshape(batch_size, num_symbols, self.num_bits_per_symbol).astype(np.int32) + # Create a vector of powers for MSB-first conversion: [2^(n-1), ..., 2^0]. + powers_of_two = 1 << np.arange(self.num_bits_per_symbol)[::-1] + raw_indices = np.sum(bits_reshaped * powers_of_two, axis=2) + # Map raw indices to odd integers centered about zero. + symbol_indices = 2 * (raw_indices + 1) - 2**self.num_bits_per_symbol - 1 + + # Map symbols to carrier frequencies. + frequencies = symbol_indices * self.frequency_spacing / 2 + + # Compute the number of samples per symbol. + samples_per_symbol = int(self.symbol_duration * self.sampling_frequency) + total_samples = num_symbols * samples_per_symbol + + # Create a time vector for one symbol period and reshape for broadcasting. + t = np.linspace(0, self.symbol_duration, samples_per_symbol, endpoint=False)[None, None, :] + + # Generate the sinusoidal waveform for each symbol in a vectorized manner. + symbol_waveforms = np.exp(2j * np.pi * frequencies[:, :, None] * t) + + # Concatenate the symbol waveforms to form the final modulated waveform. + waveform = symbol_waveforms.reshape(batch_size, total_samples) + return waveform diff --git a/src/ria_toolkit_oss/signal/block_generator/continuous_modulation/fsk_modulator_fc.py b/src/ria_toolkit_oss/signal/block_generator/continuous_modulation/fsk_modulator_fc.py new file mode 100644 index 0000000..e4c7cc8 --- /dev/null +++ b/src/ria_toolkit_oss/signal/block_generator/continuous_modulation/fsk_modulator_fc.py @@ -0,0 +1,143 @@ +import warnings + +import numpy as np + +from ria_toolkit_oss.signal.block_generator.continuous_modulation.modulator import ( + Modulator, +) +from ria_toolkit_oss.signal.block_generator.data_types import DataType + + +class FSKModulator(Modulator): + """ + A modulator for Frequency Shift Keying (FSK) signals that converts binary sequences into + passband waveforms with frequencies mapped symmetrically about a given center frequency. + + This design yields carrier frequencies that are symmetrically distributed around the + `center_frequency`. A sinusoidal waveform at the corresponding frequency is generated over + the symbol duration, and the complete modulated signal is obtained by concatenating the + waveforms for all symbols. + + The modulator also enforces parameter constraints: + - The product of `frequency_spacing` and `symbol_duration` must be at least 0.5 to ensure + sufficient frequency separation for coherent FSK. + - The lowest frequency, when mapping symbols symmetrically about the center, must be positive. + + :param num_bits_per_symbol: Number of bits per symbol. + :type num_bits_per_symbol: int + :param center_frequency: The center frequency (Hz) around which the carrier frequencies are distributed. + :type center_frequency: float + :param frequency_spacing: The frequency spacing (Hz) between adjacent symbols. Effective spacing + is half of this value when using the odd integer mapping. + :type frequency_spacing: float + :param symbol_duration: The duration (seconds) of each symbol. + :type symbol_duration: float + :param sampling_frequency: The sampling frequency (Hz) used to generate the waveform. + :type sampling_frequency: float + + :raises AssertionError: If frequency_spacing * symbol_duration is less than 1, or if the + computed lowest frequency is not positive. + """ + + def __init__( + self, + num_bits_per_symbol: int, + center_frequency: float, + frequency_spacing: float, + symbol_duration: float, + sampling_frequency: float, + ): + # Assert that the frequency spacing and symbol duration are sufficient + # to maintain orthogonality for coherent FSK. + assert frequency_spacing * symbol_duration >= 0.5, ( + "For orthogonal discontinuous phase FSK, frequency_spacing * symbol_duration must be at least 0.5. " + f"Received frequency_spacing={frequency_spacing} and symbol_duration={symbol_duration}" + ) + # Ensure that the lowest frequency (when mapping symbols symmetrically about the center) is positive. + assert center_frequency - ((2**num_bits_per_symbol - 1) / 2) * frequency_spacing > 0, ( + f"With center_frequency={center_frequency} Hz, frequency_spacing={frequency_spacing} Hz, " + f"and num_bits_per_symbol={num_bits_per_symbol}, the lowest frequency would be " + f"{center_frequency - ((2**num_bits_per_symbol - 1) / 2) * frequency_spacing} Hz, which must be positive." + ) + + # Calculate the largest possible carrier frequency from the candidate mapping. + largest_carrier = center_frequency + ((2**num_bits_per_symbol - 1) / 2) * frequency_spacing + if sampling_frequency < 2 * largest_carrier: + warnings.warn( + f"Sampling frequency ({sampling_frequency} Hz) is less than twice the largest carrier frequency " + f"({largest_carrier} Hz). This may violate the Nyquist criterion and cause aliasing.", + UserWarning, + ) + self.num_bits_per_symbol = num_bits_per_symbol + self.center_frequency = center_frequency + self.frequency_spacing = frequency_spacing + self.symbol_duration = symbol_duration + self.sampling_frequency = sampling_frequency + + @property + def input_type(self) -> DataType: + return DataType.BITS + + @property + def output_type(self) -> DataType: + return DataType.PASSBAND_SIGNAL + + def get_samples(self, num_samples): + raise NotImplementedError + + def __call__(self, bits: np.ndarray) -> np.ndarray: + """ + Modulate a batch of binary sequences into FSK waveforms in a vectorized manner. + + Each row of the input 2D numpy array is treated as an independent bit stream. + The bits are grouped into symbols of length `num_bits_per_symbol`, converted to integer + symbol indices using MSB-first ordering, and then mapped to odd integer values centered around zero. + These symbol indices are used to compute the carrier frequencies for each symbol as: + + frequency = center_frequency + (frequency_spacing / 2) * symbol_indices + + A sinusoidal waveform is generated for each symbol over the symbol duration, + and the waveforms for all symbols are concatenated to form the final modulated signal. + + :param bits: A 2D numpy array of shape (batch_size, num_bits), where each row is a separate bit stream. + :type bits: np.ndarray + :return: A 2D numpy array of shape (batch_size, total_samples) representing the modulated passband signal, + where total_samples = (num_bits // num_bits_per_symbol) * (symbol_duration * sampling_frequency). + :rtype: np.ndarray + :raises ValueError: If the number of bits per row is not a multiple of num_bits_per_symbol. + """ + batch_size, num_bits = bits.shape + + if num_bits % self.num_bits_per_symbol != 0: + raise ValueError( + f"The number of bits per row ({num_bits}) must be a multiple of " + f"num_bits_per_symbol ({self.num_bits_per_symbol})." + ) + + # Calculate the number of symbols per bit stream. + num_symbols = num_bits // self.num_bits_per_symbol + + # Reshape to (batch_size, num_symbols, num_bits_per_symbol) and convert bits to integers. + bits_reshaped = bits.reshape(batch_size, num_symbols, self.num_bits_per_symbol).astype(np.int32) + # Create a vector of powers for MSB-first conversion: [2^(n-1), ..., 2^0]. + powers_of_two = 1 << np.arange(self.num_bits_per_symbol)[::-1] + raw_indices = np.sum(bits_reshaped * powers_of_two, axis=2) + # Map raw indices to odd integers centered about zero. + symbol_indices = 2 * (raw_indices + 1) - 2**self.num_bits_per_symbol - 1 + + # Map symbols to carrier frequencies. + frequencies = self.center_frequency + (self.frequency_spacing / 2) * symbol_indices + + # Compute the number of samples per symbol. + samples_per_symbol = int(self.symbol_duration * self.sampling_frequency) + total_samples = num_symbols * samples_per_symbol + + # Create a time vector for one symbol period and reshape for broadcasting. + t = np.linspace(0, self.symbol_duration, samples_per_symbol, endpoint=False)[None, None, :] + + # Generate the sinusoidal waveform for each symbol in a vectorized manner. + symbol_waveforms = np.cos(2 * np.pi * frequencies[:, :, None] * t) + + # Concatenate the symbol waveforms to form the final modulated waveform. + waveform = symbol_waveforms.reshape(batch_size, total_samples) + return waveform diff --git a/src/ria_toolkit_oss/signal/block_generator/continuous_modulation/modulator.py b/src/ria_toolkit_oss/signal/block_generator/continuous_modulation/modulator.py new file mode 100644 index 0000000..71e775b --- /dev/null +++ b/src/ria_toolkit_oss/signal/block_generator/continuous_modulation/modulator.py @@ -0,0 +1,11 @@ +from abc import ABC, abstractmethod + +import numpy as np + +from ria_toolkit_oss.signal.block_generator.block import Block + + +class Modulator(Block, ABC): + @abstractmethod + def __call__(self, *args, **kwargs) -> np.ndarray: + raise NotImplementedError diff --git a/src/ria_toolkit_oss/signal/block_generator/data_types.py b/src/ria_toolkit_oss/signal/block_generator/data_types.py new file mode 100644 index 0000000..5785ae1 --- /dev/null +++ b/src/ria_toolkit_oss/signal/block_generator/data_types.py @@ -0,0 +1,34 @@ +from enum import IntEnum + + +class DataType(IntEnum): + """ + Enumeration of different data types used in signal processing. + """ + + NONE = 0 + """Represents no input.""" + + SYMBOLS = 1 + """Represents symbol data.""" + + SOFT_SYMBOLS = 2 + """Represents soft symbol data.""" + + UPSAMPLED_SYMBOLS = 3 + """Represents upsampled symbol data.""" + + BITS = 4 + """Represents bit data.""" + + SOFT_BITS = 5 + """Represents soft bit data.""" + + BASEBAND_SIGNAL = 6 + """Represents baseband signal data.""" + + PASSBAND_SIGNAL = 7 + """Represents passband signal data.""" + + IQ_COMPONENTS = 8 + """Represents in-phase and quadrature components.""" diff --git a/src/ria_toolkit_oss/signal/block_generator/frequency_translation/__init__.py b/src/ria_toolkit_oss/signal/block_generator/frequency_translation/__init__.py new file mode 100644 index 0000000..b067d9c --- /dev/null +++ b/src/ria_toolkit_oss/signal/block_generator/frequency_translation/__init__.py @@ -0,0 +1,4 @@ +from .downconversion import FrequencyDownConversion +from .upconversion import FrequencyUpConversion + +__all__ = ["FrequencyUpConversion", "FrequencyDownConversion"] diff --git a/src/ria_toolkit_oss/signal/block_generator/frequency_translation/downconversion.py b/src/ria_toolkit_oss/signal/block_generator/frequency_translation/downconversion.py new file mode 100644 index 0000000..359b535 --- /dev/null +++ b/src/ria_toolkit_oss/signal/block_generator/frequency_translation/downconversion.py @@ -0,0 +1,57 @@ +import numpy as np + +from ria_toolkit_oss.signal.block_generator.block import Block +from ria_toolkit_oss.signal.block_generator.data_types import DataType + + +class FrequencyDownConversion(Block): + """ + A class to perform frequency down-conversion on passband signals. + + :param carrier_frequency: The carrier frequency in Hz. + :type carrier_frequency: float + :param sampling_rate: The sampling rate of the input signal in Hz. + :type sampling_rate: float + + Methods: + -------- + __call__(signal: np.ndarray) -> np.ndarray: + Applies frequency down-conversion to the input passband signal. + """ + + def __init__(self, carrier_frequency: float, sampling_rate: float): + self.carrier_frequency = carrier_frequency + self.sampling_rate = sampling_rate + + @property + def input_type(self) -> DataType: + """Get the input data type for the frequency down-conversion operation.""" + return DataType.PASSBAND_SIGNAL + + @property + def output_type(self) -> DataType: + """Get the output data type for the frequency down-conversion operation.""" + return DataType.BASEBAND_SIGNAL + + def __call__(self, signal: np.ndarray) -> np.ndarray: + """ + Apply frequency down-conversion to the input passband signal. + + :param signal: The input passband signal to be demodulated. + :type signal: np.ndarray + :return: The demodulated baseband signal. + :rtype: np.ndarray + """ + num_samples = signal.shape[1] + t = np.arange(num_samples) / self.sampling_rate + if np.iscomplexobj(signal): + carrier = np.exp(-1j * 2 * np.pi * self.carrier_frequency * t) + else: + carrier = np.cos(2 * np.pi * self.carrier_frequency * t) + return signal * carrier + + def __str__(self) -> str: + """Return a string representation of the FrequencyDownConversion object.""" + return ( + f"FrequencyDownConversion(carrier_frequency={self.carrier_frequency}, sampling_rate={self.sampling_rate})" + ) diff --git a/src/ria_toolkit_oss/signal/block_generator/frequency_translation/upconversion.py b/src/ria_toolkit_oss/signal/block_generator/frequency_translation/upconversion.py new file mode 100644 index 0000000..18f5464 --- /dev/null +++ b/src/ria_toolkit_oss/signal/block_generator/frequency_translation/upconversion.py @@ -0,0 +1,55 @@ +import numpy as np +from utils.signal.block_generator.block import Block +from utils.signal.block_generator.data_types import DataType + + +class FrequencyUpConversion(Block): + """ + A class to perform frequency up-conversion on baseband signals. + + :param carrier_frequency: The carrier frequency in Hz. + :type carrier_frequency: float + :param sampling_rate: The sampling rate of the input signal in Hz. + :type sampling_rate: float + + Methods: + -------- + __call__(signal: np.ndarray) -> np.ndarray: + Applies frequency up-conversion to the input baseband signal. + """ + + def __init__(self, carrier_frequency: float, sampling_rate: float): + self.carrier_frequency = carrier_frequency + self.sampling_rate = sampling_rate + + @property + def input_type(self) -> DataType: + """Get the input data type for the frequency up-conversion operation.""" + return DataType.BASEBAND_SIGNAL + + @property + def output_type(self) -> DataType: + """Get the output data type for the frequency up-conversion operation.""" + return DataType.PASSBAND_SIGNAL + + def __call__(self, signal: np.ndarray) -> np.ndarray: + """ + Apply frequency up-conversion to the input baseband signal. + + :param signal: The input baseband signal to be modulated. + :type signal: np.ndarray + :return: The modulated passband signal. + :rtype: np.ndarray + """ + num_samples = signal.shape[1] + t = np.arange(num_samples) / self.sampling_rate + if np.iscomplexobj(signal): + carrier = np.exp(1j * 2 * np.pi * self.carrier_frequency * t) + else: + carrier = np.cos(2 * np.pi * self.carrier_frequency * t) + + return signal * carrier + + def __str__(self) -> str: + """Return a string representation of the FrequencyUpConversion object.""" + return f"FrequencyUpConversion(carrier_frequency={self.carrier_frequency}, sampling_rate={self.sampling_rate})" diff --git a/src/ria_toolkit_oss/signal/block_generator/generators/__init__.py b/src/ria_toolkit_oss/signal/block_generator/generators/__init__.py new file mode 100644 index 0000000..2c37c0b --- /dev/null +++ b/src/ria_toolkit_oss/signal/block_generator/generators/__init__.py @@ -0,0 +1,34 @@ +""" +RIA Block-Based Signal Generator Module: Generators + +This module provides high-level generator wrappers that utilize the RIA block-based signal generator. +These generators simplify the creation of common communication system signals by automatically +configuring and connecting the appropriate blocks. + +Key components: + +- SignalGenerator: Base class for all generators +- Specialized generators: PAMGenerator, PSKGenerator, QAMGenerator + +Features: + +- Easy-to-use interfaces for generating complex signals +- Built on top of RIA's modular block system +- Customizable parameters for each generator type + +Usage: + +- Import specific generators to quickly create signals without manually connecting individual blocks. +- For more control, use the underlying blocks directly. + +See individual generator classes for detailed parameters and methods. +""" + +from ria_toolkit_oss.signal.block_generator.generators.pam_generator import PAMGenerator +from ria_toolkit_oss.signal.block_generator.generators.psk_generator import PSKGenerator +from ria_toolkit_oss.signal.block_generator.generators.qam_generator import QAMGenerator +from ria_toolkit_oss.signal.block_generator.generators.signal_generator import ( + SignalGenerator, +) + +__all__ = ["SignalGenerator", "PAMGenerator", "PSKGenerator", "QAMGenerator"] diff --git a/src/ria_toolkit_oss/signal/block_generator/generators/pam_generator.py b/src/ria_toolkit_oss/signal/block_generator/generators/pam_generator.py new file mode 100644 index 0000000..9de4d8c --- /dev/null +++ b/src/ria_toolkit_oss/signal/block_generator/generators/pam_generator.py @@ -0,0 +1,55 @@ +from ria_toolkit_oss.datatypes.recording import Recording +from ria_toolkit_oss.signal.block_generator.generators.signal_generator import ( + SignalGenerator, +) +from ria_toolkit_oss.signal.block_generator.mapping.mapper import Mapper +from ria_toolkit_oss.signal.block_generator.multirate.upsampling import Upsampling +from ria_toolkit_oss.signal.block_generator.pulse_shaping.pulse_shaping_filter import ( + PulseShapingFilter, +) +from ria_toolkit_oss.signal.block_generator.source.binary_source import BinarySource + + +class PAMGenerator(SignalGenerator): + """ + Pulse Amplitude Modulation (PAM) signal generator. + + This class generates PAM signals with configurable parameters such as bits per symbol, + upsampling factor, and pulse shaping filter. + + :param num_bits_per_symbol: Number of bits per symbol. + :type num_bits_per_symbol: int + :param upsampling_factor: Upsampling factor. + :type upsampling_factor: int + :param pulse_shaping_filter: Pulse shaping filter to be applied. + :type pulse_shaping_filter: PulseShapingFilter + """ + + def __init__(self, num_bits_per_symbol: int, upsampling_factor: int, pulse_shaping_filter: PulseShapingFilter): + src = BinarySource() + mapper = Mapper("PAM", num_bits_per_symbol) + us = Upsampling(upsampling_factor) + self.num_bits_per_symbol = num_bits_per_symbol + super().__init__([src, mapper, us, pulse_shaping_filter]) + + def record(self, batch_size: int = 1, num_bits: int = 1024) -> Recording: + """ + Generate and record PAM signals. + + :param batch_size: Number of recordings to generate, defaults to 1. + :type batch_size: int, optional + :param num_bits: Number of bits per recording, defaults to 1024. + :type num_bits: int, optional + :return: A Recording object containing the generated signals and metadata. + :rtype: Recording + """ + x = self.blocks[0](batch_size, num_bits) + for block in self.blocks[1:]: + x = block(x) + metadata = { + "num_recordings": batch_size, + "bits_per_recording": num_bits, + "modulation": f"{2**self.num_bits_per_symbol}PAM", + "pulse_shaping_filter": str(self.blocks[-1]), + } + return Recording(x, metadata) diff --git a/src/ria_toolkit_oss/signal/block_generator/generators/psk_generator.py b/src/ria_toolkit_oss/signal/block_generator/generators/psk_generator.py new file mode 100644 index 0000000..1525707 --- /dev/null +++ b/src/ria_toolkit_oss/signal/block_generator/generators/psk_generator.py @@ -0,0 +1,55 @@ +from ria_toolkit_oss.datatypes.recording import Recording +from ria_toolkit_oss.signal.block_generator.generators.signal_generator import ( + SignalGenerator, +) +from ria_toolkit_oss.signal.block_generator.mapping.mapper import Mapper +from ria_toolkit_oss.signal.block_generator.multirate.upsampling import Upsampling +from ria_toolkit_oss.signal.block_generator.pulse_shaping.pulse_shaping_filter import ( + PulseShapingFilter, +) +from ria_toolkit_oss.signal.block_generator.source.binary_source import BinarySource + + +class PSKGenerator(SignalGenerator): + """ + A generator for Phase Shift Keying (PSK) modulated signals. + + This class generates PSK signals with configurable parameters such as + bits per symbol, upsampling factor, and pulse shaping filter. + + :param num_bits_per_symbol: Number of bits per symbol in the PSK modulation. + :type num_bits_per_symbol: int + :param upsampling_factor: Factor by which to upsample the signal. + :type upsampling_factor: int + :param pulse_shaping_filter: The pulse shaping filter to apply to the signal. + :type pulse_shaping_filter: PulseShapingFilter + """ + + def __init__(self, num_bits_per_symbol: int, upsampling_factor: int, pulse_shaping_filter: PulseShapingFilter): + src = BinarySource() + mapper = Mapper("PSK", num_bits_per_symbol) + us = Upsampling(upsampling_factor) + self.num_bits_per_symbol = num_bits_per_symbol + super().__init__([src, mapper, us, pulse_shaping_filter]) + + def record(self, batch_size: int = 1, num_bits: int = 1024) -> Recording: + """ + Generate and record PSK signals. + + :param batch_size: Number of recordings to generate, defaults to 1. + :type batch_size: int, optional + :param num_bits: Number of bits per recording, defaults to 1024. + :type num_bits: int, optional + :return: A Recording object containing the generated signals and metadata. + :rtype: Recording + """ + x = self.blocks[0](batch_size, num_bits) + for block in self.blocks[1:]: + x = block(x) + metadata = { + "num_recordings": batch_size, + "bits_per_recording:": num_bits, + "modulation": f"{2**self.num_bits_per_symbol}PSK", + "pulse_shaping_filter": str(self.blocks[-1]), + } + return Recording(x, metadata) diff --git a/src/ria_toolkit_oss/signal/block_generator/generators/qam_generator.py b/src/ria_toolkit_oss/signal/block_generator/generators/qam_generator.py new file mode 100644 index 0000000..828d706 --- /dev/null +++ b/src/ria_toolkit_oss/signal/block_generator/generators/qam_generator.py @@ -0,0 +1,55 @@ +from ria_toolkit_oss.datatypes.recording import Recording +from ria_toolkit_oss.signal.block_generator.generators.signal_generator import ( + SignalGenerator, +) +from ria_toolkit_oss.signal.block_generator.mapping.mapper import Mapper +from ria_toolkit_oss.signal.block_generator.multirate.upsampling import Upsampling +from ria_toolkit_oss.signal.block_generator.pulse_shaping.pulse_shaping_filter import ( + PulseShapingFilter, +) +from ria_toolkit_oss.signal.block_generator.source.binary_source import BinarySource + + +class QAMGenerator(SignalGenerator): + """ + A generator for Quadrature Amplitude Modulation (QAM) signals. + + This class generates QAM signals with configurable parameters such as + bits per symbol, upsampling factor, and pulse shaping filter. + + :param num_bits_per_symbol: Number of bits per QAM symbol. + :type num_bits_per_symbol: int + :param upsampling_factor: Factor by which to upsample the signal. + :type upsampling_factor: int + :param pulse_shaping_filter: Filter used for pulse shaping. + :type pulse_shaping_filter: PulseShapingFilter + """ + + def __init__(self, num_bits_per_symbol: int, upsampling_factor: int, pulse_shaping_filter: PulseShapingFilter): + src = BinarySource() + mapper = Mapper("QAM", num_bits_per_symbol) + us = Upsampling(upsampling_factor) + self.num_bits_per_symbol = num_bits_per_symbol + super().__init__([src, mapper, us, pulse_shaping_filter]) + + def record(self, batch_size: int = 1, num_bits: int = 1024) -> Recording: + """ + Generate and record QAM signals. + + :param batch_size: Number of recordings to generate, defaults to 1. + :type batch_size: int, optional + :param num_bits: Number of bits per recording, defaults to 1024. + :type num_bits: int, optional + :return: A Recording object containing the generated signals and metadata. + :rtype: Recording + """ + x = self.blocks[0](batch_size, num_bits) + for block in self.blocks[1:]: + x = block(x) + metadata = { + "num_recordings": batch_size, + "bits_per_recording": num_bits, + "modulation": f"{2**self.num_bits_per_symbol}QAM", + "pulse_shaping_filter": str(self.blocks[-1]), + } + return Recording(x, metadata) diff --git a/src/ria_toolkit_oss/signal/block_generator/generators/signal_generator.py b/src/ria_toolkit_oss/signal/block_generator/generators/signal_generator.py new file mode 100644 index 0000000..80d3a63 --- /dev/null +++ b/src/ria_toolkit_oss/signal/block_generator/generators/signal_generator.py @@ -0,0 +1,36 @@ +from abc import ABC +from typing import List + +from ria_toolkit_oss.signal.block_generator.block import Block +from ria_toolkit_oss.signal.recordable import Recordable + + +class SignalGenerator(Recordable, ABC): + """ + An abstract base class for signal generators that work with a sequence of blocks. + + This class provides a foundation for creating signal generators that operate on a + series of processing blocks. It ensures type compatibility between consecutive + blocks in the sequence by validating that the output type of each block matches + the input type of the subsequent block. + + :param blocks: A list of processing blocks to be used in the signal generation. + :type blocks: List of Blocks + + :raises ValueError: If there's a mismatch between block output and input types. + """ + + # TODO: Consider exposing 'blocks' through a property, and adding methods for adding to / manipulating the + # block sequence. + + def __init__(self, blocks: List[Block]): + self.blocks = blocks + self._validate_block_sequence() + + def _validate_block_sequence(self) -> None: + for i in range(len(self.blocks) - 1): + if self.blocks[i].output_type != self.blocks[i + 1].input_type: + raise ValueError( + f"Block {i} output type {self.blocks[i].output_type} does not match " + f"block {i + 1} input type {self.blocks[i + 1].input_type}." + ) diff --git a/src/ria_toolkit_oss/signal/block_generator/io.py b/src/ria_toolkit_oss/signal/block_generator/io.py new file mode 100644 index 0000000..2199bf2 --- /dev/null +++ b/src/ria_toolkit_oss/signal/block_generator/io.py @@ -0,0 +1,20 @@ +import pathlib +from typing import Union + +import numpy as np + + +def file_to_bits(path: str | pathlib.Path) -> np.ndarray: + data = pathlib.Path(path).read_bytes() + bits = np.unpackbits(np.frombuffer(data, dtype=np.uint8)) + return bits.astype(np.uint8) # shape (N,) + + +def bits_to_file(bits: np.ndarray, path: str | pathlib.Path): + bits = bits.astype(np.uint8)[: (len(bits) // 8) * 8] # trim to bytes + data = np.packbits(bits).tobytes() + pathlib.Path(path).write_bytes(data) + + +def txt_to_str(path: Union[str, pathlib.Path], encoding: str = "utf-8") -> str: + return pathlib.Path(path).read_text(encoding=encoding) diff --git a/src/ria_toolkit_oss/signal/block_generator/mapping/__init__.py b/src/ria_toolkit_oss/signal/block_generator/mapping/__init__.py new file mode 100644 index 0000000..2b855cd --- /dev/null +++ b/src/ria_toolkit_oss/signal/block_generator/mapping/__init__.py @@ -0,0 +1,27 @@ +""" +RIA Symbol Mapping and Demapping Module + +This module provides blocks for symbol mapping and demapping within the RIA block-based signal generator framework. + +Key components: + +- Mapper: Maps bits to constellation points for various modulation schemes (e.g., M-QAM, M-PSK, M-PAM) +- SymbolDemapper: Converts soft symbols back to original symbols using maximum likelihood estimation + +Features: + +- Support for multiple modulation schemes +- Configurable parameters for different constellation sizes + +Usage: + +- Import Mapper or SymbolDemapper to incorporate into your signal processing chain. + +For detailed parameters and methods, see individual class documentation. +""" + +from .constellation_mapper import ConstellationMapper +from .mapper import Mapper +from .symbol_demapper import SymbolDemapper + +__all__ = ["ConstellationMapper", "Mapper", "SymbolDemapper"] diff --git a/src/ria_toolkit_oss/signal/block_generator/mapping/apsk_mapper.py b/src/ria_toolkit_oss/signal/block_generator/mapping/apsk_mapper.py new file mode 100644 index 0000000..85accc8 --- /dev/null +++ b/src/ria_toolkit_oss/signal/block_generator/mapping/apsk_mapper.py @@ -0,0 +1,74 @@ +from typing import Optional + +import numpy as np + +from ria_toolkit_oss.signal.block_generator.mapping.constellation_mapper import ( + ConstellationMapper, +) + + +class _APSKMapper(ConstellationMapper): + """ + A class to map input bits to Amplitude Phase Shift Keying (APSK) constellation points. + Follows DVB-S2 / DVB-S2X standard structures for rings and radii ratios where applicable, + or generic concentric ring structures. + """ + + def __init__( + self, num_bits_per_symbol: int, normalize: Optional[bool] = True, use_gray_code: Optional[bool] = True + ): + super().__init__(num_bits_per_symbol, normalize, use_gray_code) + self.constellation = self._generate_constellation() + # Re-generate bit mapping if needed, or assume default + # Note: Base class calls _generate_bit_mapping() which does generic gray/binary + # For APSK, generic gray might not match DVB standards, but is sufficient for synthetic generation. + + def _generate_constellation(self) -> np.ndarray: + M = 2**self.num_bits_per_symbol + + # Define structures (rings and points per ring) + # Based on common DVB standards + if M == 16: # 16APSK: 4+12 + radii = [1.0, 2.57] # R2/R1 ratio approx 2.57 for DVB-S2 16APSK + points = [4, 12] + phase_offsets = [0, 0] + elif M == 32: # 32APSK: 4+12+16 + radii = [1.0, 2.53, 4.30] + points = [4, 12, 16] + phase_offsets = [0, 0, 0] + elif M == 64: # 64APSK: 4+12+20+28 + radii = [1.0, 2.5, 4.3, 6.0] # Approximate + points = [4, 12, 20, 28] + phase_offsets = [0, 0, 0, 0] + elif M == 128: # 128APSK: 8+16+24+32+48? Or 4+12+28+36+48 (from prototype) + # Proto: 4+12+28+36+48 + radii = [1.0, 2.5, 4.0, 5.5, 7.0] + points = [4, 12, 20, 36, 56] # Sum must be 128 + # 4+12+20+36+56 = 128 + phase_offsets = [0] * 5 + elif M == 256: # 256APSK + # Proto: 4+12+28+52+68+92 (Sum=256) + radii = np.linspace(1, 6, 6) + points = [4, 12, 28, 52, 68, 92] + phase_offsets = [0] * 6 + else: + # Fallback for other orders: single ring (PSK) or simple multi-ring + # Just use PSK fallback if not specific APSK structure defined + return self._generate_psk_fallback(M) + + constellation = [] + for r, p, phi in zip(radii, points, phase_offsets): + angles = np.linspace(0, 2 * np.pi, p, endpoint=False) + phi + ring = r * np.exp(1j * angles) + constellation.extend(ring) + + constellation = np.array(constellation) + + if self.normalize: + return self._normalize(constellation) + return constellation + + def _generate_psk_fallback(self, M): + # Fallback to PSK + angles = np.linspace(0, 2 * np.pi, M, endpoint=False) + return np.exp(1j * angles) diff --git a/src/ria_toolkit_oss/signal/block_generator/mapping/constellation_mapper.py b/src/ria_toolkit_oss/signal/block_generator/mapping/constellation_mapper.py new file mode 100644 index 0000000..12bfdfa --- /dev/null +++ b/src/ria_toolkit_oss/signal/block_generator/mapping/constellation_mapper.py @@ -0,0 +1,186 @@ +import os +from abc import ABC, abstractmethod +from datetime import datetime +from typing import List, Optional + +import matplotlib.pyplot as plt +import numpy as np + + +class ConstellationMapper(ABC): + """ + Abstract base class for mapping input bits to constellation points. + + This class provides methods to generate constellation points, map input bits + to constellation points, normalize constellation points, and display a + constellation diagram. + + :param num_bits_per_symbol: Number of bits per symbol. To be used by subclasses. + :type num_bits_per_symbol: int + :param normalize: Whether to normalize the constellation points. To be used by subclasses. + :type normalize: bool, optional + :param use_gray_code: Whether to use gray code as constellation points. To be used by subclasses. + :type use_gray_code: bool, optional + + Note: + This is an abstract class and should not be instantiated directly. + Subclasses should implement the `_generate_constellation` method. + """ + + def __init__( + self, num_bits_per_symbol: int, normalize: Optional[bool] = True, use_gray_code: Optional[bool] = True + ): + self.num_bits_per_symbol = num_bits_per_symbol + self.normalize = normalize + self.use_gray_code = use_gray_code + self.constellation = None + self._generate_bit_mapping() + + def _generate_bit_mapping(self): + """Generate bit mapping.""" + if self.use_gray_code: + indices = self.gray_code(self.num_bits_per_symbol) + else: + indices = range(2**self.num_bits_per_symbol) + self.bit_mapping = np.array(indices) + + @abstractmethod + def _generate_constellation(self) -> np.ndarray: + """ + Generate the constellation points. + + This method should be implemented by subclasses. + + :raises NotImplementedError: This method must be implemented by subclasses. + """ + raise NotImplementedError + + @staticmethod + def gray_code(n: int) -> List[int]: + """ + Generate Gray code for a given number of bits. + + :param n: Number of bits + :type n: int + :return: List of Gray-encoded values + :rtype: List of ints + """ + return [i ^ (i >> 1) for i in range(2**n)] + + def _reorder_for_gray(self) -> None: + """ + Physically reorder self.constellation so index = Gray-coded decimal index. + + If the base class set self.bit_mapping to a Gray code forward map fwd_map + such that fwd_map[d] = g, then we do new_const[g] = old_const[d]. + """ + M = len(self.constellation) + old_const = self.constellation.copy() + new_const = np.zeros_like(old_const) + + # self.bit_mapping is your forward Gray map array (length M) + # fwd_map[d] = g + fwd_map = self.bit_mapping + + for d in range(M): + g = fwd_map[d] + new_const[g] = old_const[d] + + self.constellation = new_const + # Once physically reordered, array index i is the Gray-coded decimal i + # So we can simplify to an identity map + self.bit_mapping = np.arange(M) + + def __call__(self, bits: np.ndarray) -> np.ndarray: + """ + Map bits to constellation points. + + :param bits: Input bits to be mapped. Shape should be (num_batches, num_bits). + :type bits: np.ndarray + + :return: Mapped constellation points. Shape will be (num_batches, num_symbols). + :rtype: np.ndarray + + :raises ValueError: If the number of input bits is not divisible by the number of bits per symbol. + """ + + # Check if the number of input bits is divisible by the number of bits per symbol + if bits.shape[1] % self.num_bits_per_symbol != 0: + raise ValueError( + f"Number of input bits ({bits.shape[1]}) " + f"must be divisible by the number of bits per symbol ({self.num_bits_per_symbol})." + ) + + # Reshape the input bits to have one row per batch and one column per bit + bits = bits.astype(np.int32).reshape((bits.shape[0], -1, self.num_bits_per_symbol)) + decimal_values = np.sum(bits * (1 << np.arange(self.num_bits_per_symbol)[::-1]), axis=2) + + # Map symbol indices to constellation points + symbol_indices = self.bit_mapping[decimal_values] + return self.constellation[symbol_indices] + + @staticmethod + def _normalize(constellation: np.ndarray) -> np.ndarray: + """ + Normalize the constellation points so that their average energy is 1. + + :param constellation: The constellation points to normalize. + :type constellation: np.ndarray + + :return: Normalized constellation points. + :rtype: np.ndarray + """ + average_energy = np.mean(np.abs(constellation) ** 2) + return constellation / np.sqrt(average_energy) + + def show_constellation(self) -> None: + """ + Display the constellation diagram with bit labels. + """ + real_part, imag_part = np.real(self.constellation), np.imag(self.constellation) + + # Determine if it's a PAM constellation + is_pam = np.allclose(imag_part, 0) + + fig, ax = plt.subplots(figsize=(10, 10)) + ax.scatter(real_part, imag_part, color="b", s=100) + + # Add bit labels to each point + if self.num_bits_per_symbol <= 6: + for i, (x, y) in enumerate(zip(real_part, imag_part)): + ax.annotate( + bin(self.bit_mapping[i])[2:].zfill(self.num_bits_per_symbol), + (x, y), + xytext=(5, 5), + textcoords="offset points", + ) + + # Set axis labels and title + ax.set_xlabel("I (In-Phase)") + ax.set_ylabel("Q (Quadrature)") + ax.set_title(f"{self.__class__.__name__[1:-6]} Constellation Diagram") + + # Show grid + ax.grid(True) + + # Make the plot square + ax.set_aspect("equal", adjustable="box") + + if is_pam: + # For PAM, set y-axis limits to make the constellation visible + y_range = max(abs(np.max(real_part)), abs(np.min(real_part))) * 0.2 + ax.set_ylim([-y_range, y_range]) + else: + # For non-PAM, set limits based on the constellation points + max_val = max(np.max(np.abs(real_part)), np.max(np.abs(imag_part))) + ax.set_xlim([-max_val * 1.2, max_val * 1.2]) + ax.set_ylim([-max_val * 1.2, max_val * 1.2]) + + # Save the figure + os.makedirs("images", exist_ok=True) + now = datetime.now() + formatted_time = now.strftime("%Y%m%d_%H%M%S") + file_name = f"images/constellation_{self.__class__.__name__}_{formatted_time}.png" + fig.savefig(file_name, dpi=300, bbox_inches="tight") + + plt.show() diff --git a/src/ria_toolkit_oss/signal/block_generator/mapping/cross_qam_mapper.py b/src/ria_toolkit_oss/signal/block_generator/mapping/cross_qam_mapper.py new file mode 100644 index 0000000..b14fc12 --- /dev/null +++ b/src/ria_toolkit_oss/signal/block_generator/mapping/cross_qam_mapper.py @@ -0,0 +1,64 @@ +from typing import Optional + +import numpy as np + +from ria_toolkit_oss.signal.block_generator.mapping.constellation_mapper import ( + ConstellationMapper, +) + + +class _CrossQAMMapper(ConstellationMapper): + """ + A class to map input bits to Cross-QAM constellation points (Odd-order QAM). + Supports 32QAM (5 bits) and 128QAM (7 bits) by removing corners from larger square constellations. + """ + + def __init__( + self, num_bits_per_symbol: int, normalize: Optional[bool] = True, use_gray_code: Optional[bool] = True + ): + # Allow odd bits + super().__init__(num_bits_per_symbol, normalize, use_gray_code) + self.constellation = self._generate_constellation() + # Use default bit mapping from base class (integer index -> symbol index) + # For true gray coding on Cross QAM, we'd need a specific lookup table. + # Using generic index mapping for now. + + def _generate_constellation(self) -> np.ndarray: + M = 2**self.num_bits_per_symbol + + if M == 32: + # 32-QAM: Subset of 6x6 (36 points) - remove 4 corners + # Grid -2.5 to 2.5 (step 1) -> -5, -3, -1, 1, 3, 5 (scaled) + axis = np.array([-5, -3, -1, 1, 3, 5]) + xv, yv = np.meshgrid(axis, axis) + points = xv + 1j * yv + points = points.flatten() + + # Remove corners: |I| > 3 AND |Q| > 3 + # axis ends are +/- 5. Inner are +/- 3, +/- 1. + # Corners are (5,5), (5,-5), (-5,5), (-5,-5) + mask = (np.abs(points.real) > 3) & (np.abs(points.imag) > 3) + constellation = points[~mask] + + elif M == 128: + # 128-QAM: Subset of 12x12 (144 points) - remove 16 points (4 from each corner) + # 12x12 grid + # axis length 12. -11, -9, ..., 9, 11 + axis = np.arange(-11, 12, 2) + xv, yv = np.meshgrid(axis, axis) + points = xv + 1j * yv + points = points.flatten() + + # Remove corners. 144 - 128 = 16 points to remove. + # 4 points per corner. + # Corner region: |I| >= 9 AND |Q| >= 9 (points 9, 11) -> 2x2 = 4 points per corner + # 9,9; 9,11; 11,9; 11,11 (and signs) + mask = (np.abs(points.real) >= 9) & (np.abs(points.imag) >= 9) + constellation = points[~mask] + + else: + raise ValueError(f"Unsupported Cross-QAM order: {M}") + + if self.normalize: + return self._normalize(constellation) + return constellation diff --git a/src/ria_toolkit_oss/signal/block_generator/mapping/mapper.py b/src/ria_toolkit_oss/signal/block_generator/mapping/mapper.py new file mode 100644 index 0000000..d75d299 --- /dev/null +++ b/src/ria_toolkit_oss/signal/block_generator/mapping/mapper.py @@ -0,0 +1,159 @@ +from typing import Optional + +import numpy as np + +from ria_toolkit_oss.signal.block_generator.data_types import DataType +from ria_toolkit_oss.signal.block_generator.mapping.pam_mapper import _PAMMapper +from ria_toolkit_oss.signal.block_generator.mapping.psk_mapper import _PSKMapper +from ria_toolkit_oss.signal.block_generator.mapping.qam_mapper import _QAMMapper +from ria_toolkit_oss.signal.block_generator.process_block import ProcessBlock +from ria_toolkit_oss.signal.block_generator.recordable_block import RecordableBlock + + +class Mapper(ProcessBlock, RecordableBlock): + """ + A class to map input bits to constellation points using various modulation schemes. + + :param constellation_type: The type of constellation ('PSK', 'QAM', 'PAM'). + :type constellation_type: str + :param num_bits_per_symbol: Number of bits per symbol. + :type num_bits_per_symbol: int + :param normalize: Whether to normalize the constellation points, defaults to True. + :type normalize: bool, optional + + Methods: + -------- + __call__(bits: np.ndarray) -> np.ndarray: + Maps input bits to constellation points. + show_constellation(): + Displays the constellation diagram. + + Example: + -------- + # Create a QAM Mapper + >>> qam_mapper = Mapper('QAM', 4, True) + + # Generate some random bits + >>> bits = np.random.randint(0, 2, (10, 8)) # 10 batches of 8 bits each + + # Map bits to QAM constellation points + >>> mapped_points = qam_mapper(bits) + + # Show the constellation diagram + >>> qam_mapper.show_constellation() + """ + + def __init__( + self, + constellation_type: Optional[str] = "psk", + num_bits_per_symbol: Optional[int] = 2, + normalize: Optional[bool] = True, + use_gray_code: Optional[bool] = True, + ): + """ + Initialize a mapper block to map bits to constellation symbols. + + :param constellation_type: The type of constellation ('PSK', 'QAM', 'PAM'). + :type constellation_type: str + :param num_bits_per_symbol: Number of bits per symbol. + :type num_bits_per_symbol: int + :param normalize: Whether to normalize the constellation points, defaults to True. + :type normalize: bool, optional + """ + self.constellation_type = constellation_type + self.num_bits_per_symbol = num_bits_per_symbol + self.normalize = normalize + self.use_gray_code = use_gray_code + self.constellation_mapper = self._create_constellation_mapper() + super().__init__() + + @property + def input_type(self) -> DataType: + """ + Get the input data type. + + :return: The input data type. + :rtype: DataType + """ + return [DataType.BITS] + + @property + def output_type(self) -> DataType: + """ + Get the output data type. + + :return: The output data type. + :rtype: DataType + """ + return DataType.SYMBOLS + + def _create_constellation_mapper(self): + """ + Factory method to create the appropriate constellation mapper based on the type specified. + + :return: An instance of a specific constellation mapper. + :rtype: ConstellationMapper + :raises ValueError: If the constellation type is unsupported. + """ + if self.constellation_type.upper() == "PSK": + return _PSKMapper(self.num_bits_per_symbol, self.normalize, self.use_gray_code) + elif self.constellation_type.upper() == "QAM": + return _QAMMapper(self.num_bits_per_symbol, self.normalize, self.use_gray_code) + elif self.constellation_type.upper() == "PAM": + return _PAMMapper(self.num_bits_per_symbol, self.normalize, self.use_gray_code) + else: + raise ValueError("Unsupported constellation type") + + def get_constellation(self) -> np.ndarray: + """ + Get the constellation points. + + :return: A numpy array of constellation points. + :rtype: np.ndarray + """ + return self.constellation_mapper.constellation + + def get_bit_mapping(self) -> np.ndarray: + """ + Get the bit mapping. + :return: A numpy array of symbol to bit mapping + :rtype: np.ndarray + """ + return self.constellation_mapper.bit_mapping + + def get_samples(self, num_samples: int): + """ + Get num_samples samples from this block by recursively requesting samples from upstream blocks. + + :param num_samples: The number of samples to output. + :type num_samples: int + + Note: If a new block implementation decimates or multiplies the number of samples from upstream blocks + this method must be overridden to implement the correct sample requests from input blocks. + """ + input_signals = [input.get_samples(num_samples * self.num_bits_per_symbol) for input in self.input] + output = self.__call__(samples=input_signals) + if len(output) != num_samples: + raise ValueError( + f"Error in block {self.__class__.__name__}: requested {num_samples} samples but got {len(output)}." + ) + return output + + def __call__(self, samples): + """ + Convert an array of bits into symbols. + + :param samples: A list containing a single array of bits, dtype = float. + :type samples: list of np.array + + :returns: Output symbols, dtype = np.complex64. + :rtype: np.array""" + return self.constellation_mapper(np.array([samples[0]]))[0] + + def show_constellation(self) -> None: + """ + Display the constellation diagram. + + :return: None + """ + self.constellation_mapper.show_constellation() diff --git a/src/ria_toolkit_oss/signal/block_generator/mapping/pam_mapper.py b/src/ria_toolkit_oss/signal/block_generator/mapping/pam_mapper.py new file mode 100644 index 0000000..c068cbd --- /dev/null +++ b/src/ria_toolkit_oss/signal/block_generator/mapping/pam_mapper.py @@ -0,0 +1,46 @@ +from typing import Optional + +import numpy as np + +from ria_toolkit_oss.signal.block_generator.mapping.constellation_mapper import ( + ConstellationMapper, +) + + +class _PAMMapper(ConstellationMapper): + """ + A class to map input bits to Pulse Amplitude Modulation (PAM) constellation points. + + :param num_bits_per_symbol: Number of bits per symbol. Must be an even number. + :type num_bits_per_symbol: int + :param normalize: Whether to normalize the constellation points, defaults to True. + :type normalize: bool, optional + :param use_gray_code: Whether to use gray code as constellation points, defaults to True. + :type use_gray_code: bool, optional + + :raises ValueError: If num_bits_per_symbol is not an even number. + """ + + def __init__( + self, num_bits_per_symbol: int, normalize: Optional[bool] = True, use_gray_code: Optional[bool] = True + ): + if num_bits_per_symbol % 2 != 0: + raise ValueError("num_bits_per_symbol must be an even number") + super().__init__(num_bits_per_symbol, normalize, use_gray_code) + self.constellation = self._generate_constellation() + if self.use_gray_code: + self._reorder_for_gray() + + def _generate_constellation(self) -> np.ndarray: + """ + Generate the PAM constellation points. + + :returns: The PAM constellation points. + :rtype: numpy array + """ + num_pam_symbols = 2**self.num_bits_per_symbol + constellation = np.arange(-num_pam_symbols + 1, num_pam_symbols, 2).astype(np.complex128) + + if self.normalize: + return self._normalize(constellation) + return constellation diff --git a/src/ria_toolkit_oss/signal/block_generator/mapping/psk_mapper.py b/src/ria_toolkit_oss/signal/block_generator/mapping/psk_mapper.py new file mode 100644 index 0000000..61a8698 --- /dev/null +++ b/src/ria_toolkit_oss/signal/block_generator/mapping/psk_mapper.py @@ -0,0 +1,49 @@ +from typing import Optional + +import numpy as np + +from ria_toolkit_oss.signal.block_generator.mapping.constellation_mapper import ( + ConstellationMapper, +) + + +class _PSKMapper(ConstellationMapper): + """ + A class to map input bits to Phase Shift Keying (PSK) constellation points. + + :param num_bits_per_symbol: Number of bits per symbol. Must be an even number. + :type num_bits_per_symbol: int + :param normalize: Whether to normalize the constellation points, defaults to True. + :type normalize: bool, optional + :param use_gray_code: Whether to use gray code as constellation points, defaults to True. + :type use_gray_code: bool, optional + + :raises ValueError: If num_bits_per_symbol is not an even number. + """ + + def __init__( + self, num_bits_per_symbol: int, normalize: Optional[bool] = True, use_gray_code: Optional[bool] = True + ): + super().__init__(num_bits_per_symbol, normalize, use_gray_code) + self.constellation = self._generate_constellation() + if self.use_gray_code: + self._reorder_for_gray() + + def _generate_constellation(self) -> np.ndarray: + """ + Generate the PSK constellation points. + + :returns: The PSK constellation points. + :rtype: numpy array + """ + num_symbols = 2**self.num_bits_per_symbol + symbol_indices = np.arange(0, num_symbols) + 1 + real_part = np.cos(2 * np.pi * symbol_indices / num_symbols) + image_part = np.sin(2 * np.pi * symbol_indices / num_symbols) + + constellation = real_part + 1j * image_part + if self.num_bits_per_symbol == 2: + constellation *= np.exp(1j * np.pi / 4) # rotate 45 degrees + if self.normalize: + return self._normalize(constellation) + return constellation diff --git a/src/ria_toolkit_oss/signal/block_generator/mapping/qam_mapper.py b/src/ria_toolkit_oss/signal/block_generator/mapping/qam_mapper.py new file mode 100644 index 0000000..e52bbcf --- /dev/null +++ b/src/ria_toolkit_oss/signal/block_generator/mapping/qam_mapper.py @@ -0,0 +1,119 @@ +from typing import Optional, Tuple + +import numpy as np + +from ria_toolkit_oss.signal.block_generator.mapping.constellation_mapper import ( + ConstellationMapper, +) + +QAM16_GRAY_CODE = np.array([0, 1, 3, 2, 4, 5, 7, 6, 12, 13, 15, 14, 8, 9, 11, 10]) + + +class _QAMMapper(ConstellationMapper): + """ + A class to map input bits to Quadrature Amplitude Modulation (QAM) constellation points. + + :param num_bits_per_symbol: Number of bits per symbol. Must be an even number. + :type num_bits_per_symbol: int + :param normalize: Whether to normalize the constellation points, defaults to True. + :type normalize: bool, optional + :param use_gray_code: Whether to use gray code as constellation points, defaults to True. + :type use_gray_code: bool, optional + + :raises ValueError: If num_bits_per_symbol is not an even number. + """ + + def __init__( + self, num_bits_per_symbol: int, normalize: Optional[bool] = True, use_gray_code: Optional[bool] = True + ): + if num_bits_per_symbol % 2 != 0: + raise ValueError("num_bits_per_symbol must be an even number") + elif num_bits_per_symbol <= 2: + raise ValueError("num_bits_per_symbol must more than two") + super().__init__(num_bits_per_symbol, normalize, False) + self.constellation = self._generate_constellation() + self.use_gray_code = use_gray_code + if self.use_gray_code: + self.constellation, self.bit_mapping, _ = self._generate_gray_code(num_bits_per_symbol) + self._reorder_for_gray() + + @staticmethod + def _generate_indexing_scheme(n: int) -> np.ndarray: + # Create an empty n x n matrix to store the result + matrix = np.full((n, n), np.nan) + + index = 0 + + # Fill 1st quadrant (bottom-left), but in reverse (flip up-down) + for col in range(n // 2): + for row in range(n // 2 - 1, -1, -1): + matrix[n // 2 + row, col] = index + index += 1 + + # Fill 2nd quadrant (top-left) + for col in range(n // 2): + for row in range(n // 2): + matrix[n // 2 - 1 - row, col] = index + index += 1 + + # Fill 3rd quadrant (top-right) + for col in range(n // 2, n): + for row in range(n // 2): + matrix[n // 2 - 1 - row, col] = index + index += 1 + + # Fill 4th quadrant (bottom-right), but in reverse (flip up-down) + for col in range(n // 2, n): + for row in range(n // 2 - 1, -1, -1): + matrix[n // 2 + row, col] = index + index += 1 + + return matrix.astype(np.int32) + + def _generate_gray_code(self, num_bits_per_symbol: int) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: + """ + Recursively generate Gray code for higher-order QAM constellations. Base case is 16QAM. + + :param num_bits_per_symbol: Number of bits for the QAM constellation + :return: Tuple of numpy arrays (constellation, bit_mapping and ref_bit_mapping) + """ + if num_bits_per_symbol == 4: + return self.constellation, QAM16_GRAY_CODE, QAM16_GRAY_CODE + + _, _, lower_mod_gray_code = self._generate_gray_code(num_bits_per_symbol - 2) + grid_len = int(np.sqrt(2 ** (num_bits_per_symbol - 2))) + lower_mod_gray_code = np.flipud(lower_mod_gray_code.reshape(grid_len, grid_len).T) + + # Generate quadrants + quadrants = [ + lower_mod_gray_code, + lower_mod_gray_code + 2 ** (num_bits_per_symbol - 2), + lower_mod_gray_code + 3 * 2 ** (num_bits_per_symbol - 2), + lower_mod_gray_code + 2 ** (num_bits_per_symbol - 1), + ] + + # Combine quadrants + left_side = np.vstack((np.flipud(quadrants[1]), quadrants[0])) + right_side = np.vstack((np.flipud(np.fliplr(quadrants[2])), np.fliplr(quadrants[3]))) + ref_bit_mapping = np.hstack((left_side, right_side)).reshape(-1) + + # Apply indexing scheme + indices = self._generate_indexing_scheme(int(np.sqrt(2**num_bits_per_symbol))).reshape(-1) + constellation = self.constellation[indices] + bit_mapping = ref_bit_mapping[indices] + return constellation, bit_mapping, ref_bit_mapping + + def _generate_constellation(self) -> np.ndarray: + """ + Generate the QAM constellation points. + + :returns: The QAM constellation points. + :rtype: numpy array + """ + num_pam_symbols = 2 ** (self.num_bits_per_symbol // 2) + pam_constellation = np.arange(-num_pam_symbols + 1, num_pam_symbols, 2) + constellation = np.array(np.meshgrid(pam_constellation, pam_constellation)).T.reshape((-1, 2)) + constellation = constellation[:, 0] + 1j * constellation[:, 1] + if self.normalize: + return self._normalize(constellation) + return constellation diff --git a/src/ria_toolkit_oss/signal/block_generator/mapping/symbol_demapper.py b/src/ria_toolkit_oss/signal/block_generator/mapping/symbol_demapper.py new file mode 100644 index 0000000..85fb325 --- /dev/null +++ b/src/ria_toolkit_oss/signal/block_generator/mapping/symbol_demapper.py @@ -0,0 +1,141 @@ +from typing import Optional + +import numpy as np +from scipy.special import logsumexp + +from ria_toolkit_oss.signal.block_generator.data_types import DataType +from ria_toolkit_oss.signal.block_generator.process_block import ProcessBlock +from ria_toolkit_oss.signal.block_generator.recordable_block import RecordableBlock + + +class SymbolDemapper(RecordableBlock, ProcessBlock): + """ + A class to map received symbols back to their most likely symbols from a predefined constellation + using Maximum Likelihood Detection. + + :param constellation: The array of constellation points. + :type constellation: np.ndarray + :param no: The noise power spectral density, defaults to 1. + :type no: float, optional + :param prior: The prior probabilities of the constellation points, defaults to None. + :type prior: np.ndarray, optional + :param bits_out: Whether to return bits or symbols, defaults to True. + :type bits_out: bool, optional + + Methods: + -------- + __call__(rx_symbols: np.ndarray) -> np.ndarray: + Maps received symbols to their nearest constellation points based on the maximum likelihood estimation. + + """ + + def __init__( + self, + constellation: np.ndarray, + bit_mapping: np.ndarray, + no: Optional[float] = 1e-6, + prior: Optional[np.ndarray] = None, + bits_out: Optional[bool] = True, + llrs_out: Optional[bool] = False, + gray_code: Optional[bool] = False, + ): + self.constellation = constellation + self.bits_out = bits_out + self.llrs_out = llrs_out + if gray_code: + self.bit_mapping = np.argsort(bit_mapping) + else: + self.bit_mapping = bit_mapping + if prior is not None: + self.prior = prior + else: + self.prior = np.zeros((len(constellation),)) + self.no = no + + @property + def input_type(self) -> DataType: + """ + Get the input data type for the SymbolDemapper. + + :return: The input data type. + :rtype: DataType + """ + return [DataType.SOFT_SYMBOLS] + + @property + def output_type(self) -> DataType: + """ + Get the output data type for the SymbolDemapper. + + :return: The output data type. + :rtype: DataType + """ + if self.bits_out: + return DataType.BITS + else: + return DataType.SYMBOLS + + def _decimal_to_bits(self, decimal_arr: np.ndarray) -> np.ndarray: + """ + Convert an array of decimal values to their binary representations. + + :param decimal_arr: 2D array of decimal values to be converted + :type decimal_arr: numpy array + :return: 2D array of binary representations + :rtype: numpy array + """ + num_bits_per_symbol = int(np.log2(len(self.constellation))) + num_samples, num_symbols = decimal_arr.shape + + # Vectorized conversion of decimal to binary + binary_arr = ((decimal_arr[:, :, np.newaxis] & (1 << np.arange(num_bits_per_symbol)[::-1])) > 0).astype(int) + + # Reshape to flatten the bits for each sample + return binary_arr.reshape(num_samples, -1) + + def get_samples(self, num_samples): + samples = self.input[0].get_samples(num_samples) + return self.process(rx_symbols=samples) + + def __call__(self, rx_symbols: np.ndarray) -> np.ndarray: + """ + Maps received symbols to their nearest constellation points based on the maximum likelihood estimation. + + :param rx_symbols: The received symbols to be demapped. + :type rx_symbols: np.ndarray + :return: The array of demapped constellation points. + :rtype: numpy array + """ + rx_symbols_extended = np.tile( + rx_symbols.reshape((rx_symbols.shape[0], rx_symbols.shape[1], 1)), (1, 1, len(self.constellation)) + ) + constellation_extended = self.constellation.reshape((1, 1, -1)) + prior_extended = self.prior.reshape((1, 1, -1)) + minus_dist = -np.abs(rx_symbols_extended - constellation_extended) ** 2 / self.no + prior_extended + + if self.llrs_out: + batches, num_symbols = rx_symbols.shape + bits_per_sym = int(np.log2(len(self.constellation))) + bit_mapping = np.asarray(self.bit_mapping, dtype=np.uint16) # shape (M,) + bit_table = ((bit_mapping[:, None] >> np.arange(bits_per_sym - 1, -1, -1)) & 1).astype(bool) + + neg_inf = -1e30 + llr = np.empty((batches, num_symbols, bits_per_sym), dtype=np.float32) + + for b in range(bits_per_sym): + mask0 = ~bit_table[:, b] # symbols where bit b == 0 + mask1 = bit_table[:, b] # symbols where bit b == 1 + + ll0 = np.where(mask0, minus_dist, neg_inf) # (B,T,M) + ll1 = np.where(mask1, minus_dist, neg_inf) + + llr[..., b] = logsumexp(ll0, axis=-1) - logsumexp(ll1, axis=-1) + return llr.reshape(batches, num_symbols * bits_per_sym) + + elif self.bits_out: + indices = np.argmax(minus_dist, axis=-1) + return self._decimal_to_bits(self.bit_mapping[indices]) + + else: + indices = np.argmax(minus_dist, axis=-1) + return self.constellation[indices] diff --git a/src/ria_toolkit_oss/signal/block_generator/multirate/__init__.py b/src/ria_toolkit_oss/signal/block_generator/multirate/__init__.py new file mode 100644 index 0000000..cbc0261 --- /dev/null +++ b/src/ria_toolkit_oss/signal/block_generator/multirate/__init__.py @@ -0,0 +1,28 @@ +""" +RIA Miscellaneous Signal Processing Blocks Module + +This module provides auxiliary blocks for use in signal processing chains within the RIA block-based signal generator +framework. + +Key components: + +- Downsampling: Reduces the sampling rate of a signal +- Upsampling: Increases the sampling rate of a signal + +Features: + +- Integration with other RIA blocks +- Configurable parameters for flexible signal manipulation +- Essential utilities for common signal processing tasks + +Usage: + +- Import specific blocks to incorporate into your signal processing chain. + +For detailed parameters and methods, see individual block documentation. +""" + +from ria_toolkit_oss.signal.block_generator.multirate.downsampling import Downsampling +from ria_toolkit_oss.signal.block_generator.multirate.upsampling import Upsampling + +__all__ = ["Upsampling", "Downsampling"] diff --git a/src/ria_toolkit_oss/signal/block_generator/multirate/downsampling.py b/src/ria_toolkit_oss/signal/block_generator/multirate/downsampling.py new file mode 100644 index 0000000..bfae45a --- /dev/null +++ b/src/ria_toolkit_oss/signal/block_generator/multirate/downsampling.py @@ -0,0 +1,60 @@ +from typing import Optional + +import numpy as np + +from ria_toolkit_oss.signal.block_generator.block import Block +from ria_toolkit_oss.signal.block_generator.data_types import DataType + + +class Downsampling(Block): + """ + A class to perform downsampling on input signals. + + :param factor: The downsampling factor. + :type factor: int + + Methods: + __call__(signal: np.ndarray, delay: Optional[int] = 0, num_samples: Optional[int] = -1) -> np.ndarray: + Downsamples the input signal by the specified factor along the given axes. + """ + + def __init__(self, factor: int): + self.factor = factor + + def __call__(self, signal: np.ndarray, num_samples: Optional[int], delay: Optional[int] = 0) -> np.ndarray: + """ + Downsamples the input signal by the specified factor along the given axes. + + :param signal: The input signal to be downsampled. + :type signal: numpy array + :param num_samples: The number of samples to return after downsampling. + :type num_samples: int, optional + :param delay: The delay to start downsampling, defaults to 0. + :type delay: int, optional + :return: The downsampled signal. + :rtype: numpy array + """ + if num_samples: + return signal[:, delay : delay + self.factor * num_samples : self.factor] + else: + return signal[:, delay :: self.factor] + + @property + def input_type(self) -> DataType: + """ + Get the input data type for the downsampling operation. + + :return: The input data type. + :rtype: DataType + """ + return DataType.BASEBAND_SIGNAL + + @property + def output_type(self) -> DataType: + """ + Get the output data type for the downsampling operation. + + :return: The output data type. + :rtype: DataType + """ + return DataType.BASEBAND_SIGNAL diff --git a/src/ria_toolkit_oss/signal/block_generator/multirate/upsampling.py b/src/ria_toolkit_oss/signal/block_generator/multirate/upsampling.py new file mode 100644 index 0000000..d0626e5 --- /dev/null +++ b/src/ria_toolkit_oss/signal/block_generator/multirate/upsampling.py @@ -0,0 +1,66 @@ +import numpy as np + +from ria_toolkit_oss.signal.block_generator.block import Block +from ria_toolkit_oss.signal.block_generator.data_types import DataType + + +class Upsampling(Block): + """ + A class to perform upsampling on input signals. + + :param factor: The upsampling factor. + :type factor: int + + Methods: + __call__(signal: np.ndarray, axes: int = 0) -> np.ndarray: + Upsamples the input signal by the specified factor along the given axes. + + Example: + -------- + # Create an Upsampling instance with a factor of 3 + >>> upsampler = Upsampling(3) + + # Original signal + >>> signal = np.array([[1, 2], [3, 4]]) + + # Perform upsampling + >>> upsampled_signal = upsampler(signal) + >>> print(upsampled_signal) + array([[1, 0, 0, 2, 0, 0], + [3, 0, 0, 4, 0, 0]]) + """ + + def __init__(self, factor: int): + self.factor = factor + + @property + def input_type(self) -> DataType: + """Get the input data type for the upsampling operation. + + :return: The input data type. + :rtype: DataType + """ + return DataType.SYMBOLS + + @property + def output_type(self) -> DataType: + """Get the output data type for the upsampling operation. + + :return: The output data type. + :rtype: DataType + """ + return DataType.UPSAMPLED_SYMBOLS + + def __call__(self, signal: np.ndarray) -> np.ndarray: + """Upsample the input signal by inserting zeros between samples. + + :param signal: The input signal to be upsampled. Shape should be (n_samples, n_bits). + :type signal: numpy array + + :return: The upsampled signal. Shape will be (n_samples, n_bits * factor). + :rtype: numpy array + """ + n_samples, n_bits = signal.shape + us_signal = np.zeros((n_samples, n_bits * self.factor), dtype=signal.dtype) + us_signal[:, :: self.factor] = signal + return us_signal diff --git a/src/ria_toolkit_oss/signal/block_generator/process_block.py b/src/ria_toolkit_oss/signal/block_generator/process_block.py new file mode 100644 index 0000000..4b618cd --- /dev/null +++ b/src/ria_toolkit_oss/signal/block_generator/process_block.py @@ -0,0 +1,87 @@ +from abc import ABC, abstractmethod + +import numpy as np + +from ria_toolkit_oss.signal.block_generator.block import Block +from ria_toolkit_oss.signal.block_generator.data_types import DataType + + +class ProcessBlock(Block, ABC): + def __init__(self): + self.input: list[Block] = [] + + def _validate_input(self, input) -> None: + """ + Validate input block formats. + Must be a list of Block object of the correct length. + + :raises ValueError: if block configuration is invalid. + """ + if not isinstance(input, list): + raise ValueError( + f"Block '{self.__class__.__name__}' input must be a list of block objects but was {type(input)}." + ) + + elif not all(isinstance(item, Block) for item in input): + raise ValueError( + f"Invalid input to block '{self.__class__.__name__}'. \ + Expected a list of Block objects but got \ + {'[' + ',' .join(f'{item.__class__.__name__}({repr(item)})' for item in input) + ']'}" + ) + + elif len(input) != len(self.input_type): + raise ValueError( + f"Block '{self.__class__.__name__}' requires {len(self.input_type)} input but got {len(input)}" + ) + + def connect_input(self, input: list[Block]) -> None: + """ + Declare the input block(s) for this block. + + :param input: Input blocks. + :type input: list of Block objects. + """ + + self._validate_input(input) + self.input = input + + @property + @abstractmethod + def input_type(self) -> list[DataType]: + """ + Get the input data types for the block. + + :return: The data type of each input. + :rtype: list[DataType] + """ + pass + + @abstractmethod + def __call__(self, samples: list[np.array]): + """ + Process input samples and return output samples. + + :param samples: A list of n input arrays, where length and datatypes are defined by block.input_type. + :type samples: list of np.array + + :returns: The processed output array, where datatype is defined by block.output_type. + :rtype: np.array""" + pass + + def get_samples(self, num_samples: int): + """ + Get num_samples samples from this block by recursively requesting samples from upstream blocks. + + :param num_samples: The number of samples to output. + :type num_samples: int + + Note: If a new block implementation decimates or multiplies the number of samples from upstream blocks + this method must be overridden to implement the correct sample requests from input blocks. + """ + input_signals = [input.get_samples(num_samples) for input in self.input] + output = self.__call__(samples=input_signals) + if len(output) != num_samples: + raise ValueError( + f"Error in block {self.__class__.__name__}: requested {num_samples} samples but got {len(output)}." + ) + return output diff --git a/src/ria_toolkit_oss/signal/block_generator/pulse_shaping/__init__.py b/src/ria_toolkit_oss/signal/block_generator/pulse_shaping/__init__.py new file mode 100644 index 0000000..fc80459 --- /dev/null +++ b/src/ria_toolkit_oss/signal/block_generator/pulse_shaping/__init__.py @@ -0,0 +1,52 @@ +""" +A set of blocks to pulse shape a modulated signal. + +Pulse shaping is a signal processing technique +used in digital communications to modify the waveform +of transmitted pulses to improve efficiency and reduce +interference. +It helps control the bandwidth of the +transmitted signal and minimizes intersymbol +interference (ISI), which occurs when overlapping +pulses cause errors in symbol detection. +Common filters include Sinc, Raised Cosine and Root Raised Cosine. + +Filters are applied to upsampled signal, which consists of +each input symbol followed by n-1 0 samples, where n is the +upsampling factor. + +Example Usage: + + >>> from ria_toolkit_oss.signal.block_generator import RandomBinarySource, Mapper, Upsampling, RaisedCosineFilter + + >>> # create digital modulaiton symbols + >>> source = RandomBinarySource() + >>> mapper = Mapper(constellation_type='psk', num_bits_per_symbol=2) + >>> mapper.connect_input([source]) + + >>> # pulse shape the symbols + >>> upsampling_factor = 4 + >>> upsampler = Upsampling(factor = upsampling_factor) + >>> upsampler.connect_input([mapper]) + >>> filter = RaisedCosineFilter(span_in_symbols=100, upsampling_factor=upsampling_factor, beta=0.1) + >>> filter.connect_input([upsampler]) + >>> filter.record(num_samples = 10000) +""" + +from .gaussian_filter import GaussianFilter +from .pulse_shaping_filter import PulseShapingFilter +from .raised_cosine_filter import RaisedCosineFilter +from .rect_filter import RectFilter +from .root_raised_cosine_filter import RootRaisedCosineFilter +from .sinc_filter import SincFilter +from .upsampling import Upsampling + +__all__ = [ + "PulseShapingFilter", + "GaussianFilter", + "RaisedCosineFilter", + "RootRaisedCosineFilter", + "RectFilter", + "SincFilter", + "Upsampling", +] diff --git a/src/ria_toolkit_oss/signal/block_generator/pulse_shaping/gaussian_filter.py b/src/ria_toolkit_oss/signal/block_generator/pulse_shaping/gaussian_filter.py new file mode 100644 index 0000000..9f4b5cc --- /dev/null +++ b/src/ria_toolkit_oss/signal/block_generator/pulse_shaping/gaussian_filter.py @@ -0,0 +1,95 @@ +from typing import Optional + +import numpy as np + +from ria_toolkit_oss.signal.block_generator.pulse_shaping.pulse_shaping_filter import ( + PulseShapingFilter, +) + + +class GaussianFilter(PulseShapingFilter): + r""" + A class to implement the Gaussian filter used in GMSK. + + The Gaussian filter impulse response in continuous time can be expressed as: + + .. math:: + h(t) = \frac{1}{\sqrt{2\pi}\,\sigma} \exp\!\Bigl(-\frac{t^2}{2\,\sigma^2}\Bigr), + + where :math:`\sigma` is related to the bandwidth-time product (BT). In many references, one sets + :math:`BT` for the 3 dB bandwidth and the symbol period :math:`T=1`, leading to + + .. math:: + \sigma = \frac{\sqrt{\ln(2)}}{2\,\pi\,BT}. + + For discrete-time implementation, we sample :math:`h(t)` over a finite span in symbols (``span_in_symbols``) + and at ``upsampling_factor`` samples per symbol. If ``normalize=True``, the filter coefficients are normalized + according to the base class's :meth:`_normalize_weights` method (which might be unit-energy or unit-sum, depending + on your implementation). + + :param span_in_symbols: The span of the filter in terms of symbols. + :type span_in_symbols: int + :param upsampling_factor: The number of samples per symbol. + :type upsampling_factor: int + :param bt: The bandwidth-time product, a key parameter for Gaussian filters. + :type bt: float + :param normalize: Whether to normalize the filter coefficients, defaults to True. + :type normalize: bool, optional + """ + + def __init__(self, span_in_symbols: int, upsampling_factor: int, bt: float, normalize: Optional[bool] = True): + self.bt = bt + + # Calculate the total number of taps; ensure it's odd (like in SincFilter). + num_taps = span_in_symbols * upsampling_factor + if num_taps % 2 == 0: + num_taps += 1 + + # Generate and optionally normalize the filter coefficients + weights = self._generate_weights(num_taps, upsampling_factor) + super().__init__(span_in_symbols, upsampling_factor, weights, normalize) + + def _generate_weights(self, num_taps, upsampling_factor) -> np.ndarray: + r""" + Generate the Gaussian filter coefficients for GMSK. + + In normalized units (symbol period :math:`T = 1`), we define: + + .. math:: + \sigma = \frac{\sqrt{\ln(2)}}{2\,\pi\,BT} + + and compute the discrete-time Gaussian: + + .. math:: + h[n] = \frac{1}{\sqrt{2\pi}\,\sigma} \exp\!\Bigl(-\frac{t^2}{2\,\sigma^2}\Bigr), + + where :math:`t = \frac{n}{\text{upsampling_factor}}` in the range + :math:`\pm \frac{\text{span_in_symbols}}{2}` symbols. + + :return: A 1D numpy array of Gaussian filter taps. + :rtype: np.ndarray + """ + # Define sigma based on the bandwidth-time product (BT) + sigma = np.sqrt(np.log(2)) / (2 * np.pi * self.bt) + + # Create a symmetric time axis in "symbol units". + # Example: if num_taps=11, we get n from -5..5, so time from -5/upsamp..+5/upsamp + half = num_taps // 2 + n = np.arange(-half, half + 1) + t_axis = n / upsampling_factor # in "symbol durations" + + # Compute the Gaussian pulse + gauss = 1.0 / (np.sqrt(2.0 * np.pi) * sigma) * np.exp(-0.5 * (t_axis / sigma) ** 2) + return gauss + + def __str__(self) -> str: + """ + Return a string representation of the GaussianFilter object. + + :return: A string describing the GaussianFilter with its parameters. + :rtype: str + """ + return ( + f"GaussianFilter(span_in_symbols={self.span_in_symbols}, " + f"upsampling_factor={self.upsampling_factor}, bt={self.bt})" + ) diff --git a/src/ria_toolkit_oss/signal/block_generator/pulse_shaping/pulse_shaping_filter.py b/src/ria_toolkit_oss/signal/block_generator/pulse_shaping/pulse_shaping_filter.py new file mode 100644 index 0000000..71da225 --- /dev/null +++ b/src/ria_toolkit_oss/signal/block_generator/pulse_shaping/pulse_shaping_filter.py @@ -0,0 +1,200 @@ +import os +from datetime import datetime +from typing import List, Optional, Tuple + +import matplotlib.pyplot as plt +import numpy as np +import scipy.signal as ss + +from ria_toolkit_oss.signal.block_generator.data_types import DataType +from ria_toolkit_oss.signal.block_generator.process_block import ProcessBlock +from ria_toolkit_oss.signal.block_generator.recordable_block import RecordableBlock + + +class PulseShapingFilter(ProcessBlock, RecordableBlock): + """ + Pulse Shaping Block + + Applies a pulse shaping filter to an upsampled signal. + + Input Type: UPSAMPLED_SYMBOLS + + Output Type: BASEBAND_SIGNAL + + :param span_in_symbols: The span of the filter in terms of symbols. + :type span_in_symbols: int + :param upsampling_factor: Number of samples per symbol. + :type upsampling_factor: int + :param weights: The filter coefficients, defaults to None. + :type weights: np.ndarray | None + :param normalize: Whether to normalize the filter coefficients, defaults to True. + :type normalize: bool, optional + """ + + def __init__( + self, + span_in_symbols: Optional[int] = 100, + upsampling_factor: Optional[int] = 4, + weights: Optional[np.ndarray] = None, + normalize: Optional[bool] = True, + ): + self.span_in_symbols = span_in_symbols + self.upsampling_factor = upsampling_factor + self.weights: Optional[np.ndarray] = weights + self.num_taps: Optional[int] = len(self.weights) if self.weights is not None else None + if normalize: + self._normalize_weights() + + super().__init__() + + @property + def input_type(self) -> DataType: + """ + Get the input data type for the filter. + + :return: The input data type. + :rtype: DataType + """ + return [DataType.UPSAMPLED_SYMBOLS] + + @property + def output_type(self) -> DataType: + """ + Get the output data type for the filter. + + :return: The output data type. + :rtype: DataType + """ + return DataType.BASEBAND_SIGNAL + + def __str__(self) -> str: + """ + Return a string representation of the PulseShapingFilter. + + :return: A string describing the filter's parameters. + :rtype: str + """ + return f"CustomFilter(span_in_symbols={self.span_in_symbols}, " f"upsampling_factor={self.upsampling_factor})" + + def _normalize_weights(self) -> None: + """ + Normalize the filter weights so that their energy sums to 1. + """ + if self.weights is not None: + self.weights /= np.sqrt(np.sum(np.abs(self.weights) ** 2)) + + def _pad_signals(self, signal: np.ndarray, padding_axis: int = -1) -> Tuple[np.ndarray, np.ndarray]: + """ + Pad the upsampled signal and weights to the maximum length. + + :param signal: The signal to be padded. + :type signal: np.ndarray + :param padding_axis: The axis along which to perform the padding. + :type padding_axis: int + :return: The padded signal and weights as a tuple of numpy arrays. + :rtype: tuple of np.ndarray + """ + # Ensure weights are 1D array + weights = self.weights + # Determine the maximum length for padding + max_len = max(weights.shape[0], signal.shape[1]) + + # Pad the upsampled signal to the maximum length + if signal.shape[1] < max_len: + pad_width: List[Tuple[int, int]] = [(0, 0)] * signal.ndim + pad_width[padding_axis] = (0, max_len - signal.shape[1]) + signal_padded = np.concatenate((signal, np.zeros(pad_width, dtype=signal.dtype)), axis=padding_axis) + else: + signal_padded = signal + + # Pad the weights if they are smaller than the signal + if weights.shape[0] < max_len: + weights_padded = np.concatenate((weights, np.zeros(max_len - weights.shape[0], weights.dtype))) + else: + weights_padded = weights + weights_padded = np.tile(weights_padded.reshape((1, -1)), (signal_padded.shape[0], 1)) + return signal_padded, weights_padded + + def _trim_output(self, signal: np.ndarray, input_length: int) -> np.ndarray: + """ + Trim the output signal to the expected length. + + :param signal: The filtered signal. + :type signal: np.ndarray + :param input_length: The length of the input signal. + :type input_length: int + :return: The trimmed signal. + :rtype: np.ndarray + """ + expected_length = input_length + self.num_taps - 1 + return signal[..., :expected_length] + + def __call__(self, samples): + """ + Apply the filter to an upsampled signal using convolution and trim the output. + + :param samples: The signal to be filtered. + :type samples: list of np.array, length = 1 + + :return: The filtered and trimmed signal. + :rtype: np.array + """ + padding = "full" + upsampled_signal = np.array([samples[0]]) + upsampled_signal_padded, weights_padded = self._pad_signals(upsampled_signal, 1) + filtered_signal = ss.fftconvolve(upsampled_signal_padded, weights_padded, mode=padding, axes=-1) + return self._trim_output(filtered_signal, upsampled_signal.shape[-1])[0, : len(samples[0])] + + def apply_matched_filter( + self, upsampled_signal: np.ndarray, padding: str = "full", padding_axis: int = 0 + ) -> np.ndarray: + """ + Apply the matched filter to an upsampled signal using convolution and trim the output. + + :param upsampled_signal: The signal to be filtered. + :type upsampled_signal: np.ndarray + :param padding: The type of padding to use, defaults to 'full'. Options are 'full', 'same', 'valid'. + :type padding: str + :param padding_axis: The axis along which to perform the padding, defaults to 0. + :type padding_axis: int + :return: The filtered and trimmed signal. + :rtype: np.ndarray + """ + upsampled_signal_padded, weights_padded = self._pad_signals(upsampled_signal, padding_axis) + filtered_signal = ss.fftconvolve(upsampled_signal_padded, np.conj(weights_padded[::-1]), mode=padding, axes=-1) + return self._trim_output(filtered_signal, upsampled_signal.shape[-1]) + + def show(self) -> None: + """ + Display the impulse response, phase response, and frequency response of the filter. + """ + fft_size = 4096 + phase_response = np.angle(self.weights) + freq_response = np.abs(np.fft.fftshift(np.fft.fft(self.weights, fft_size))) + num_taps = self.num_taps + + fig, axs = plt.subplots(figsize=(10, 10), nrows=3, ncols=1) + t_axis = np.linspace(-self.span_in_symbols // 2, self.span_in_symbols // 2, num_taps) + f_axis = np.linspace(-fft_size // 2, fft_size // 2, fft_size) + axs[0].plot(t_axis, self.weights, linewidth=3) + axs[0].set_title("Impulse Response") + axs[0].set_ylabel("Amplitude") + axs[0].set_xlabel(r"Normalized time with respect to symbol duration $T_s$") + + axs[1].plot(t_axis, phase_response, linewidth=3) + axs[1].set_title("Phase Response") + axs[1].set_ylabel("Phase") + axs[1].set_xlabel(r"Normalized time with respect to symbol duration $T_s$") + + axs[2].plot(f_axis, 10 * np.log10(freq_response), linewidth=3) + axs[2].set_title("Frequency Response") + axs[2].set_ylabel("Magnitude (dB)") + axs[2].set_xlabel("Frequency bins") + plt.tight_layout() + # ToDo: this saving approach needs to change - not sure how yet :D + os.makedirs("images", exist_ok=True) + now = datetime.now() + formatted_time = now.strftime("%Y%m%d_%H%M%S") + file_name = f"images/impulse_response_{formatted_time}.png" + fig.savefig(file_name, dpi=800) + plt.show() diff --git a/src/ria_toolkit_oss/signal/block_generator/pulse_shaping/raised_cosine_filter.py b/src/ria_toolkit_oss/signal/block_generator/pulse_shaping/raised_cosine_filter.py new file mode 100644 index 0000000..c938b81 --- /dev/null +++ b/src/ria_toolkit_oss/signal/block_generator/pulse_shaping/raised_cosine_filter.py @@ -0,0 +1,110 @@ +from typing import Optional + +import numpy as np + +from ria_toolkit_oss.signal.block_generator.pulse_shaping.pulse_shaping_filter import ( + PulseShapingFilter, +) + + +class RaisedCosineFilter(PulseShapingFilter): + r""" + Raised Cosine Filter Block + + Applies a raised cosine filter to an upsampled signal. + + Input Type: UPSAMPLED_SYMBOLS + + Output Type: BASEBAND_SIGNAL + + The raised cosine filter is defined by the following equation: + + .. math:: + h(t) = + \begin{cases} + \frac{\pi}{4T} \text{sinc}\left(\frac{1}{2\beta}\right), & \text { if }t = \pm \frac{T}{2\beta}\\ + \frac{1}{T}\text{sinc}\left(\frac{t}{T}\right)\ + \frac{\cos\left(\frac{\pi\beta t}{T}\right)}{1-\left(\frac{2\beta t}{T}\right)^2}, & \text{otherwise} + \end{cases} + + where :math:`\beta` is the roll-off factor and :math:`T` the symbol duration. + + :param span_in_symbols: The span of the filter in terms of symbols. + :type span_in_symbols: int + :param upsampling_factor: The number of samples per symbol. + :type upsampling_factor: int + :param beta: The roll-off factor of the raised cosine filter. Must be between 0 and 1. + :type beta: float + :param normalize: Whether to normalize the filter coefficients, defaults to True. + :type normalize: bool, optional + """ + + def __init__( + self, + span_in_symbols: Optional[int] = 100, + upsampling_factor: Optional[int] = 4, + beta: Optional[float] = 0.1, + normalize: Optional[bool] = True, + ): + super().__init__(span_in_symbols, upsampling_factor, None, normalize) + assert 0 < beta <= 1, "Beta must be between 0 and 1" + self.beta = beta + + num_taps = self.span_in_symbols * self.upsampling_factor + if num_taps % 2 == 0: + num_taps += 1 + self.num_taps = num_taps + self.weights = self._generate_weights() + if normalize: + self._normalize_weights() + + def _generate_weights(self) -> np.ndarray: + """ + Generate the weights for the raised cosine filter. + + :return: The filter coefficients. + :rtype: np.ndarray + """ + num_taps = self.num_taps + half = num_taps // 2 + t_axis = np.arange(-half, half + 1) + return self._raised_cosine(t_axis) + + def _raised_cosine(self, t: np.ndarray) -> np.ndarray: + """ + Calculate the raised cosine filter coefficients for a given time axis. + + This method implements the raised cosine filter equation, including + handling the limit case where t = ±T/(2β). + + :param t: The time axis. + :type t: np.ndarray + + :return: The raised cosine filter coefficients. + :rtype: np.ndarray + """ + t_symbol = self.upsampling_factor + beta = self.beta + f_val = ( + 1 + / t_symbol + * np.sinc(t / t_symbol) + * np.cos(np.pi * beta * t / t_symbol) + / (1 - (2 * beta * t / t_symbol) ** 2) + ) + idx_limit_case = np.where(np.abs(np.abs(t) - (t_symbol / (2 * beta))) < 1e-6)[0] + if idx_limit_case.size > 0: + f_val[idx_limit_case] = np.pi / (4 * t_symbol) * np.sinc(1 / (2 * beta)) + return f_val + + def __str__(self) -> str: + """ + Return a string representation of the RaisedCosineFilter object. + + :returns: A string containing the class name and its main parameters. + :rtype: str + """ + return ( + f"RaisedCosineFilter(span_in_symbols={self.span_in_symbols}, " + f"upsampling_factor={self.upsampling_factor}, beta={self.beta})" + ) diff --git a/src/ria_toolkit_oss/signal/block_generator/pulse_shaping/rect_filter.py b/src/ria_toolkit_oss/signal/block_generator/pulse_shaping/rect_filter.py new file mode 100644 index 0000000..0c09468 --- /dev/null +++ b/src/ria_toolkit_oss/signal/block_generator/pulse_shaping/rect_filter.py @@ -0,0 +1,53 @@ +from typing import Optional + +import numpy as np + +from ria_toolkit_oss.signal.block_generator.pulse_shaping.pulse_shaping_filter import ( + PulseShapingFilter, +) + + +class RectFilter(PulseShapingFilter): + r""" + A class to implement the rectangular (boxcar) filter. + + The rectangular filter is defined by a constant amplitude over its span. In discrete time, + this translates to filter coefficients that are all ones (or all some constant). If normalization + is enabled, the base class's :meth:`_normalize_weights` method will apply the chosen normalization + rule (e.g., unit energy or unit sum). + + :param span_in_symbols: The span of the filter in terms of symbols. + :type span_in_symbols: int + :param upsampling_factor: The number of samples per symbol. + :type upsampling_factor: int + :param normalize: Whether to normalize the filter coefficients, defaults to True. + :type normalize: bool, optional + """ + + def __init__(self, span_in_symbols: int, upsampling_factor: int, normalize: Optional[bool] = True): + # Calculate the total number of taps (ensure it's odd, similar to SincFilter) + num_taps = span_in_symbols * upsampling_factor + if num_taps % 2 == 0: + num_taps += 1 + + # Generate and optionally normalize the filter coefficients + weights = self._generate_weights(num_taps) + super().__init__(span_in_symbols, upsampling_factor, weights, normalize) + + def _generate_weights(self, num_taps) -> np.ndarray: + """ + Generate the weights for the rectangular filter. + + :return: A 1D numpy array of ones of length `self.num_taps`. + :rtype: np.ndarray + """ + return np.ones(num_taps) + + def __str__(self) -> str: + """ + Return a string representation of the RectFilter object. + + :return: A string describing the RectFilter with its parameters. + :rtype: str + """ + return f"RectFilter(span_in_symbols={self.span_in_symbols}, upsampling_factor={self.upsampling_factor})" diff --git a/src/ria_toolkit_oss/signal/block_generator/pulse_shaping/root_raised_cosine_filter.py b/src/ria_toolkit_oss/signal/block_generator/pulse_shaping/root_raised_cosine_filter.py new file mode 100644 index 0000000..1b801cb --- /dev/null +++ b/src/ria_toolkit_oss/signal/block_generator/pulse_shaping/root_raised_cosine_filter.py @@ -0,0 +1,111 @@ +from typing import Optional + +import numpy as np + +from ria_toolkit_oss.signal.block_generator.pulse_shaping.pulse_shaping_filter import ( + PulseShapingFilter, +) + + +class RootRaisedCosineFilter(PulseShapingFilter): + r""" + Root Raised Cosine Filter Block + + Applies a root raised cosine filter to an upsampled signal. + + Input Type: UPSAMPLED_SYMBOLS + + Output Type: BASEBAND_SIGNAL + + The root-raised cosine filter is defined by the following equation: + + .. math:: + h(t) = + \begin{cases} + \frac{1}{T} \left(1 + \beta\left(\frac{4}{\pi}-1\right) \right), & \text{if } t = 0 \\ + \frac{\beta}{T\sqrt{2}} \left[ \left(1+\frac{2}{\pi}\right)\sin\left(\frac{\pi}{4\beta}\right) + + \left(1-\frac{2}{\pi}\right)\cos\left(\frac{\pi}{4\beta}\right) \right], & \text{if } t = \pm\frac{T}{4\beta}\\ + \frac{1}{T} \frac{\sin\left(\pi\frac{t}{T}(1-\beta)\right) + 4\beta\frac{t}{T}\cos\left(\pi\frac{t}{T} + (1+\beta)\right)}{\pi\frac{t}{T}\left(1-\left(4\beta\frac{t}{T}\right)^2\right)}, & \text{otherwise} + \end{cases} + + where :math:`\beta` is the roll-off factor and :math:`T` the symbol duration. + + :param span_in_symbols: The span of the filter in terms of symbols. + :type span_in_symbols: int + :param upsampling_factor: The number of samples per symbol. + :type upsampling_factor: int + :param beta: The roll-off factor of the raised cosine filter. Must be between 0 and 1. + :type beta: float + :param normalize: Whether to normalize the filter coefficients, defaults to True. + :type normalize: bool, optional + """ + + def __init__( + self, + span_in_symbols: Optional[int] = 100, + upsampling_factor: Optional[int] = 4, + beta: Optional[float] = 0.1, + normalize: Optional[bool] = True, + ): + super().__init__(span_in_symbols, upsampling_factor, None, normalize) + assert 0 < beta <= 1, "Beta must be between 0 and 1" + self.beta = beta + + num_taps = self.span_in_symbols * self.upsampling_factor + if num_taps % 2 == 0: + num_taps += 1 + self.num_taps = num_taps + self.weights = self._generate_weights() + if normalize: + self._normalize_weights() + + def _generate_weights(self) -> np.ndarray: + """ + Generate the weights for the root raised cosine filter. + + :return: The filter coefficients. + :rtype: np.ndarray + """ + num_taps = self.num_taps + half = num_taps // 2 + t_axis = np.arange(-half, half + 1) + return self._root_raised_cosine(t_axis) + + def _root_raised_cosine(self, t: np.ndarray) -> np.ndarray: + """ + Calculate the root raised cosine filter coefficients for a given time axis. + + :param t: The time axis. + :type t: np.ndarray + :return: The root raised cosine filter coefficients. + :rtype: np.ndarray + """ + beta = self.beta + t_symbol = self.upsampling_factor + alpha = 4 * beta * t / t_symbol + + t[t == 0] = 1e9 + f_val = (np.sin(np.pi * t / t_symbol * (1 - beta)) + alpha * np.cos(np.pi * t / t_symbol * (1 + beta))) / ( + np.pi * t * (1 - alpha**2) + ) + f_val[t == 1e9] = (1 + beta * (4 / np.pi - 1)) / t_symbol + + idx_limit_case = np.where(np.abs(np.abs(t) - (t_symbol / (4 * beta))) < 1e-6)[0] + if idx_limit_case.size > 0: + f_val[idx_limit_case] = (beta / t_symbol / np.sqrt(2)) * ( + (1 + 2 / np.pi) * np.sin(np.pi / 4 / beta) + (1 - 2 / np.pi) * np.cos(np.pi / 4 / beta) + ) + return f_val + + def __str__(self) -> str: + """ + Return a string representation of the RootRaisedCosineFilter object. + + :return: A string describing the filter's parameters. + :rtype: str + """ + return ( + f"RootRaisedCosineFilter(span_in_symbols={self.span_in_symbols}, " + f"upsampling_factor={self.upsampling_factor}, beta={self.beta})" + ) diff --git a/src/ria_toolkit_oss/signal/block_generator/pulse_shaping/sinc_filter.py b/src/ria_toolkit_oss/signal/block_generator/pulse_shaping/sinc_filter.py new file mode 100644 index 0000000..1b593d9 --- /dev/null +++ b/src/ria_toolkit_oss/signal/block_generator/pulse_shaping/sinc_filter.py @@ -0,0 +1,73 @@ +from typing import Optional + +import numpy as np + +from ria_toolkit_oss.signal.block_generator.pulse_shaping.pulse_shaping_filter import ( + PulseShapingFilter, +) + + +class SincFilter(PulseShapingFilter): + r""" + Sinc Filter Block + + Apply a sinc filter to an upsampled signal. + + Input Type: UPSAMPLED_SYMBOLS + + Output Type: BASEBAND_SIGNAL + + The sinc filter is defined by the following equation: + + .. math:: + + h(t) = \frac{1}{T}\text{sinc}\left(\frac{t}{T}\right) + + where :math:`T` the symbol duration. + + :param span_in_symbols: The span of the filter in terms of symbols. + :type span_in_symbols: int + :param upsampling_factor: The number of samples per symbol. + :type upsampling_factor: int + :param normalize: Whether to normalize the filter coefficients, defaults to True. + :type normalize: bool, optional + """ + + def __init__( + self, + span_in_symbols: Optional[int] = 100, + upsampling_factor: Optional[int] = 4, + normalize: Optional[bool] = True, + ): + super().__init__(span_in_symbols, upsampling_factor, None, normalize) + + num_taps = self.span_in_symbols * self.upsampling_factor + if num_taps % 2 == 0: + num_taps += 1 + self.num_taps = num_taps + self.weights = self._generate_weights() + if normalize: + self._normalize_weights() + + def _generate_weights(self) -> np.ndarray: + """ + Generate the weights for the sinc filter. + + :return: The filter coefficients. + :rtype: np.ndarray + """ + num_taps = self.num_taps + t_symbol = self.upsampling_factor + half = num_taps // 2 + n = np.arange(-half, half + 1) + t_axis = n / t_symbol + return np.sinc(t_axis) + + def __str__(self) -> str: + """ + Return a string representation of the SincFilter object. + + :return: A string describing the SincFilter with its parameters. + :rtype: str + """ + return f"SincFilter(span_in_symbols={self.span_in_symbols}, " f"upsampling_factor={self.upsampling_factor})" diff --git a/src/ria_toolkit_oss/signal/block_generator/pulse_shaping/upsampling.py b/src/ria_toolkit_oss/signal/block_generator/pulse_shaping/upsampling.py new file mode 100644 index 0000000..156f441 --- /dev/null +++ b/src/ria_toolkit_oss/signal/block_generator/pulse_shaping/upsampling.py @@ -0,0 +1,75 @@ +import math +from typing import Optional + +import numpy as np + +from ria_toolkit_oss.signal.block_generator.data_types import DataType +from ria_toolkit_oss.signal.block_generator.process_block import ProcessBlock +from ria_toolkit_oss.signal.block_generator.recordable_block import RecordableBlock + + +class Upsampling(ProcessBlock, RecordableBlock): + """ + Upsampling Block + + Upsample the input signal. This means that each input symbol will be followed by n-1 0 samples, + where n is the upsampling factor. This process is performed before a pulse shaping filter to convert + symbols into IQ samples. Ensure that the upsampling factor of both the upsampler and the filter are the same. + + For example, if factor = 4: + Input = [1,1,1,1] + + Output = [1,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0] + + Input Type: SYMBOLS + Output Type: UPSAMPLED_SYMBOLS + + :param factor: The upsampling factor. + :type factor: int + """ + + def __init__(self, factor: Optional[int] = 4): + self.factor = factor + + @property + def input_type(self) -> DataType: + """Get the input data type for the upsampling operation. + + :return: The input data type. + :rtype: DataType + """ + return [DataType.SYMBOLS] + + @property + def output_type(self) -> DataType: + """Get the output data type for the upsampling operation. + + :return: The output data type. + :rtype: DataType + """ + return DataType.UPSAMPLED_SYMBOLS + + def get_samples(self, num_samples) -> np.ndarray: + """Upsample the input signal by inserting zeros between samples. + + :param signal: The input signal to be upsampled. Shape should be (n_samples, n_bits). + :type signal: numpy array + + :return: The upsampled signal. Shape will be (n_samples, n_bits * factor). + :rtype: numpy array + """ + return self.__call__([self.input[0].get_samples(int(math.ceil(num_samples / self.factor)))[:num_samples]]) + + def __call__(self, samples): + """ + Upsample an array of complex samples. + + :param samples: A list containing a single array of complex samples. + :type samples: list of np.array + + :returns: Processed samples. + :rtype: np.array""" + signal = samples[0] + us_signal = np.zeros(len(signal) * self.factor, dtype=signal.dtype) + us_signal[:: self.factor] = signal + return us_signal diff --git a/src/ria_toolkit_oss/signal/block_generator/recordable_block.py b/src/ria_toolkit_oss/signal/block_generator/recordable_block.py new file mode 100644 index 0000000..4b63648 --- /dev/null +++ b/src/ria_toolkit_oss/signal/block_generator/recordable_block.py @@ -0,0 +1,30 @@ +from ria_toolkit_oss.datatypes import Recording +from ria_toolkit_oss.signal import Recordable +from ria_toolkit_oss.signal.block_generator.block import Block + + +class RecordableBlock(Block, Recordable): + def record(self, num_samples: int) -> Recording: + """ + Create a Recording object (samples and metadata), num_samples long, + generated by this block and all connected input blocks. + Metadata includes all object parameters of all connected blocks. + + :param num_samples: The number of samples to record. + :type num_samples: int + + :returns: A recording object. + :rtype: :ref:`Recording ` + + :raises ValueError: If input blocks have incompatible output and input datatypes. + :raises ValueError: If the number of samples is incorrect.""" + samples = self.get_samples(num_samples) + if len(samples) != num_samples: + raise ValueError( + f"Error in block {self.__class__.__name__} record(). \ + Requested {num_samples} samples but got {len(samples)}" + ) + metadata = self._get_metadata() + return Recording(data=samples, metadata=metadata) + + # TODO enforce output type = IQ_SAMPLES diff --git a/src/ria_toolkit_oss/signal/block_generator/recording_gen_wrapper.py b/src/ria_toolkit_oss/signal/block_generator/recording_gen_wrapper.py new file mode 100644 index 0000000..ef449e4 --- /dev/null +++ b/src/ria_toolkit_oss/signal/block_generator/recording_gen_wrapper.py @@ -0,0 +1,141 @@ +import os +from datetime import datetime + +import click +import numpy as np + +from ria_toolkit_oss.datatypes.recording import Recording +from ria_toolkit_oss.signal.block_generator.mapping.mapper import Mapper +from ria_toolkit_oss.signal.block_generator.multirate.upsampling import Upsampling +from ria_toolkit_oss.signal.block_generator.pulse_shaping.raised_cosine_filter import ( + RaisedCosineFilter, +) +from ria_toolkit_oss.signal.block_generator.pulse_shaping.root_raised_cosine_filter import ( + RootRaisedCosineFilter, +) +from ria_toolkit_oss.signal.block_generator.pulse_shaping.sinc_filter import SincFilter +from ria_toolkit_oss.signal.block_generator.siso_channel.awgn_channel import AWGNChannel +from ria_toolkit_oss.signal.block_generator.siso_channel.flat_rayleigh import ( + FlatRayleigh, +) + + +@click.command() +@click.option("--num_samples", default=10, help="Number of samples.") +@click.option("--num_bits", default=40096, help="Number of bits.") +@click.option("--num_bits_per_symbol", default=4, help="Number of bits per symbol.") +@click.option("--modulation_list", multiple=True, default=["QAM", "PSK", "PAM"], help="List of modulation schemes.") +@click.option( + "--filter_type", default="RRC", type=click.Choice(["SINC", "RC", "RRC"], case_sensitive=False), help="Filter type." +) +@click.option("--span_in_symbols", default=6, help="Span in symbols.") +@click.option("--samples_per_symbol", default=8, help="Samples per symbol.") +@click.option("--beta", default=0.25, help="Roll-off factor for RC and RRC filters.") +@click.option( + "--channel_type", + default="Rayleigh", + type=click.Choice(["Rayleigh", "AWGN"], case_sensitive=False), + help="Channel type.", +) +@click.option("--path_gain", default=0, help="Path gain in dB for Rayleigh channel.") +@click.option("--noise_power", multiple=True, default=[1e-5, 1e-4, 1e-3], help="Noise power for the AWGN channel.") +@click.option("--verbose", is_flag=True, help="Enable verbose output.") +def generate_signal( + num_samples, + num_bits, + num_bits_per_symbol, + modulation_list, + filter_type, + span_in_symbols, + samples_per_symbol, + beta, + channel_type, + path_gain, + noise_power, + verbose, +): + + now = datetime.now() + formatted_time = now.strftime("%Y%m%d_%H%M%S") + os.makedirs("recordings", exist_ok=True) + recordings_dir_name = os.path.join("recordings", f"recording_set_{formatted_time}") + os.makedirs(recordings_dir_name) + + if verbose: + click.echo(f"Output directory: {recordings_dir_name}") + click.echo("Starting signal generation...") + + for modulation in modulation_list: + if verbose: + click.echo(f"Processing modulation: {modulation}") + + f = _choose_filter(filter_type, span_in_symbols, samples_per_symbol, beta) + us = Upsampling(samples_per_symbol) + + if modulation in ["QAM", "PSK", "PAM"]: + mapper = Mapper(modulation, num_bits_per_symbol, normalize=True) + else: + raise ValueError("modulation must be QAM, PSK or PAM") + + if channel_type == "Rayleigh": + chan = FlatRayleigh(path_gain) + rx_noise = AWGNChannel() + elif channel_type == "AWGN": + chan = None + rx_noise = AWGNChannel() + else: + raise ValueError("channel_type must be Rayleigh or AWGN") + + for no in noise_power: + if verbose: + click.echo(f" Noise power: {np.round(10 * np.log10(no * 1000), 2)} dBm") + + metadata = { + "modulation": modulation, + "channel_type": channel_type, + "noise_power": no, + "filter_type": filter_type, + "span_in_symbols": span_in_symbols, + "samples_per_symbol": samples_per_symbol, + "roll_off_factor": beta, + } + if chan: + metadata["path_gain_db"] = path_gain + + rx_noise.var = no + bits = np.random.randint(0, 2, (num_samples, num_bits)) + symbols = mapper(bits) + sig = f(us(symbols)) + if chan: + sig_chan = rx_noise(chan(sig)) + else: + sig_chan = rx_noise(sig) + + total_samples_generated = 0 + + for i, sig_chan_sample in enumerate(sig_chan): + now = datetime.now() + formatted_time = now.strftime("%Y%m%d_%H%M%S") + file_name = f"{modulation}_{channel_type}_{filter_type}_{formatted_time}_{i}" + + recording = Recording(sig_chan_sample, metadata=metadata) + recording.to_npy(filename=file_name, path=recordings_dir_name) + total_samples_generated += 1 + + if verbose: + click.echo(f"Generated {total_samples_generated} recordings for {modulation} modulation.") + + +def _choose_filter(filter_type, span_in_symbols, samples_per_symbol, beta): + if filter_type == "RRC": + return RootRaisedCosineFilter(span_in_symbols, samples_per_symbol, beta) + elif filter_type == "RC": + return RaisedCosineFilter(span_in_symbols, samples_per_symbol, beta) + elif filter_type == "SINC": + return SincFilter(span_in_symbols, samples_per_symbol) + else: + raise ValueError("filter_type must be RRC or RC or Sinc") + + +if __name__ == "__main__": + generate_signal() diff --git a/src/ria_toolkit_oss/signal/block_generator/siso_channel/__init__.py b/src/ria_toolkit_oss/signal/block_generator/siso_channel/__init__.py new file mode 100644 index 0000000..1910c61 --- /dev/null +++ b/src/ria_toolkit_oss/signal/block_generator/siso_channel/__init__.py @@ -0,0 +1,30 @@ +""" +RIA Block-Based Signal Generator Module + +This module provides a flexible framework for simulating communication systems using configurable blocks. It includes: + +- Various block types: filters, mappers, modulators, demodulators, and channels +- Easy-to-use classes for creating custom signal processing chains +- Pre-configured generators for common use cases + +Key features: + +- Modular design for building complex systems +- Customizable block parameters +- Ready-to-use generators for quick prototyping + +Usage: + +1. Import desired blocks +2. Configure block parameters +3. Connect blocks to create a processing chain +4. Run simulations with custom or provided input signals + +For detailed examples and API reference, see the documentation. +""" + +from .awgn_channel import AWGNChannel +from .flat_rayleigh import FlatRayleigh +from .siso_channel import SISOChannel + +__all__ = [AWGNChannel, FlatRayleigh, SISOChannel] diff --git a/src/ria_toolkit_oss/signal/block_generator/siso_channel/awgn_channel.py b/src/ria_toolkit_oss/signal/block_generator/siso_channel/awgn_channel.py new file mode 100644 index 0000000..c9d2df6 --- /dev/null +++ b/src/ria_toolkit_oss/signal/block_generator/siso_channel/awgn_channel.py @@ -0,0 +1,61 @@ +from typing import Optional + +import numpy as np + +from ria_toolkit_oss.signal.block_generator.siso_channel.siso_channel import SISOChannel + + +class AWGNChannel(SISOChannel): + """ + Additive White Gaussian Noise (AWGN) channel class. + + :param var: The noise variance. + :type var: float + + Methods: + -------- + __call__(signal: np.ndarray) -> np.ndarray: + Adds AWGN to the input signal. + """ + + def __init__(self, var: Optional[float] = 0): + self._var = var + self.rng = np.random.default_rng() + + @property + def var(self) -> float: + """Get the noise variance.""" + return self._var + + @var.setter + def var(self, var: float) -> None: + """Set the noise variance.""" + self._var = var + + def __call__(self, samples: list[np.ndarray]) -> np.ndarray: + """ + Add AWGN to the input signal. + + :param samples: The input signal to be processed as a list containing a single numpy array. + :type samples: list[numpy array] + + :returns: The output signal with added noise. + :rtype: numpy array + + Example: + -------- + # Create an AWGN channel with variance 0.1 + awgn_channel = AWGN(0.1) + + # Original signal + signal = np.array([1+1j, 2+2j, 3+3j]) + + # Pass the signal through the AWGN channel + noisy_signal = awgn_channel(signal) + print(noisy_signal) + """ + signal = samples[0] + noise = np.sqrt(self._var / 2) * ( + self.rng.standard_normal(signal.shape) + 1j * self.rng.standard_normal(signal.shape) + ) + return signal + noise diff --git a/src/ria_toolkit_oss/signal/block_generator/siso_channel/flat_rayleigh.py b/src/ria_toolkit_oss/signal/block_generator/siso_channel/flat_rayleigh.py new file mode 100644 index 0000000..463f8d9 --- /dev/null +++ b/src/ria_toolkit_oss/signal/block_generator/siso_channel/flat_rayleigh.py @@ -0,0 +1,41 @@ +from typing import Optional + +import numpy as np + +from ria_toolkit_oss.signal.block_generator.siso_channel.siso_channel import SISOChannel + + +class FlatRayleigh(SISOChannel): + """ + Flat Rayleigh Fading Channel Block + + :param path_gain_db: The path gain in decibels, defaults to 0. + :type path_gain_db: float, optional + + Methods: + -------- + __call__(signal: np.ndarray) -> np.ndarray: + Applies the flat Rayleigh fading effect to the input signal. + """ + + def __init__(self, path_gain_db: Optional[float] = 0): + self.path_gain_db = path_gain_db + self.rng = np.random.default_rng() + + def __call__(self, samples: list[np.array]) -> np.ndarray: + """ + Applies the flat Rayleigh fading effect to the input signal. + + :param samples: The input signal to be processed, as a list containing 1 numpy array. + :type samples: numpy array + :return: The signal after being affected by the flat Rayleigh fading. + :rtype: numpy array + """ + signal = np.array(samples) + num_signals, sig_len = signal.shape + path_gain = 10 ** (self.path_gain_db / 10) + h = np.sqrt(path_gain / 2) * ( + self.rng.standard_normal((num_signals, 1)) + 1j * self.rng.standard_normal((num_signals, 1)) + ) + output = h * signal + return output[0] diff --git a/src/ria_toolkit_oss/signal/block_generator/siso_channel/siso_channel.py b/src/ria_toolkit_oss/signal/block_generator/siso_channel/siso_channel.py new file mode 100644 index 0000000..7022193 --- /dev/null +++ b/src/ria_toolkit_oss/signal/block_generator/siso_channel/siso_channel.py @@ -0,0 +1,54 @@ +from abc import abstractmethod + +import numpy as np + +from ria_toolkit_oss.signal.block_generator.data_types import DataType +from ria_toolkit_oss.signal.block_generator.process_block import ProcessBlock +from ria_toolkit_oss.signal.block_generator.recordable_block import RecordableBlock + + +class SISOChannel(ProcessBlock, RecordableBlock): + """ + Abstract base class for Single-Input Single-Output (SISO) communication channels. + + Methods: + -------- + __call__(signal: np.ndarray) -> np.ndarray: + Apply the channel effect to the input signal. + """ + + def __init__(self, input): + super().__init__(input=input) + + @property + def input_type(self) -> DataType: + """ + Get the input data type for the SISO channel. + + :return: The input data type. + :rtype: DataType + """ + return [DataType.BASEBAND_SIGNAL] + + @property + def output_type(self) -> DataType: + """ + Get the output data type for the SISO channel. + + :return: The output data type. + :rtype: DataType + """ + return DataType.BASEBAND_SIGNAL + + @abstractmethod + def __call__(self, signal: np.ndarray) -> np.ndarray: + """ + Apply the channel effect to the input signal. + + :param signal: The input signal to be processed by the channel. + :type signal: numpy array + + :returns: The output signal after applying the channel effect. + :rtype: numpy array + """ + raise NotImplementedError diff --git a/src/ria_toolkit_oss/signal/block_generator/source/__init__.py b/src/ria_toolkit_oss/signal/block_generator/source/__init__.py new file mode 100644 index 0000000..f59e66f --- /dev/null +++ b/src/ria_toolkit_oss/signal/block_generator/source/__init__.py @@ -0,0 +1,19 @@ +from .awgn_source import AWGNSource +from .binary_source import BinarySource +from .constant_source import ConstantSource +from .lfm_chirp_source import LFMChirpSource +from .recording_source import RecordingSource +from .sawtooth_source import SawtoothSource +from .sine_source import SineSource +from .square_source import SquareSource + +__all__ = [ + "AWGNSource", + "ConstantSource", + "LFMChirpSource", + "BinarySource", + "RecordingSource", + "SawtoothSource", + "SineSource", + "SquareSource", +] diff --git a/src/ria_toolkit_oss/signal/block_generator/source/awgn_source.py b/src/ria_toolkit_oss/signal/block_generator/source/awgn_source.py new file mode 100644 index 0000000..aebd61b --- /dev/null +++ b/src/ria_toolkit_oss/signal/block_generator/source/awgn_source.py @@ -0,0 +1,47 @@ +from typing import Optional + +import numpy as np + +from ria_toolkit_oss.signal.block_generator.data_types import DataType +from ria_toolkit_oss.signal.block_generator.recordable_block import RecordableBlock +from ria_toolkit_oss.signal.block_generator.source_block import SourceBlock + + +class AWGNSource(SourceBlock, RecordableBlock): + """ + AWGN Block + + Produces Additive White Gaussian Noise (AWGN) samples. + + Output Type: BASEBAND_SIGNAL + + :param variance: The variance of the AWGN. + :type variance: float + """ + + def __init__(self, variance: Optional[float] = 1): + self.input = [] + self.variance = variance + pass + + @property + def input_type(self): + return [DataType.NONE] + + @property + def output_type(self): + return DataType.BASEBAND_SIGNAL + + def __call__(self, num_samples: int): + """ + Create an array of complex noise samples. + + :param num_samples: The number of samples to return. + :type num_samples: int + + :returns: Output samples. + :rtype: np.array + """ + real = np.random.normal(loc=0, scale=np.sqrt(self.variance), size=num_samples) + imag = 1j * np.random.normal(loc=0, scale=np.sqrt(self.variance), size=num_samples) + return np.array(real + imag) diff --git a/src/ria_toolkit_oss/signal/block_generator/source/binary_source.py b/src/ria_toolkit_oss/signal/block_generator/source/binary_source.py new file mode 100644 index 0000000..f743f93 --- /dev/null +++ b/src/ria_toolkit_oss/signal/block_generator/source/binary_source.py @@ -0,0 +1,87 @@ +from pathlib import Path +from typing import Literal, Optional, Union + +import numpy as np + +from ria_toolkit_oss.signal.block_generator.data_types import DataType +from ria_toolkit_oss.signal.block_generator.source_block import SourceBlock + + +class BinarySource(SourceBlock): + """ + Generates bit sequences either randomly or from a file's raw bytes. + + - Random mode (default): uses `p` as the probability of generating a 0. + - File mode: if `file_path` is passed to __call__, the file is read as BYTES + and converted to bits using numpy.unpackbits (no assumption of '0'/'1' chars). + + Args: + p: Probability of outputting 0 in random mode (0..1). + rng: Optional numpy Generator to control randomness. + """ + + def __init__(self, p: float = 0.5, rng: Optional[np.random.Generator] = None): + self.p = float(p) + self.rng = rng if rng is not None else np.random.default_rng() + + @property + def input_type(self) -> DataType: + return [DataType.NONE] + + @property + def output_type(self) -> DataType: + return DataType.BITS + + def __call__( + self, + num_samples: int = 1, + num_bits: int = 1024, + file_path: Optional[Union[str, Path]] = None, + *, + cycle: bool = True, + bitorder: Literal["big", "little"] = "big", + ) -> np.ndarray: + """ + Generate binary sequences. + + Args: + num_samples: number of sequences (rows). + num_bits: bits per sequence (columns). + file_path: optional path to a file; if provided, read BYTES and convert to bits. + cycle: if True and requested bits exceed available, repeat from start. + bitorder: 'big' (MSB-first) or 'little' (LSB-first) for byte-to-bits conversion. + + Returns: + Array shape (num_samples, num_bits), dtype float32 with values {0.0, 1.0}. + """ + if file_path is None: + # Random mode: 0 with prob p, 1 with prob (1-p) + return (self.rng.random((num_samples, num_bits)) > self.p).astype(np.float32) + + # File mode: read raw bytes and unpack to bits + path = Path(file_path) + if not path.exists(): + raise FileNotFoundError(f"File not found: {path}") + + data = path.read_bytes() + if not data: + raise ValueError(f"File is empty: {path}") + + # Convert bytes -> bits (uint8 -> 8 bits each) + byte_arr = np.frombuffer(data, dtype=np.uint8) + bits_u8 = np.unpackbits(byte_arr, bitorder=bitorder) + file_bits = bits_u8.astype(np.float32) # {0., 1.} + + total_bits = num_samples * num_bits + if total_bits > file_bits.size: + if not cycle: + raise ValueError( + f"Requested {total_bits} bits, but file provides {file_bits.size}. " + f"Set cycle=True (default) to repeat." + ) + reps = int(np.ceil(total_bits / file_bits.size)) + out = np.tile(file_bits, reps)[:total_bits] + else: + out = file_bits[:total_bits] + + return out.reshape(num_samples, num_bits) diff --git a/src/ria_toolkit_oss/signal/block_generator/source/constant_source.py b/src/ria_toolkit_oss/signal/block_generator/source/constant_source.py new file mode 100644 index 0000000..b862668 --- /dev/null +++ b/src/ria_toolkit_oss/signal/block_generator/source/constant_source.py @@ -0,0 +1,43 @@ +from typing import Optional + +import numpy as np + +from ria_toolkit_oss.signal.block_generator.data_types import DataType +from ria_toolkit_oss.signal.block_generator.recordable_block import RecordableBlock +from ria_toolkit_oss.signal.block_generator.source_block import SourceBlock + + +class ConstantSource(SourceBlock, RecordableBlock): + """ + Constant Source Block + + Produces constant real samples and 0 imaginary samples. + + :param amplitude: The value of the real samples. + :type amplitude: float. + """ + + def __init__(self, amplitude: Optional[float] = 1): + + self.amplitude = amplitude + pass + + @property + def input_type(self): + return [DataType.NONE] + + @property + def output_type(self): + return DataType.BASEBAND_SIGNAL + + def __call__(self, num_samples): + """ + Create an array of constant value samples with 0 imaginary component. + + :param num_samples: The number of samples to return. + :type num_samples: int + + :returns: Output samples. + :rtype: np.array + """ + return np.ones(num_samples, dtype=np.complex64) * self.amplitude diff --git a/src/ria_toolkit_oss/signal/block_generator/source/lfm_chirp_source.py b/src/ria_toolkit_oss/signal/block_generator/source/lfm_chirp_source.py new file mode 100644 index 0000000..94ff69b --- /dev/null +++ b/src/ria_toolkit_oss/signal/block_generator/source/lfm_chirp_source.py @@ -0,0 +1,107 @@ +from typing import Optional + +import numpy as np +from scipy.signal import chirp + +from ria_toolkit_oss.signal.block_generator.data_types import DataType +from ria_toolkit_oss.signal.block_generator.recordable_block import RecordableBlock +from ria_toolkit_oss.signal.block_generator.source_block import SourceBlock + + +class LFMChirpSource(SourceBlock, RecordableBlock): + """ + LFM Chirp Source Block + + Produces Linear Frequency Modulation (LFM) Chirp signals. + + :param sample_rate: The sample rate. + :type sample_rate: float + :param bandwidth: The bandwidth of the chirp signal, must be < sample_rate/2. + :type bandwidth: float. + :param chirp_period: The chirp period in seconds. + :type period: float. + :param chirp_type: The direction (on a spectrogram) of the LFM chirps. + Options: 'up','down', or 'up_down', defaults to 'up'. + :type chirp_type: str.""" + + def __init__( + self, + sample_rate: Optional[float] = 1e6, + bandwidth: Optional[float] = 5e5, + chirp_period: Optional[float] = 0.01, + chirp_type: Optional[str] = "up", + ): + self.sample_rate = sample_rate + self.bandwidth = bandwidth + self.chirp_period = chirp_period + self.chirp_type = chirp_type + + @property + def input_type(self): + return [DataType.NONE] + + @property + def output_type(self): + return DataType.BASEBAND_SIGNAL + + def __call__(self, num_samples): + """ + Create an array of samples of an LFM signal with previously initialized parameters. + + :param num_samples: The number of samples to return. + :type num_samples: int + + :returns: Output samples. + :rtype: np.array + """ + chirp_length = int(self.chirp_period * self.sample_rate) + t_chirp = np.linspace(0, self.chirp_period, chirp_length) + + if len(t_chirp) > chirp_length: + t_chirp = t_chirp[:chirp_length] + + # Generate one chirp from 0 Hz to the full width + if self.chirp_type == "up": + baseband_chirp = chirp( + t_chirp, + f0=1000, + f1=self.bandwidth, + t1=self.chirp_period, + method="linear", + complex=True, + ) + elif self.chirp_type == "down": + baseband_chirp = chirp( + t_chirp, + f0=self.bandwidth, + f1=0, + t1=self.chirp_period, + method="linear", + complex=True, + ) + elif self.chirp_type == "up_down": + half_duration = self.chirp_period / 2 + t_up_half, t_down_half = np.array_split(t_chirp, 2) + + up_part = chirp( + t_up_half, + f0=0, + t1=half_duration, + f1=self.bandwidth, + method="linear", + complex=True, + ) + down_part = np.flip(up_part) + baseband_chirp = np.concatenate([up_part, down_part]) + + num_chirps = int(np.ceil(num_samples / chirp_length)) + full_signal = np.tile(baseband_chirp, num_chirps) + trimmed_signal = full_signal[:num_samples] + # Create an analytic signal (complex with no negative frequency components) + # Shift the chirp to the signal center frequency + total_time = num_samples / self.sample_rate + t_full = np.linspace(0, total_time, len(trimmed_signal)) + complex_chirp = trimmed_signal * np.exp(1j * 2 * np.pi * (0 - self.bandwidth / 2) * t_full) + if len(complex_chirp) != num_samples: + raise ValueError("LFMJammer did not produce the correct number of samples.") + return complex_chirp diff --git a/src/ria_toolkit_oss/signal/block_generator/source/recording_source.py b/src/ria_toolkit_oss/signal/block_generator/source/recording_source.py new file mode 100644 index 0000000..1b7795a --- /dev/null +++ b/src/ria_toolkit_oss/signal/block_generator/source/recording_source.py @@ -0,0 +1,47 @@ +from ria_toolkit_oss.datatypes import Recording +from ria_toolkit_oss.signal.block_generator.data_types import DataType +from ria_toolkit_oss.signal.block_generator.recordable_block import RecordableBlock +from ria_toolkit_oss.signal.block_generator.source_block import SourceBlock + + +class RecordingSource(SourceBlock, RecordableBlock): + """ + Recording Source Block + + Passes samples from the provided recording to downstream blocks. + + :param recording: The :ref:`Recording ` that provides samples. + :type recording: :ref:`Recording ` + + Warning: Only uses channel 0 of multi-channel recordings.""" + + def __init__(self, recording: Recording): + self.recording = recording + + @property + def input_type(self): + return [DataType.NONE] + + @property + def output_type(self): + return DataType.BASEBAND_SIGNAL + + def __call__(self, num_samples): + """ + Return the first num_samples samples of the recording, channel 0. + + :param num_samples: The number of samples to return. + :type num_samples: int + + :returns: Output samples. + :rtype: np.array + + :raises ValueError: If num_samples is greater than the recording length. + """ + if num_samples - 1 >= self.recording.data.shape[1]: + raise ValueError( + f"{num_samples} samples requested from recording source with \ + {self.recording.data.shape[1]} samples available." + ) + + return self.recording.data[0, 0:num_samples] diff --git a/src/ria_toolkit_oss/signal/block_generator/source/sawtooth_source.py b/src/ria_toolkit_oss/signal/block_generator/source/sawtooth_source.py new file mode 100644 index 0000000..d72abf5 --- /dev/null +++ b/src/ria_toolkit_oss/signal/block_generator/source/sawtooth_source.py @@ -0,0 +1,66 @@ +from typing import Optional + +import numpy as np +import scipy + +from ria_toolkit_oss.signal.block_generator.data_types import DataType +from ria_toolkit_oss.signal.block_generator.recordable_block import RecordableBlock +from ria_toolkit_oss.signal.block_generator.source_block import SourceBlock + + +class SawtoothSource(SourceBlock, RecordableBlock): + """ + Sawtooth Source Block + Creates a sawtooth signal real part and 0 imaginary part. + + :param frequency: The frequency of the saw wave. + :type frequency: float. + :param sample_rate: The sample rate. + :type sample_rate: float + :param amplitude: The maximum amplitude of the signal, defaults to 1. + :type amplitude: float. + :param phase_shift: The phase shift of the saw wave in radians + relative to the wave period. NOT a complex phase shift. + :type phase_shift: float. + """ + + def __init__( + self, + frequency: Optional[float] = 100e3, + sample_rate: Optional[float] = 1e6, + amplitude: Optional[float] = 1, + phase_shift: Optional[float] = 0, + ): + self.input = [] + self.frequency = frequency + self.amplitude = amplitude + self.sample_rate = sample_rate + self.phase_shift = phase_shift + pass + + @property + def input_type(self) -> DataType: + return [DataType.NONE] + + @property + def output_type(self): + return DataType.BASEBAND_SIGNAL + + def __call__(self, num_samples): + """ + Create a sawtooth signal. + + :param num_samples: The number of samples to return. + :type num_samples: int + + :returns: Output samples. + :rtype: np.array + """ + + t = np.arange(num_samples) + + saw_wave = self.amplitude * scipy.signal.sawtooth( + 2 * np.pi * self.frequency * (t / self.sample_rate - (self.phase_shift / (2 * np.pi))) + ) + saw_wave = np.array(saw_wave, dtype=np.complex64) + return saw_wave diff --git a/src/ria_toolkit_oss/signal/block_generator/source/sine_source.py b/src/ria_toolkit_oss/signal/block_generator/source/sine_source.py new file mode 100644 index 0000000..bcc50e5 --- /dev/null +++ b/src/ria_toolkit_oss/signal/block_generator/source/sine_source.py @@ -0,0 +1,64 @@ +from typing import Optional + +import numpy as np + +from ria_toolkit_oss.signal.block_generator.data_types import DataType +from ria_toolkit_oss.signal.block_generator.recordable_block import RecordableBlock +from ria_toolkit_oss.signal.block_generator.source_block import SourceBlock + + +class SineSource(SourceBlock, RecordableBlock): + """ + Sine Source Block + + Creates a sine signal with a sinusoidal real part and 0 imaginary part. + + :param frequency: The frequency of the sine wave. + :type frequency: float. + :param sample_rate: The sample rate. + :type sample_rate: float + :param amplitude: The maximum amplitude of the signal, defaults to 1. + :type amplitude: float. + :param phase_shift: The phase shift of the sine wave in radians + relative to the wave period. NOT a complex phase shift. + :type phase_shift: float. + """ + + def __init__( + self, + frequency: Optional[float] = 100e3, + sample_rate: Optional[float] = 1e6, + amplitude: Optional[float] = 1, + phase_shift: Optional[float] = 0, + ): + self.input = [] + self.frequency = frequency + self.amplitude = amplitude + self.sample_rate = sample_rate + self.phase_shift = phase_shift + pass + + @property + def input_type(self) -> DataType: + return [DataType.NONE] + + @property + def output_type(self): + return DataType.BASEBAND_SIGNAL + + def __call__(self, num_samples): + """ + Create a sine signal. + + :param num_samples: The number of samples to return. + :type num_samples: int + + :returns: Output samples. + :rtype: np.array + """ + + total_time = num_samples / self.sample_rate + t = np.linspace(0, total_time, num_samples, endpoint=False) + sine_wave = self.amplitude * np.sin(2 * np.pi * self.frequency * t + self.phase_shift) + sine_wave = np.array(sine_wave, dtype=np.complex64) + return sine_wave diff --git a/src/ria_toolkit_oss/signal/block_generator/source/square_source.py b/src/ria_toolkit_oss/signal/block_generator/source/square_source.py new file mode 100644 index 0000000..aaa0abc --- /dev/null +++ b/src/ria_toolkit_oss/signal/block_generator/source/square_source.py @@ -0,0 +1,70 @@ +from typing import Optional + +import numpy as np +import scipy + +from ria_toolkit_oss.signal.block_generator.data_types import DataType +from ria_toolkit_oss.signal.block_generator.recordable_block import RecordableBlock +from ria_toolkit_oss.signal.block_generator.source_block import SourceBlock + + +class SquareSource(RecordableBlock, SourceBlock): + """ + Square Source Block + + Creates a square wave signal with a square shaped real part and 0 imaginary part. + + :param frequency: The frequency of the square wave. + :type frequency: float. + :param sample_rate: The sample rate. + :type sample_rate: float + :param amplitude: The maximum amplitude of the signal, defaults to 1. + :type amplitude: float. + :param duty_cycle: The ratio of positive to negative values in single period. + :type duty_cycle: float + :param phase_shift: The phase shift of the sine wave in radians + relative to the wave period. NOT a complex phase shift. + :type phase_shift: float. + """ + + def __init__( + self, + frequency: Optional[float] = 100e3, + sample_rate: Optional[float] = 1e6, + amplitude: Optional[int] = 1, + duty_cycle: Optional[float] = 0.5, + phase_shift: Optional[float] = 0, + ): + self.input = [] + self.frequency = frequency + self.amplitude = amplitude + self.sample_rate = sample_rate + self.phase_shift = phase_shift + self.duty_cycle = duty_cycle + pass + + @property + def input_type(self): + return [DataType.NONE] + + @property + def output_type(self): + return DataType.BASEBAND_SIGNAL + + def __call__(self, num_samples): + """ + Create a square wave signal. + + :param num_samples: The number of samples to return. + :type num_samples: int + + :returns: Output samples. + :rtype: np.array + """ + t = np.arange(num_samples) + square_wave = self.amplitude * scipy.signal.square( + 2 * np.pi * self.frequency * (t / self.sample_rate - (self.phase_shift / (2 * np.pi))), + duty=self.duty_cycle, + ) + square_wave = np.array(square_wave, dtype=np.complex64) + return square_wave diff --git a/src/ria_toolkit_oss/signal/block_generator/source_block.py b/src/ria_toolkit_oss/signal/block_generator/source_block.py new file mode 100644 index 0000000..f19d690 --- /dev/null +++ b/src/ria_toolkit_oss/signal/block_generator/source_block.py @@ -0,0 +1,37 @@ +import json +from abc import ABC, abstractmethod + +from ria_toolkit_oss.signal.block_generator.block import Block + + +class SourceBlock(Block, ABC): + @abstractmethod + def __call__(self, num_samples: int): + """ + Create num_samples samples. + + :param num_samples: The number of samples to create. + :type num_samples: int""" + pass + + def get_samples(self, num_samples): + """ + Return num_samples samples from this source block. + + :param num_samples: The number of samples to return. + :type num_samples: int""" + + return self.__call__(num_samples=num_samples) + + def _get_metadata(self): + metadata = {} + for key, value in vars(self).items(): + try: + # Try to serialize the value to check if it's JSON serializable + json.dumps(value) + metadata[f"BlockGenerator:{self.__class__.__name__}:{key}"] = value + except (TypeError, ValueError): + # If the value is not JSON serializable, skip it + continue + + return metadata diff --git a/src/ria_toolkit_oss/signal/block_generator/symbol_modulation/__init__.py b/src/ria_toolkit_oss/signal/block_generator/symbol_modulation/__init__.py new file mode 100644 index 0000000..ed9f34b --- /dev/null +++ b/src/ria_toolkit_oss/signal/block_generator/symbol_modulation/__init__.py @@ -0,0 +1,5 @@ +from .gmsk_modulator import GMSKModulator +from .ook_modulator import OOKModulator +from .oqpsk_modulator import OQPSKModulator + +__all__ = ["GMSKModulator", "OOKModulator", "OQPSKModulator"] diff --git a/src/ria_toolkit_oss/signal/block_generator/symbol_modulation/gmsk_modulator.py b/src/ria_toolkit_oss/signal/block_generator/symbol_modulation/gmsk_modulator.py new file mode 100644 index 0000000..286691c --- /dev/null +++ b/src/ria_toolkit_oss/signal/block_generator/symbol_modulation/gmsk_modulator.py @@ -0,0 +1,65 @@ +import numpy as np + +from ria_toolkit_oss.signal.block_generator.block import Block +from ria_toolkit_oss.signal.block_generator.data_types import DataType +from ria_toolkit_oss.signal.block_generator.recordable_block import RecordableBlock + + +class GMSKModulator(RecordableBlock): + """Gaussian Minimum Shift Keying Modulator""" + + def __init__(self, input_block: Block, samples_per_symbol: int = 8, bt: float = 0.3): + self.input = [input_block] + self.sps = samples_per_symbol + self.bt = bt + + # Generate Gaussian filter + + # Let's use a simplified approximation or standard formula + sigma = np.sqrt(np.log(2)) / (2 * np.pi * self.bt) + # t is normalized by T (symbol period) + t_norm = np.arange(-4 * self.sps, 4 * self.sps + 1) / self.sps + + # Gaussian shape + g = (1 / (np.sqrt(2 * np.pi) * sigma)) * np.exp(-(t_norm**2) / (2 * sigma**2)) + # Normalize area to 0.5 (pulse area for MSK is 0.5) + g = g / np.sum(g) * 0.5 + self.pulse = g + + @property + def input_type(self) -> DataType: + return [DataType.BITS] + + @property + def output_type(self) -> DataType: + return DataType.BASEBAND_SIGNAL + + def get_samples(self, num_samples: int): + # Samples needed + num_symbols = int(np.ceil(num_samples / self.sps)) + bits = self.input[0].get_samples(num_symbols) + + # NRZ: 0->-1, 1->1 + symbols = 2 * bits - 1 + + # Upsample (Impulse train) + upsampled = np.zeros(len(symbols) * self.sps) + upsampled[:: self.sps] = symbols + + # Convolve with Gaussian pulse -> Frequency + freq_signal = np.convolve(upsampled, self.pulse, mode="same") + + # Integrate Frequency -> Phase + # Phase = 2 * pi * integral(freq) + # Cumulative sum + phase = np.cumsum(freq_signal) * np.pi # scale factor? + # MSK index h=0.5. Pulse area is 0.5. + # phase(t) = 2*pi*h * integral(q(tau)) + # If pulse area is 0.5, total phase change per symbol is 0.5 * pi (90 deg). Correct for MSK. + + iq = np.exp(1j * phase) + + return iq[:num_samples] + + def __call__(self, num_samples): + return self.get_samples(num_samples=num_samples) diff --git a/src/ria_toolkit_oss/signal/block_generator/symbol_modulation/ook_modulator.py b/src/ria_toolkit_oss/signal/block_generator/symbol_modulation/ook_modulator.py new file mode 100644 index 0000000..a09a0ae --- /dev/null +++ b/src/ria_toolkit_oss/signal/block_generator/symbol_modulation/ook_modulator.py @@ -0,0 +1,40 @@ +import numpy as np + +from ria_toolkit_oss.signal.block_generator.block import Block +from ria_toolkit_oss.signal.block_generator.data_types import DataType +from ria_toolkit_oss.signal.block_generator.recordable_block import RecordableBlock + + +class OOKModulator(RecordableBlock): + """On-Off Keying Modulator""" + + def __init__(self, input_block: Block, samples_per_symbol: int = 8): + self.input = [input_block] + self.sps = samples_per_symbol + + @property + def input_type(self) -> DataType: + return [DataType.BITS] + + @property + def output_type(self) -> DataType: + return DataType.BASEBAND_SIGNAL + + def get_samples(self, num_samples: int): + # Needed bits = num_samples / sps + num_symbols = int(np.ceil(num_samples / self.sps)) + bits = self.input[0].get_samples(num_symbols) + + # Map 0 -> 0, 1 -> 1 + # Upsample + # Rectangular pulse shape (repeat) + # bits is array of 0.0 and 1.0 + + samples = np.repeat(bits, self.sps) + # Convert to complex + samples = samples.astype(np.complex64) + + return samples[:num_samples] + + def __call__(self, num_samples): + return self.get_samples(num_samples=num_samples) diff --git a/src/ria_toolkit_oss/signal/block_generator/symbol_modulation/oqpsk_modulator.py b/src/ria_toolkit_oss/signal/block_generator/symbol_modulation/oqpsk_modulator.py new file mode 100644 index 0000000..285a55c --- /dev/null +++ b/src/ria_toolkit_oss/signal/block_generator/symbol_modulation/oqpsk_modulator.py @@ -0,0 +1,70 @@ +import numpy as np + +from ria_toolkit_oss.signal.block_generator.block import Block +from ria_toolkit_oss.signal.block_generator.data_types import DataType +from ria_toolkit_oss.signal.block_generator.recordable_block import RecordableBlock + + +class OQPSKModulator(RecordableBlock): + """Offset QPSK Modulator""" + + def __init__(self, input_block: Block, samples_per_symbol: int = 8): + self.input = [input_block] + self.sps = samples_per_symbol + # QPSK: 2 bits per symbol + self.bps = 2 + + @property + def input_type(self) -> DataType: + return [DataType.BITS] + + @property + def output_type(self) -> DataType: + return DataType.BASEBAND_SIGNAL + + def get_samples(self, num_samples: int): + # Need enough bits. 1 sample comes from 1 symbol? No, sps. + # total symbols = num_samples / sps + # total bits = total symbols * 2 + num_symbols = int(np.ceil(num_samples / self.sps)) + num_bits = num_symbols * 2 + + bits = self.input[0].get_samples(num_bits) + + # Reshape to (N, 2) + # Even bits -> I, Odd bits -> Q + i_bits = bits[0::2] + q_bits = bits[1::2] + + # Map 0->-1, 1->1 + i_syms = 2 * i_bits - 1 + q_syms = 2 * q_bits - 1 + + # Upsample (Rectangular pulse for now, or should we use RRC?) + # OQPSK usually implies pulse shaping, often RRC or Half-Sine. + # User requested "OQPSK". Standard OQPSK often has rectangular or shaped pulses. + # The prototype used "2*bits-1" and "roll". + # We will implement rectangular pulse OQPSK (staggered). + + i_samples = np.repeat(i_syms, self.sps) + q_samples = np.repeat(q_syms, self.sps) + + # Offset Q channel by T_sym / 2 (half symbol) + offset = self.sps // 2 + + # Pad I with offset zeros at start? Or pad Q? + # Delay Q by half symbol. + # Prepend offset zeros to Q, append offset zeros to I to match length? + # To keep alignment simple for streaming, we just roll/shift. + + q_samples_delayed = np.roll(q_samples, offset) + # Zero out the wrap-around part if non-circular? + q_samples_delayed[:offset] = 0 # Initialize + + # Complex sum + iq = i_samples + 1j * q_samples_delayed + + return iq[:num_samples] + + def __call__(self, num_samples): + return self.get_samples(num_samples=num_samples) diff --git a/src/ria_toolkit_oss/signal/recordable.py b/src/ria_toolkit_oss/signal/recordable.py new file mode 100644 index 0000000..d9c77f0 --- /dev/null +++ b/src/ria_toolkit_oss/signal/recordable.py @@ -0,0 +1,17 @@ +from abc import ABC, abstractmethod + +from ria_toolkit_oss.datatypes import Recording + + +class Recordable(ABC): + """Base class for all recordables, including SDRs and synthetic signal generators, that produce ``Recording`` + objects. + """ + + @abstractmethod + def record(self, *args, **kwargs) -> Recording: + """Generate Recording object. + + :rtype: Recording + """ + pass