cli #15

Merged
madrigal merged 28 commits from cli into main 2025-12-22 10:42:57 -05:00
76 changed files with 5593 additions and 7 deletions
Showing only changes of commit cbd94c8fe0 - Show all commits

View File

@ -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)

View 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"]

View 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)

View 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()
```

View 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",
]

View File

@ -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"]

View 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]

View File

@ -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

View File

@ -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

View File

@ -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)

View 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

View File

@ -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} "
)

View File

@ -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
# ------------------------------------------------------------------ #
# frontend filter (same as transmitter) and matchedfilter 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 (M1), …, +(M1))
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)
# ------------------------------------------------------------------ #
# Precompute symbolrate 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 → matchedfilter (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 N1
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 partialresponse 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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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})"
)

View File

@ -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})"
)

View File

@ -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

View File

@ -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

View File

@ -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

View 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."""

View File

@ -0,0 +1,4 @@
from .downconversion import FrequencyDownConversion
from .upconversion import FrequencyUpConversion
__all__ = ["FrequencyUpConversion", "FrequencyDownConversion"]

View File

@ -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})"
)

View File

@ -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})"

View File

@ -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"]

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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}."
)

View 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)

View File

@ -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"]

View File

@ -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)

View File

@ -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()

View File

@ -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

View 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()

View File

@ -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

View File

@ -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

View 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

View File

@ -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]

View File

@ -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"]

View File

@ -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

View File

@ -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

View 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

View File

@ -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",
]

View File

@ -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})"
)

View File

@ -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()

View File

@ -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})"
)

View File

@ -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})"

View File

@ -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})"
)

View File

@ -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})"

View File

@ -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

View File

@ -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

View File

@ -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()

View File

@ -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]

View File

@ -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

View File

@ -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]

View File

@ -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

View File

@ -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",
]

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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]

View File

@ -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

View File

@ -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

View File

@ -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

View 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

View File

@ -0,0 +1,5 @@
from .gmsk_modulator import GMSKModulator
from .ook_modulator import OOKModulator
from .oqpsk_modulator import OQPSKModulator
__all__ = ["GMSKModulator", "OOKModulator", "OQPSKModulator"]

View File

@ -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)

View File

@ -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)

View File

@ -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)

View 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