# -*- coding: utf-8 -*-
from __future__ import annotations
from typing_extensions import override
import numpy as np
from hermespy.core import Serializable, SerializationProcess, DeserializationProcess
__author__ = "André Noll Barreto"
__copyright__ = "Copyright 2024, Barkhausen Institut gGmbH"
__credits__ = ["André Barreto", "Jan Adler"]
__license__ = "AGPLv3"
__version__ = "1.5.0"
__maintainer__ = "Jan Adler"
__email__ = "jan.adler@barkhauseninstitut.org"
__status__ = "Prototype"
[docs]
class PskQamMapping(Serializable):
"""Implements the mapping of bits into complex numbers, following a PSK/QAM modulation.
Attributes:
modulation_order: size of modulation constellation.
bits_per_symbol: number of bits in modulation symbol.
soft_output:
if True, then soft output (LLR) will be provided. #
If False, then estimated bits are returned.
"""
mapping_available = [2, 4, 8, 16, 64, 64, 256]
"""List of available default constellations."""
mapping_available_pam = [2, 4, 8, 16]
"""List of available default constellations for PAM."""
_psk8_map = np.exp(
1j * np.array([0, 1 / 4, 3 / 4, 1 / 2, -1 / 4, -1 / 2, 1, -3 / 4]) * np.pi
) # gray-coded 8-PSK map
def __init__(
self,
modulation_order: int,
mapping: np.ndarray | None = None,
soft_output: bool = False,
is_complex: bool = True,
):
"""
Args:
modulation_order:
Number of points in the constellation. Must be a power of two.
mapping:
Vector with length `modulation_order` defining the mapping between bits
and modulation symbols. At each symbol, bits are input MSB first.
For example, with a 32-point constellation, the bit sequence 01101
corresponds to the decimal 13, and hence will be mapped to the
13-th element in 'mapping' vector.
It is optional for certain modulation orders, which are given in
PskQamMapping.mapping_available, and for which a default mapping is provided.
soft_output:
if True, then soft output (LLR) will be provided.
If False, then estimated bits (0 or 1) are returned.
is_complex:
if True, then complex modulation is considered (PSK/QAM),
if False, then real-valued modulation is considered (PAM)
"""
if modulation_order <= 0 or (modulation_order & (modulation_order - 1)) != 0:
raise ValueError("modulation_order must be a power of two")
if (
is_complex
and modulation_order not in PskQamMapping.mapping_available
and mapping is None
):
raise ValueError("constellation must be provided for this modulation order")
if (
not is_complex
and modulation_order not in PskQamMapping.mapping_available_pam
and mapping is None
):
raise ValueError("constellation must be provided for this modulation order")
if mapping is not None and mapping.size != modulation_order:
raise ValueError(
"mapping must have the same number of elements as the modulation order"
)
self.is_complex = is_complex
self.modulation_order = modulation_order
self.bits_per_symbol = int(np.log2(self.modulation_order))
self.mapping = mapping
if self.mapping is None and modulation_order == 8 and is_complex:
self.mapping = PskQamMapping._psk8_map
elif self.mapping is not None:
# normalize mapping
energy = np.mean(np.abs(self.mapping) ** 2)
self.mapping = mapping / np.sqrt(energy)
self.soft_output = soft_output
[docs]
def get_symbols(self, bits: np.ndarray) -> np.ndarray:
"""Calculates the complex numbers corresponding to the information in 'bits'.
Note:
The constellation is normalized, such that the mean symbol energy is unitary.
Args:
bits:
Vector with N elements,
corresponding to the bits to be modulated.
Returns:
Vector of N/log2(modulation_order) elements with modulated symbols.
"""
number_symbols = int(bits.size / self.bits_per_symbol)
# bits in rows, symbols in columns
bits = np.reshape(bits, (self.bits_per_symbol, number_symbols), order="F")
if self.mapping is not None:
# e.g. [8, 4, 2, 1]
power_of_2 = 2 ** np.arange(self.bits_per_symbol - 1, -1, -1)
idx = np.matmul(power_of_2, bits) # multiply to get symbol value
symbols = self.mapping[idx]
else:
# use 3GPP mapping for BPSK, QPSK, 16-,64- and 256-QAM (or PAM
# equivalent)
if self.modulation_order == 2:
# BPSK
symbols = self.generate_pam_symbol_3gpp(2, bits) + 1j * 0
elif self.modulation_order == 4 and self.is_complex:
# QPSK
real_part = self.generate_pam_symbol_3gpp(2, bits[0, :])
imag_part = self.generate_pam_symbol_3gpp(2, bits[1, :])
symbols = (real_part + 1j * imag_part) / np.sqrt(2)
elif self.modulation_order == 4 and not self.is_complex:
# 4-PAM
symbols = self.generate_pam_symbol_3gpp(4, bits) / np.sqrt(5) + 1j * 0
elif self.modulation_order == 8 and not self.is_complex:
# 8-PAM
symbols = self.generate_pam_symbol_3gpp(8, bits) / np.sqrt(21) + 1j * 0
elif self.modulation_order == 16 and self.is_complex:
# 16-QAM
real_part = self.generate_pam_symbol_3gpp(4, bits[[0, 2], :])
imag_part = self.generate_pam_symbol_3gpp(4, bits[[1, 3], :])
symbols = (real_part + 1j * imag_part) / np.sqrt(10)
elif self.modulation_order == 16 and not self.is_complex:
# 16-PAM
symbols = self.generate_pam_symbol_3gpp(16, bits) / np.sqrt(85) + 1j * 0
elif self.modulation_order == 64 and self.is_complex:
real_part = self.generate_pam_symbol_3gpp(8, bits[[0, 2, 4], :])
imag_part = self.generate_pam_symbol_3gpp(8, bits[[1, 3, 5], :])
symbols = (real_part + 1j * imag_part) / np.sqrt(42)
elif self.modulation_order == 256 and self.is_complex:
real_part = self.generate_pam_symbol_3gpp(16, bits[[0, 2, 4, 6], :])
imag_part = self.generate_pam_symbol_3gpp(16, bits[[1, 3, 5, 7], :])
symbols = (real_part + 1j * imag_part) / np.sqrt(170)
else:
raise RuntimeError(
f"Modulation ({self.modulation_order}-{'QAM' if self.is_complex else 'PAM'}) not supported"
)
return np.ravel(symbols)
[docs]
def detect_bits(
self, rx_symbols: np.ndarray, noise_variance: np.ndarray | float = 1.0
) -> np.ndarray:
"""Returns either bits or LLR for the provided symbols.
Args:
rx_symbols(numpy.ndarray):
Vector of N received symbols, for which the bits/LLR will be estimated
noise_variance:
vector with the noise variance in each received symbol. If a
scalar is given, then the same variance is assumed for all symbols.
This is only relevant if 'self.soft_output' is true.
Returns:
Vector of N * self.bits_per_symbol elements containing either the estimated
bits or the LLR values of each bit, depending on the value of 'self.soft_output'
"""
number_of_bits = rx_symbols.size * self.bits_per_symbol
llr = np.zeros(number_of_bits)
noise_variance = (
noise_variance * np.ones(rx_symbols.shape)
if isinstance(noise_variance, float)
else noise_variance
)
# set starting index of encoded symbol (MSB)
bits_idx = np.arange(0, number_of_bits, self.bits_per_symbol, dtype=int)
if self.mapping is not None:
if not self.soft_output:
# get closest point in constellation diagram
dist = np.abs(rx_symbols - self.mapping.reshape(-1, 1))
min_index = np.argmin(dist, axis=0)
for bit_offset in range(self.bits_per_symbol): # iterate from the MSB to the LSB
# calculate encoded value
power_of_2 = int(2 ** (self.bits_per_symbol - bit_offset - 1))
llr[bits_idx + bit_offset] = np.bitwise_and(min_index, power_of_2) > 0
llr = llr * 2 - 1
else:
raise RuntimeError(
"Soft output demodulation not implemented for custom constellation"
)
# use 3GPP mapping for BPSK, QPSK, 16-,64- and 256-QAM
elif self.modulation_order == 2:
# BPSK
llr = self.get_llr_3gpp(2, np.real(rx_symbols), noise_variance, False)
elif self.modulation_order == 4 and self.is_complex:
# QPSK
llr[0::2] = self.get_llr_3gpp(2, np.real(rx_symbols), noise_variance, True)
llr[1::2] = self.get_llr_3gpp(2, np.imag(rx_symbols), noise_variance, True)
elif self.modulation_order == 4 and not self.is_complex:
# 4-PAM
llr = self.get_llr_3gpp(4, np.real(rx_symbols), noise_variance, False)
elif self.modulation_order == 8 and not self.is_complex:
# 8-PAM
llr = self.get_llr_3gpp(8, np.real(rx_symbols), noise_variance, False)
elif self.modulation_order == 16 and not self.is_complex:
# 16-PAM
llr = self.get_llr_3gpp(16, np.real(rx_symbols), noise_variance, False)
elif self.modulation_order == 16 and self.is_complex:
# 16-QAM
llr[0::2] = self.get_llr_3gpp(4, np.real(rx_symbols), noise_variance, True)
llr[1::2] = self.get_llr_3gpp(4, np.imag(rx_symbols), noise_variance, True)
elif self.modulation_order == 64 and self.is_complex:
# 64-QAM
llr[0::2] = self.get_llr_3gpp(8, np.real(rx_symbols), noise_variance, True)
llr[1::2] = self.get_llr_3gpp(8, np.imag(rx_symbols), noise_variance, True)
elif self.modulation_order == 256 and self.is_complex:
# 256-QAM
llr[0::2] = self.get_llr_3gpp(16, np.real(rx_symbols), noise_variance, True)
llr[1::2] = self.get_llr_3gpp(16, np.imag(rx_symbols), noise_variance, True)
else:
raise RuntimeError("Unsupported modulation scheme")
# Return finished bit stream either as soft or hard detections
return llr if self.soft_output else llr > 0
[docs]
@staticmethod
def generate_pam_symbol_3gpp(modulation_order: int, bits: np.ndarray) -> np.ndarray:
"""Returns 1D amplitudes following 3GPP modulation mapping.
3GPP has defined in TS 36.211 mapping tables from bits into complex symbols.
Since the mapping from bits into amplitudes is the same for both I and Q components,
and this function maps blocks of N bits into one of M=2^N possible (real-valued)
amplitudes.
Args:
modulation_order: modulation order M. M=2, 4, 8, 16 are supported
bits: N x K array, with K the number of symbols
Returns:
Vector of K real-valued symbols. Note that the symbols are not normalized,
and range from -(M-1) to (M+1) with step 2, e..g., for M=8,
values can be -7, -5, -3, -1, 1, 3, 5, 7.
"""
if modulation_order == 2:
symbols = 1.0 - 2 * bits
elif modulation_order == 4:
symbols = (1 - 2 * bits[0, :]) * (1 + 2 * bits[1, :])
elif modulation_order == 8:
symbols = (2 * bits[0, :] - 1) * ((1 - 2 * bits[1, :]) * (1 + 2 * bits[2, :]) - 4)
elif modulation_order == 16:
symbols = (
(((1 - 2 * bits[2, :]) * (1 + 2 * bits[3, :]) - 4) * (-1 + 2 * bits[1, :])) - 8
) * (-1 + 2 * bits[0, :])
else:
raise ValueError(f"unsupported modulation order ({modulation_order})")
return symbols
[docs]
@staticmethod
def get_llr_3gpp(
modulation_order: int, rx_symbols: np.ndarray, noise_variance: np.ndarray, is_complex: bool
) -> np.ndarray:
"""Returns LLR for each bit based on a received symbol, following 1D 3GPP modulation mapping.
3GPP has defined in TS 36.211 mapping tables from bits into complex symbols.
Since the mapping from bits into amplitudes is the same for both I and Q
components, and this function maps received real-valued amplitudes into
blocks of N = log2(M) log-likelihood ratios (LLR) for all bits, with M the modulation
order.
Only linear approximation of LLR is considered, similar to the one described in:
Tosato, Bisaglia, "Simplified Soft-Output Demapper for Binary Interleaved COFDM with
Application to HIPERLAN/2", Proceedings of IEEE International Commun. Conf. (ICC) 2002
LLR calculation is available for real-valued modulations of order 2, 4, 8 or 16.
LLR is returned considering unit-power Gaussian noise at all symbols.
Args:
modulation_order: modulation order M. M=2, 4, 8, 16 are supported
rx_symbols: array with K received symbols
noise_variance: float or array with noise varaiance of K symbols
is_complex:
if True, then complex modulation is considered (PSK/QAM),
If False, then real-valued modulation is considered (PAM)
Returns: Vector of N x K elements with the LLR values.
"""
if is_complex:
rx_symbols = rx_symbols * np.sqrt(2)
if modulation_order == 2:
llr = -2 * rx_symbols / noise_variance
elif modulation_order == 4:
llr = np.zeros([2, rx_symbols.size])
rx_symbols = rx_symbols * np.sqrt(5)
llr[0, :] = (
2
* (
(rx_symbols <= -2) * (-4 * (1 + rx_symbols))
+ np.logical_and(rx_symbols > -2, rx_symbols <= 2) * (-2 * rx_symbols)
+ (rx_symbols > 2) * (4 * (1 - rx_symbols))
)
/ noise_variance
)
llr[1, :] = (
2
* (
(rx_symbols <= 0) * (-2 * (2 + rx_symbols))
+ (rx_symbols > 0) * (-2 * (2 - rx_symbols))
)
/ noise_variance
)
llr = llr / 5
elif modulation_order == 8:
llr = np.zeros([3, rx_symbols.size])
rx_symbols = rx_symbols * np.sqrt(21)
llr[0, :] = (
4
* (
(rx_symbols <= -6) * (-4 * (3 + rx_symbols))
+ np.bitwise_and(rx_symbols > -6, rx_symbols <= -4) * (-3 * (2 + rx_symbols))
+ np.bitwise_and(rx_symbols > -4, rx_symbols <= -2) * (-2 * (1 + rx_symbols))
+ np.bitwise_and(rx_symbols > -2, rx_symbols <= 2) * (-rx_symbols)
+ np.bitwise_and(rx_symbols > 2, rx_symbols <= 4) * (2 * (1 - rx_symbols))
+ np.bitwise_and(rx_symbols > 4, rx_symbols <= 6) * (3 * (2 - rx_symbols))
+ (rx_symbols > 6) * (4 * (3 - rx_symbols))
)
/ noise_variance
)
llr[1, :] = (
4
* (
(rx_symbols <= -6) * (-2 * (5 + rx_symbols))
+ np.bitwise_and(rx_symbols > -6, rx_symbols <= -2) * (-(4 + rx_symbols))
+ np.bitwise_and(rx_symbols > -2, rx_symbols <= 0) * (-2 * (3 + rx_symbols))
+ np.bitwise_and(rx_symbols > 0, rx_symbols <= 2) * (-2 * (3 - rx_symbols))
+ np.bitwise_and(rx_symbols > 2, rx_symbols <= 6) * (-(4 - rx_symbols))
+ (rx_symbols > 6) * (-2 * (5 - rx_symbols))
)
/ noise_variance
)
llr[2, :] = (
4
* (
(rx_symbols <= -4) * (-(6 + rx_symbols))
+ np.bitwise_and(rx_symbols > -4, rx_symbols <= 0) * (2 + rx_symbols)
+ np.bitwise_and(rx_symbols > 0, rx_symbols <= 4) * (2 - rx_symbols)
+ (rx_symbols > 4) * (-(6 - rx_symbols))
)
/ noise_variance
)
llr = llr / 21
elif modulation_order == 16:
llr = np.zeros([4, rx_symbols.size])
rx_symbols = rx_symbols * np.sqrt(85)
llr[0, :] = (
8
* (
(rx_symbols <= -14) * (-4 * (7 + rx_symbols))
- np.bitwise_and(rx_symbols > -14, rx_symbols <= -12) * 3.5 * (6 + rx_symbols)
- np.bitwise_and(rx_symbols > -12, rx_symbols <= -10) * 3 * (5 + rx_symbols)
- np.bitwise_and(rx_symbols > -10, rx_symbols <= -8) * 2.5 * (4 + rx_symbols)
- np.bitwise_and(rx_symbols > -8, rx_symbols <= -6) * 2 * (3 + rx_symbols)
- np.bitwise_and(rx_symbols > -6, rx_symbols <= -4) * 1.5 * (2 + rx_symbols)
- np.bitwise_and(rx_symbols > -4, rx_symbols <= -2) * (1 + rx_symbols)
- np.bitwise_and(rx_symbols > -2, rx_symbols <= 2) * 0.5 * rx_symbols
+ np.bitwise_and(rx_symbols > 2, rx_symbols <= 4) * (1 - rx_symbols)
+ np.bitwise_and(rx_symbols > 4, rx_symbols <= 6) * 1.5 * (2 - rx_symbols)
+ np.bitwise_and(rx_symbols > 6, rx_symbols <= 8) * 2 * (3 - rx_symbols)
+ np.bitwise_and(rx_symbols > 8, rx_symbols <= 10) * 2.5 * (4 - rx_symbols)
+ np.bitwise_and(rx_symbols > 10, rx_symbols <= 12) * 3 * (5 - rx_symbols)
+ np.bitwise_and(rx_symbols > 12, rx_symbols <= 14) * 3.5 * (6 - rx_symbols)
+ (rx_symbols > 14) * 4 * (7 - rx_symbols)
)
/ noise_variance
)
llr[1, :] = (
8
* (
(rx_symbols <= -14) * (-2 * (11 + rx_symbols))
- np.bitwise_and(rx_symbols > -14, rx_symbols <= -12) * 1.5 * (10 + rx_symbols)
- np.bitwise_and(rx_symbols > -12, rx_symbols <= -10) * (9 + rx_symbols)
- np.bitwise_and(rx_symbols > -10, rx_symbols <= -6) * 0.5 * (8 + rx_symbols)
- np.bitwise_and(rx_symbols > -6, rx_symbols <= -4) * (7 + rx_symbols)
- np.bitwise_and(rx_symbols > -4, rx_symbols <= -2) * 1.5 * (6 + rx_symbols)
- np.bitwise_and(rx_symbols > -2, rx_symbols <= 0) * 2 * (5 + rx_symbols)
- np.bitwise_and(rx_symbols > 0, rx_symbols <= 2) * 2 * (5 - rx_symbols)
- np.bitwise_and(rx_symbols > 2, rx_symbols <= 4) * 1.5 * (6 - rx_symbols)
- np.bitwise_and(rx_symbols > 4, rx_symbols <= 6) * (7 - rx_symbols)
- np.bitwise_and(rx_symbols > 6, rx_symbols <= 10) * 0.5 * (8 - rx_symbols)
- np.bitwise_and(rx_symbols > 10, rx_symbols <= 12) * (9 - rx_symbols)
- np.bitwise_and(rx_symbols > 12, rx_symbols <= 14) * 1.5 * (10 - rx_symbols)
- (rx_symbols > 14) * 2 * (11 - rx_symbols)
)
/ noise_variance
)
llr[2, :] = (
8
* (
(rx_symbols <= -14) * (-13 - rx_symbols)
- np.bitwise_and(rx_symbols > -14, rx_symbols <= -10) * 0.5 * (12 + rx_symbols)
- np.bitwise_and(rx_symbols > -10, rx_symbols <= -8) * (11 + rx_symbols)
+ np.bitwise_and(rx_symbols > -8, rx_symbols <= -6) * (5 + rx_symbols)
+ np.bitwise_and(rx_symbols > -6, rx_symbols <= -2) * 0.5 * (4 + rx_symbols)
+ np.bitwise_and(rx_symbols > -2, rx_symbols <= 0) * (3 + rx_symbols)
+ np.bitwise_and(rx_symbols > 0, rx_symbols <= 2) * (3 - rx_symbols)
+ np.bitwise_and(rx_symbols > 2, rx_symbols <= 6) * 0.5 * (4 - rx_symbols)
+ np.bitwise_and(rx_symbols > 6, rx_symbols <= 8) * (5 - rx_symbols)
- np.bitwise_and(rx_symbols > 8, rx_symbols <= 10) * (11 - rx_symbols)
- np.bitwise_and(rx_symbols > 10, rx_symbols <= 14) * 0.5 * (12 - rx_symbols)
- (rx_symbols > 14) * (13 - rx_symbols)
)
/ noise_variance
)
llr[3, :] = (
8
* (
(rx_symbols <= -12) * (-0.5 * (14 + rx_symbols))
+ np.bitwise_and(rx_symbols > -12, rx_symbols <= -8) * 0.5 * (10 + rx_symbols)
- np.bitwise_and(rx_symbols > -8, rx_symbols <= -4) * 0.5 * (6 + rx_symbols)
+ np.bitwise_and(rx_symbols > -4, rx_symbols <= 0) * 0.5 * (2 + rx_symbols)
+ np.bitwise_and(rx_symbols > 0, rx_symbols <= 4) * 0.5 * (2 - rx_symbols)
- np.bitwise_and(rx_symbols > 4, rx_symbols <= 8) * 0.5 * (6 - rx_symbols)
+ np.bitwise_and(rx_symbols > 8, rx_symbols <= 12) * 0.5 * (10 - rx_symbols)
- (rx_symbols > 12) * 0.5 * (14 - rx_symbols)
)
/ noise_variance
)
llr = llr / 85
else:
raise ValueError(f"unsupported modulation order ({modulation_order})")
if is_complex:
llr = llr / 2
return llr.ravel("F")
[docs]
def get_mapping(self) -> np.ndarray:
"""Returns current mapping table
Returns:
array with M `modulation_order` elements containing all possible modulation symbols.
See specifications in "PskQamMapping.__init__"
"""
if self.mapping is not None:
mapping = self.mapping
else:
bits_all = np.zeros(self.modulation_order * self.bits_per_symbol)
for symbol_idx in range(self.modulation_order):
idx = symbol_idx * self.bits_per_symbol
bits = np.asarray(
[
1 if symbol_idx & (1 << (self.bits_per_symbol - 1 - n)) else 0
for n in range(self.bits_per_symbol)
]
)
bits_all[idx : idx + self.bits_per_symbol] = bits
mapping = self.get_symbols(bits_all)
return mapping
[docs]
@override
def serialize(self, process: SerializationProcess) -> None:
process.serialize_integer(self.modulation_order, "modulation_order")
if self.mapping is not None:
process.serialize_array(self.mapping, "mapping")
[docs]
@classmethod
@override
def Deserialize(cls, process: DeserializationProcess) -> PskQamMapping:
return cls(
process.deserialize_integer("modulation_order"),
process.deserialize_array("mapping", np.complex128, None),
)