diff --git a/const.py b/const.py new file mode 100644 index 0000000..60a899f --- /dev/null +++ b/const.py @@ -0,0 +1,148 @@ +from functools import cache +import numpy as np +import const_utils + +# https://www.ieee802.org/3/bn/public/nov13/prodan_3bn_02_1113.pdf + +@cache +def gray_1d(k, label): + const_utils.gray_1d_input_validation(k, label) + # special case + if k == 1: + return 1 if label==0 else -1 + + # all other cases -> recurse + b0, new_symbol = const_utils.next_symbol(k, label) + return (1-2*b0)*(2**(k-1)+gray_1d(k-1, new_symbol)) + + +def gray_2d(n, m, label): + const_utils.gray_2d_input_validation(n, m, label) + # n or m is 0 + if (coord:=const_utils.gray_2d_handle_1d(n, m, label)) is not None: + return coord + + # all other cases + symbol_i, symbol_q = const_utils.split_symbol(n, m, label) + return (gray_1d(n, symbol_i), gray_1d(m, symbol_q)) + +def hamming_dist(a, b): + if not isinstance(a, int): + raise ValueError('a must be an integer') + if not isinstance(b, int): + raise ValueError('b must be an integer') + +def euclidean_distance(coord1, coord2): + if isinstance(coord1, int): + return abs(coord1 - coord2) + return np.sqrt((coord1[0] - coord2[0])**2 + (coord1[1] - coord2[1])**2) + +def find_nearest(coord, coords): + min_distance = float('inf') + nearest_symbols = [] + + for c in coords: + dist = euclidean_distance(coord, c) + if dist == 0: + continue + elif dist < min_distance: + min_distance = dist + nearest_symbols = [c] + elif dist == min_distance: + nearest_symbols.append(c) + # else: + # pass + + return nearest_symbols + +def gray_penalty(constellation): + # constellation: {label_0:coordinate_0, label_1:coordinate_1, .., label_2^n-1:coordinate_2^n-1} + + # 2^n-QAM -> 2^n symbols S_i, where i=0,1,..2^n-1, ex. S_0 = (-3,-3) or S_0 = -2 + # N(S_i): set of (euclidean) nearest symbols S_j -> N((-3,-3)) = {(-3,-2), (-3,-4), (-2,-3), (-4,-3)} + # |N(S_i)|: size of set N(S_i) + # l(S): label given by mapping -> inverse of gray_Qd -> generate all symbols/labels for given constellation + # wt(l_1, l_2), hamming distance btw. two labels + + t = len(constellation) + inverted_constellation = {tuple(symbol):label for label,symbol in constellation.items() if label != 'meta'} # -> invert constellation dict + syms = [symbol for _, symbol in constellation.items()] + + if (n:=np.log2(t)) != int(n): + raise ValueError('only constellations with 2^n points supported') + + G = 0 + for li, si in constellation.items(): + N = find_nearest(si, syms) + size_N = len(N) + wt = sum(hamming_dist(inverted_constellation[tuple(sj)], li) for sj in N) + G += wt/size_N + G /= t + + return G + +def find_rows_columns(coordinates): + if not coordinates: + return 0, 0 + + min_row = min(coord[0] for coord in coordinates.values()) + max_row = max(coord[0] for coord in coordinates.values()) + row_spacing = abs(coordinates[next(iter(coordinates))][0] - coordinates[next(iter(coordinates))][0]) + + min_col = min(coord[1] for coord in coordinates.values()) + max_col = max(coord[1] for coord in coordinates.values()) + col_spacing = abs(coordinates[next(iter(coordinates))][1] - coordinates[next(iter(coordinates))][1]) + + num_rows = (max_row - min_row) // row_spacing + 1 + num_cols = (max_col - min_col) // col_spacing + 1 + + return num_rows, num_cols + +def transform_rectangular_mapping(constellation): + n, m = find_rows_columns(constellation) + + # example: 32-qam -> 2^(2n+1) -> n = 2 + + two_n1 = np.log2(len(constellation)) + if int(two_n1) != two_n1: + raise ValueError('only constellations with 2^m points allowed') + + if n == 1 or m == 1: # 1D-constellation + return constellation + + n = c/2 + m = r/2 + + const_utils._validate_integer(n, 'n') + const_utils._validate_integer(m, 'm') + + if n == m: # square 2^(2n)-QAM + return constellation + + if n == 2 and m == 1: # rectangular 8-QAM (4*2) + return transform_8QAM(constellation) + elif n == m+2: + new_const = {} + s = 2**(n-1) + for label, symbol in constellation.items(): + + + +def transform_8QAM(constellation): + new_const = {} + for label, symbol in constellation.items(): + if symbol[0] < 3: + new_const[label] = symbol + else: + i_rct, q_rct = symbol + i_cr = -np.sign(i_rct)*(4-np.abs(i_rct)) + q_cr = np.sign(q_rct)*(np.abs(q_rct)+2) + new_const[label] = [i_cr, q_cr] + return new_const# rectangular 2^(m+n)-QAM + + + +if __name__ == '__main__': + # print(gray_1d(2, 0)) + print(gray_2d(2, 3, 4)) + print(gray_2d(0, 2, 4)) diff --git a/const_test.py b/const_test.py new file mode 100644 index 0000000..d86a966 --- /dev/null +++ b/const_test.py @@ -0,0 +1,67 @@ +import pytest + + + +from const import gray_1d + +# Happy path tests with various realistic test values +@pytest.mark.parametrize("k, symbol, expected", [ + (0, 0, 0), # k=0 case + (1, 0, 1), # k=1, symbol=0 case + (1, 1, -1), # k=1, symbol=1 case + (2, 0, 3), # k=2, symbol=0 case + (2, 1, 1), # k=2, symbol=1 case + (2, 2, -3), # k=2, symbol=2 case + (2, 3, -1), # k=2, symbol=3 case +], ids=[ + "k=0_symbol=0", + "k=1_symbol=0", + "k=1_symbol=1", + "k=2_symbol=0", + "k=2_symbol=1", + "k=2_symbol=2", + "k=2_symbol=3" +]) +def test_gray_1d_happy_path(k, symbol, expected): + # Act + result = gray_1d(k, symbol) + + # Assert + assert result == expected + +# Various edge cases +@pytest.mark.parametrize("k, symbol, expected", [ + (3, 0, 7), # k=3, symbol=0 case + (3, 7, -3), # k=3, symbol=7 case + (4, 0, 15), # k=4, symbol=0 case + (4, 15, -5), # k=4, symbol=15 case +], ids=[ + "k=3_symbol=0", + "k=3_symbol=7", + "k=4_symbol=0", + "k=4_symbol=15" +]) +def test_gray_1d_edge_cases(k, symbol, expected): + # Act + result = gray_1d(k, symbol) + + # Assert + assert result == expected + +# Various error cases +@pytest.mark.parametrize("k, symbol, exception", [ + (-1, 0, ValueError), # k is negative + (1, -1, ValueError), # symbol is negative + (1, 2, ValueError), # symbol is out of range for k=1 + (2, 4, ValueError), # symbol is out of range for k=2 +], ids=[ + "k_negative", + "symbol_negative", + "symbol_out_of_range_k1", + "symbol_out_of_range_k2" +]) +def test_gray_1d_error_cases(k, symbol, exception): + # Act and Assert + with pytest.raises(exception): + gray_1d(k, symbol) + diff --git a/const_utils.py b/const_utils.py new file mode 100644 index 0000000..76ad1dc --- /dev/null +++ b/const_utils.py @@ -0,0 +1,52 @@ +from const import gray_1d + + +def _validate_integer(value, name): + if not isinstance(value, int): + raise ValueError(f'{name} must be an integer') + + +def _validate_range(value, name, min_val=None, max_val=None): + if max_val is not None and value > max_val: + raise ValueError(f'{name} must be \u2265 {max_val}') + if min_val is not None and value < min_val: + raise ValueError(f'{name} must be \u2264 {min_val}') + + +def gray_1d_input_validation(k, symbol): + _validate_integer(symbol, 'symbol') + _validate_integer(k, 'k') + _validate_range(k, 'k', min_val=1) + _validate_range(symbol, 'symbol', min_val=0, max_val=2**k-1) + + +def next_symbol(k, symbol): + bits = format(symbol, 'b').zfill(k) + b0 = int(bits[0]) + new_symbol = int(bits[1:], 2) + return b0,new_symbol + + +def gray_2d_input_validation(n, m, symbol): + _validate_integer(n, 'n') + _validate_integer(m, 'm') + _validate_integer(symbol, 'symbol') + _validate_range(n, 'n', min_val=0) + min_m = 0 if n > 0 else 1 + _validate_range(m, 'm', min_val=min_m) + _validate_range(symbol, 'symbol', min_val=0, max_val=2**(m+n)-1) + + +def gray_2d_handle_1d(n, m, symbol): + n,m,swapped = m,n,True if n == 0 else n,m,False # swap n and m if n is zero -> if only one > 0, it's n + if m == 0: + return (gray_1d(n, symbol),None) if swapped else (None,gray_1d(n, symbol)) + else: + return None + + +def split_symbol(n, m, symbol): + bits = format(symbol, 'b').zfill(n+m) + symbol_i = int(bits[:n], 2) + symbol_q = int(bits[n:], 2) + return symbol_i,symbol_q \ No newline at end of file