cli #15
|
|
@ -5,9 +5,8 @@ from typing import Optional
|
||||||
|
|
||||||
import click
|
import click
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import yaml
|
|
||||||
|
|
||||||
import utils.signal.basic_signal_generator as basic_gen
|
import utils.signal.basic_signal_generator as basic_gen
|
||||||
|
import yaml
|
||||||
from utils.data import Recording
|
from utils.data import Recording
|
||||||
from utils.signal.block_gen.continuous_modulation.fsk_modulator import FSKModulator
|
from utils.signal.block_gen.continuous_modulation.fsk_modulator import FSKModulator
|
||||||
from utils.signal.block_generator.basic import FrequencyShift
|
from utils.signal.block_generator.basic import FrequencyShift
|
||||||
|
|
@ -26,7 +25,7 @@ from utils.signal.block_generator.pulse_shaping import (
|
||||||
Upsampling,
|
Upsampling,
|
||||||
)
|
)
|
||||||
from utils.signal.block_generator.source import (
|
from utils.signal.block_generator.source import (
|
||||||
LFMJammingSource,
|
LFMChirpSource,
|
||||||
RandomBinarySource,
|
RandomBinarySource,
|
||||||
RecordingSource,
|
RecordingSource,
|
||||||
SawtoothSource,
|
SawtoothSource,
|
||||||
|
|
@ -554,7 +553,7 @@ def chirp(
|
||||||
|
|
||||||
echo_progress(f"Generating {chirp_type} chirp ({format_frequency(bandwidth)}, {period}s)...", quiet)
|
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)
|
recording = source.record(ns)
|
||||||
|
|
||||||
|
|
|
||||||
7
src/ria_toolkit_oss/signal/__init__.py
Normal file
7
src/ria_toolkit_oss/signal/__init__.py
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
"""
|
||||||
|
The Signal Package provides a comprehensive suite of tools for signal generation and processing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .recordable import Recordable
|
||||||
|
|
||||||
|
__all__ = ["Recordable"]
|
||||||
398
src/ria_toolkit_oss/signal/basic_signal_generator.py
Normal file
398
src/ria_toolkit_oss/signal/basic_signal_generator.py
Normal file
|
|
@ -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)
|
||||||
63
src/ria_toolkit_oss/signal/block_generator/README.md
Normal file
63
src/ria_toolkit_oss/signal/block_generator/README.md
Normal file
|
|
@ -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()
|
||||||
|
```
|
||||||
88
src/ria_toolkit_oss/signal/block_generator/__init__.py
Normal file
88
src/ria_toolkit_oss/signal/block_generator/__init__.py
Normal file
|
|
@ -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",
|
||||||
|
]
|
||||||
|
|
@ -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"]
|
||||||
69
src/ria_toolkit_oss/signal/block_generator/basic/add.py
Normal file
69
src/ria_toolkit_oss/signal/block_generator/basic/add.py
Normal file
|
|
@ -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]
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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)
|
||||||
122
src/ria_toolkit_oss/signal/block_generator/block.py
Normal file
122
src/ria_toolkit_oss/signal/block_generator/block.py
Normal file
|
|
@ -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
|
||||||
|
|
@ -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} "
|
||||||
|
)
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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})"
|
||||||
|
)
|
||||||
|
|
@ -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})"
|
||||||
|
)
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
34
src/ria_toolkit_oss/signal/block_generator/data_types.py
Normal file
34
src/ria_toolkit_oss/signal/block_generator/data_types.py
Normal file
|
|
@ -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."""
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
from .downconversion import FrequencyDownConversion
|
||||||
|
from .upconversion import FrequencyUpConversion
|
||||||
|
|
||||||
|
__all__ = ["FrequencyUpConversion", "FrequencyDownConversion"]
|
||||||
|
|
@ -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})"
|
||||||
|
)
|
||||||
|
|
@ -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})"
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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}."
|
||||||
|
)
|
||||||
20
src/ria_toolkit_oss/signal/block_generator/io.py
Normal file
20
src/ria_toolkit_oss/signal/block_generator/io.py
Normal file
|
|
@ -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)
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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
|
||||||
159
src/ria_toolkit_oss/signal/block_generator/mapping/mapper.py
Normal file
159
src/ria_toolkit_oss/signal/block_generator/mapping/mapper.py
Normal file
|
|
@ -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()
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
119
src/ria_toolkit_oss/signal/block_generator/mapping/qam_mapper.py
Normal file
119
src/ria_toolkit_oss/signal/block_generator/mapping/qam_mapper.py
Normal file
|
|
@ -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
|
||||||
|
|
@ -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]
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
87
src/ria_toolkit_oss/signal/block_generator/process_block.py
Normal file
87
src/ria_toolkit_oss/signal/block_generator/process_block.py
Normal file
|
|
@ -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
|
||||||
|
|
@ -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",
|
||||||
|
]
|
||||||
|
|
@ -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})"
|
||||||
|
)
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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})"
|
||||||
|
)
|
||||||
|
|
@ -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})"
|
||||||
|
|
@ -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})"
|
||||||
|
)
|
||||||
|
|
@ -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})"
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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 <ria_toolkit_oss.data.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
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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]
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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]
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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",
|
||||||
|
]
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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 <ria_toolkit_oss.data.Recording>` that provides samples.
|
||||||
|
:type recording: :ref:`Recording <ria_toolkit_oss.data.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]
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
37
src/ria_toolkit_oss/signal/block_generator/source_block.py
Normal file
37
src/ria_toolkit_oss/signal/block_generator/source_block.py
Normal file
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
from .gmsk_modulator import GMSKModulator
|
||||||
|
from .ook_modulator import OOKModulator
|
||||||
|
from .oqpsk_modulator import OQPSKModulator
|
||||||
|
|
||||||
|
__all__ = ["GMSKModulator", "OOKModulator", "OQPSKModulator"]
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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)
|
||||||
17
src/ria_toolkit_oss/signal/recordable.py
Normal file
17
src/ria_toolkit_oss/signal/recordable.py
Normal file
|
|
@ -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
|
||||||
Loading…
Reference in New Issue
Block a user