commkit 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. commkit/__init__.py +74 -0
  2. commkit/_cuda/__init__.py +321 -0
  3. commkit/_cuda/compiler.py +88 -0
  4. commkit/_cuda/src/bps_min_d2.cu +104 -0
  5. commkit/_cuda/src/cs_block.cu +119 -0
  6. commkit/_cuda/src/selftest.cu +14 -0
  7. commkit/analysis/__init__.py +55 -0
  8. commkit/analysis/_common.py +236 -0
  9. commkit/analysis/allan.py +108 -0
  10. commkit/analysis/drift.py +213 -0
  11. commkit/analysis/interferometry.py +887 -0
  12. commkit/analysis/linewidth.py +480 -0
  13. commkit/analysis/trajectory.py +91 -0
  14. commkit/backend.py +507 -0
  15. commkit/coding/__init__.py +23 -0
  16. commkit/coding/base.py +17 -0
  17. commkit/coding/bch.py +6 -0
  18. commkit/coding/convolutional.py +7 -0
  19. commkit/coding/crc.py +7 -0
  20. commkit/coding/galois.py +8 -0
  21. commkit/coding/hamming.py +6 -0
  22. commkit/coding/interleaving.py +7 -0
  23. commkit/coding/ldpc.py +8 -0
  24. commkit/coding/polar.py +8 -0
  25. commkit/coding/ratematch.py +6 -0
  26. commkit/coding/reed_solomon.py +6 -0
  27. commkit/coding/turbo.py +8 -0
  28. commkit/core/__init__.py +32 -0
  29. commkit/core/frame.py +992 -0
  30. commkit/core/generation.py +581 -0
  31. commkit/core/signal.py +725 -0
  32. commkit/equalization/__init__.py +49 -0
  33. commkit/equalization/_block.py +1855 -0
  34. commkit/equalization/_common.py +606 -0
  35. commkit/equalization/_kernels_jax.py +1720 -0
  36. commkit/equalization/_kernels_numba.py +1704 -0
  37. commkit/equalization/blind.py +223 -0
  38. commkit/equalization/linear.py +365 -0
  39. commkit/equalization/polarization.py +790 -0
  40. commkit/equalization/result.py +191 -0
  41. commkit/equalization/sequential.py +2805 -0
  42. commkit/filtering.py +1120 -0
  43. commkit/frequency.py +1191 -0
  44. commkit/helpers.py +489 -0
  45. commkit/impairments/__init__.py +43 -0
  46. commkit/impairments/channel/__init__.py +20 -0
  47. commkit/impairments/channel/linear.py +310 -0
  48. commkit/impairments/channel/nonlinear.py +11 -0
  49. commkit/impairments/frontend.py +229 -0
  50. commkit/impairments/noise.py +105 -0
  51. commkit/impairments/source.py +219 -0
  52. commkit/io.py +308 -0
  53. commkit/logger.py +103 -0
  54. commkit/mapping/__init__.py +46 -0
  55. commkit/mapping/bits.py +240 -0
  56. commkit/mapping/constellation.py +153 -0
  57. commkit/mapping/gray.py +429 -0
  58. commkit/mapping/llr.py +253 -0
  59. commkit/mapping/shaping.py +218 -0
  60. commkit/metrics.py +949 -0
  61. commkit/multirate.py +476 -0
  62. commkit/plotting/__init__.py +78 -0
  63. commkit/plotting/analysis.py +627 -0
  64. commkit/plotting/constellation.py +483 -0
  65. commkit/plotting/equalizer.py +390 -0
  66. commkit/plotting/eye.py +388 -0
  67. commkit/plotting/spectral.py +575 -0
  68. commkit/plotting/sync.py +953 -0
  69. commkit/plotting/theme.py +203 -0
  70. commkit/plotting/waveform.py +200 -0
  71. commkit/py.typed +0 -0
  72. commkit/recovery/__init__.py +51 -0
  73. commkit/recovery/bps.py +337 -0
  74. commkit/recovery/corrections.py +751 -0
  75. commkit/recovery/pilots.py +803 -0
  76. commkit/recovery/pll.py +482 -0
  77. commkit/recovery/tikhonov.py +424 -0
  78. commkit/recovery/viterbi_viterbi.py +227 -0
  79. commkit/spectral.py +560 -0
  80. commkit/timing.py +841 -0
  81. commkit-1.0.0.dist-info/METADATA +145 -0
  82. commkit-1.0.0.dist-info/RECORD +84 -0
  83. commkit-1.0.0.dist-info/WHEEL +4 -0
  84. commkit-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,429 @@
1
+ """
2
+ Constellation geometry and Gray labelling.
3
+
4
+ Gray code sequences, their inverse, and the Gray-mapped constellation
5
+ generators for PSK / ASK / square-, 8-rect-, and cross-QAM. These are the
6
+ geometric foundation consumed by the bit mapping, soft demapping, and
7
+ probabilistic-shaping layers.
8
+
9
+ Note: codes and constellations are generated using NumPy (host-side).
10
+ """
11
+
12
+ from functools import lru_cache
13
+
14
+ import numpy as np
15
+
16
+ from ..logger import logger
17
+
18
+ __all__ = [
19
+ "gray_code",
20
+ "gray_constellation",
21
+ "gray_to_binary",
22
+ ]
23
+
24
+
25
+ @lru_cache(maxsize=128)
26
+ def gray_code(n: int) -> np.ndarray:
27
+ """
28
+ Generates a Gray code sequence for `n` bits.
29
+
30
+ Gray coding ensures that adjacent symbols in the constellation differ
31
+ by only one bit, minimizing bit error rate (BER) for a given symbol
32
+ error rate (SER).
33
+
34
+ Parameters
35
+ ----------
36
+ n : int
37
+ Number of bits.
38
+
39
+ Returns
40
+ -------
41
+ np.ndarray
42
+ Array of integers representing the Gray code sequence.
43
+ Shape: (2^n,).
44
+
45
+ Examples
46
+ --------
47
+ >>> gray_code(2)
48
+ array([0, 1, 3, 2])
49
+ """
50
+ if n < 0:
51
+ raise ValueError("n must be non-negative")
52
+ if n == 0:
53
+ return np.array([0], dtype=int)
54
+
55
+ # Vectorized implementation: s ^ (s >> 1)
56
+ i = np.arange(1 << n, dtype=int)
57
+
58
+ return i ^ (i >> 1)
59
+
60
+
61
+ @lru_cache(maxsize=128)
62
+ def gray_to_binary(n: int) -> np.ndarray:
63
+ """
64
+ Computes the inverse Gray code mapping: symbol index to bit pattern.
65
+
66
+ In soft demapping, it is necessary to identify which bits correspond to
67
+ each constellation point. Since `gray_code(n)[i]` provides the Gray
68
+ code for natural binary `i`, this inverse mapping finds the natural
69
+ binary representation corresponding to a specific Gray-coded index.
70
+
71
+ Parameters
72
+ ----------
73
+ n : int
74
+ Number of bits per symbol.
75
+
76
+ Returns
77
+ -------
78
+ np.ndarray
79
+ Array where `result[s]` provides the natural binary integer
80
+ representing the bit pattern for symbol `s`. Shape: (2^n,).
81
+ """
82
+ if n < 0:
83
+ raise ValueError("n must be non-negative")
84
+ if n == 0:
85
+ return np.array([0], dtype=int)
86
+
87
+ # gray_code gives: natural_binary -> gray_symbol
88
+ # We want: gray_symbol -> natural_binary (inverse)
89
+ gray = gray_code(n)
90
+ inverse = np.zeros(1 << n, dtype=int)
91
+ inverse[gray] = np.arange(1 << n)
92
+
93
+ return inverse
94
+
95
+
96
+ @lru_cache(maxsize=128)
97
+ def gray_constellation(
98
+ modulation: str,
99
+ order: int,
100
+ normalize: bool = True,
101
+ unipolar: bool = False,
102
+ ) -> np.ndarray:
103
+ """
104
+ Generate constellation points with Gray mapping.
105
+
106
+ The returned array is indexed by the symbol's natural binary value.
107
+ `constellation[i]` is the complex/float value for the symbol representing
108
+ the bit pattern for integer `i`.
109
+
110
+ Parameters
111
+ ----------
112
+ modulation : {"psk", "qam", "ask", "pam"}
113
+ Modulation type. If string contains 'unipolar', unipolar=True is triggered.
114
+ order : int
115
+ Modulation order (number of symbols). Must be a power of 2.
116
+ normalize : bool, default True
117
+ If True, scales the constellation to unit average power (E_s = 1).
118
+ unipolar : bool, default False
119
+ If True, shifts the constellation to be strictly non-negative (for 'ask'/'pam').
120
+ If the modulation string contains 'unipol', this is automatically forced to True.
121
+
122
+ Returns
123
+ -------
124
+ np.ndarray
125
+ Array of constellation points (NumPy). Shape: (order,).
126
+ Complex for 'psk' and 'qam', Float for 'ask'.
127
+
128
+ Notes
129
+ -----
130
+ By default, constellations are normalized to **unit average power (E_s = 1)**.
131
+ This ensures that when different modulation schemes are mixed (e.g., PSK
132
+ pilots with QAM payload), they start from a consistent power baseline.
133
+ """
134
+ logger.debug(
135
+ "Generating Gray-coded constellation: modulation=%s, order=%s, normalize=%s",
136
+ modulation,
137
+ order,
138
+ normalize,
139
+ )
140
+ modulation = modulation.lower()
141
+
142
+ # Extract core modulation scheme
143
+ if "psk" in modulation:
144
+ modulation = "psk"
145
+ elif "qam" in modulation:
146
+ modulation = "qam"
147
+ elif "ask" in modulation or "pam" in modulation:
148
+ modulation = "ask"
149
+
150
+ if order < 2:
151
+ raise ValueError("Order must be at least 2 for modulation")
152
+
153
+ if modulation == "psk":
154
+ result = _gray_psk(order)
155
+ elif modulation == "ask":
156
+ result = _gray_ask(order, unipolar=unipolar)
157
+ elif modulation == "qam":
158
+ k = int(np.log2(order))
159
+ if 2**k != order:
160
+ raise ValueError(f"Order must be power of 2 for {modulation}")
161
+
162
+ # Check for 8-QAM
163
+ if order == 8:
164
+ result = _gray_qam_8_rect()
165
+ elif k % 2 == 0:
166
+ result = _gray_qam_square(order)
167
+ else:
168
+ result = _gray_qam_cross(order)
169
+ else:
170
+ raise ValueError(f"Unsupported modulation type: {modulation}")
171
+
172
+ if normalize:
173
+ if modulation == "psk":
174
+ # PSK is already on unit circle (E_s = 1)
175
+ pass
176
+ else:
177
+ from .. import helpers
178
+
179
+ result = helpers.normalize(result, mode="average_power")
180
+
181
+ return result
182
+
183
+
184
+ def _gray_psk(order: int) -> np.ndarray:
185
+ """
186
+ Generates an M-PSK constellation with Gray mapping.
187
+
188
+ Parameters
189
+ ----------
190
+ order : int
191
+ Modulation order (must be a power of 2).
192
+
193
+ Returns
194
+ -------
195
+ np.ndarray
196
+ Complex array of PSK constellation points.
197
+ """
198
+ # Bits per symbol
199
+ k = int(np.log2(order))
200
+ if 2**k != order:
201
+ raise ValueError("Order must be power of 2 for psk")
202
+
203
+ # Gray codes for k bits
204
+ gray = gray_code(k)
205
+
206
+ # BPSK Special Case: strictly real values
207
+ if order == 2:
208
+ points = np.array([-1.0, 1.0], dtype=float)
209
+ constellation = np.zeros(order, dtype=float)
210
+ constellation[gray] = points
211
+ return constellation
212
+
213
+ # Geometric points (phases)
214
+ # Phases are 0, 2pi/M, ..., 2pi(M-1)/M
215
+ phases = np.arange(order) * 2 * np.pi / order
216
+ points = np.exp(1j * phases)
217
+
218
+ constellation = np.zeros(order, dtype=complex)
219
+ constellation[gray] = points
220
+
221
+ return constellation
222
+
223
+
224
+ def _gray_ask(order: int, unipolar: bool = False) -> np.ndarray:
225
+ """
226
+ Generates an M-ASK constellation with Gray mapping.
227
+
228
+ By default, returns bipolar values centered at zero. If unipolar=True,
229
+ shifts values to start from zero.
230
+
231
+ Parameters
232
+ ----------
233
+ order : int
234
+ Modulation order (must be a power of 2).
235
+
236
+ Returns
237
+ -------
238
+ np.ndarray
239
+ Real-valued array of ASK constellation points.
240
+ """
241
+ k = int(np.log2(order))
242
+ if 2**k != order:
243
+ raise ValueError("Order must be power of 2 for ask")
244
+
245
+ gray = gray_code(k)
246
+ # Points are symmetric: -M+1, -M+3, ..., M-3, M-1
247
+ points = np.linspace(-order + 1, order - 1, order)
248
+
249
+ if unipolar:
250
+ points = points - np.min(points)
251
+
252
+ constellation = np.zeros(order, dtype=float)
253
+ constellation[gray] = points
254
+
255
+ return constellation
256
+
257
+
258
+ def _gray_qam_square(order: int) -> np.ndarray:
259
+ """
260
+ Generates a Square M-QAM constellation with Gray mapping.
261
+
262
+ Constructed as the Cartesian product of two Gray-coded ASK
263
+ constellations. Valid for orders where log2(M) is even.
264
+
265
+ Parameters
266
+ ----------
267
+ order : int
268
+ Modulation order (must be an even power of 2, e.g., 16, 64).
269
+
270
+ Returns
271
+ -------
272
+ np.ndarray
273
+ Complex array of square QAM constellation points.
274
+ """
275
+ # M = 2^(2k). I bits = k, Q bits = k.
276
+ k_total = int(np.log2(order))
277
+ n = k_total // 2 # bits per axis
278
+
279
+ # Generate ASK for each axis
280
+ m_axis = 2**n
281
+ pam = _gray_ask(m_axis)
282
+
283
+ # Cartesian product
284
+ # Symbol s (2n bits). High n bits -> I, Low n bits -> Q.
285
+ s = np.arange(order)
286
+ mask_q = (1 << n) - 1
287
+
288
+ idx_i = s >> n
289
+ idx_q = s & mask_q
290
+
291
+ i_vals = pam[idx_i]
292
+ q_vals = pam[idx_q]
293
+
294
+ constellation = i_vals + 1j * q_vals
295
+
296
+ return constellation
297
+
298
+
299
+ def _gray_qam_8_rect() -> np.ndarray:
300
+ """
301
+ Generates an 8-QAM constellation with Rectangular Gray mapping.
302
+
303
+ Returns
304
+ -------
305
+ np.ndarray
306
+ Complex array of 8-QAM constellation points.
307
+ """
308
+
309
+ points = np.zeros(8, dtype=complex)
310
+
311
+ # 000 (I=-3, Q=-1)
312
+ points[0] = -3.0 - 1.0j
313
+
314
+ # 001 (I=-3, Q=+1)
315
+ points[1] = -3.0 + 1.0j
316
+
317
+ # 010 (I=-1, Q=-1)
318
+ points[2] = -1.0 - 1.0j
319
+
320
+ # 011 (I=-1, Q=+1)
321
+ points[3] = -1.0 + 1.0j
322
+
323
+ # 100 (I=+3, Q=-1)
324
+ points[4] = 3.0 - 1.0j
325
+
326
+ # 101 (I=+3, Q=+1)
327
+ points[5] = 3.0 + 1.0j
328
+
329
+ # 110 (I=+1, Q=-1)
330
+ points[6] = 1.0 - 1.0j
331
+
332
+ # 111 (I=+1, Q=+1)
333
+ points[7] = 1.0 + 1.0j
334
+
335
+ return points
336
+
337
+
338
+ def _gray_qam_cross(order: int) -> np.ndarray:
339
+ """
340
+ Generates a Cross-QAM constellation using a Quasi-Gray mapping.
341
+
342
+ Cross constellations are used for QAM orders where log2(M) is odd (e.g., 32, 128)
343
+ to maintain a more circular/compact shape than a purely rectangular grid.
344
+
345
+ Parameters
346
+ ----------
347
+ order : int
348
+ Modulation order (must be an odd power of 2).
349
+
350
+ Returns
351
+ -------
352
+ np.ndarray
353
+ Complex array of cross QAM constellation points.
354
+ """
355
+ # M = 2^(2k+1).
356
+ k_total = int(np.log2(order))
357
+
358
+ # I bits n = ceil(k_total/2) = (k+1)//2
359
+ # Q bits m = floor(k_total/2) = k // 2
360
+ n = (k_total + 1) // 2
361
+ m = k_total // 2
362
+
363
+ width = 2**n
364
+ height = 2**m
365
+
366
+ # 1. Generate Gray sequences for underlying Rectangular Grid
367
+ gray_i_seq = gray_code(n)
368
+ gray_q_seq = gray_code(m)
369
+
370
+ # 2. Build Inverse Lookup Tables (Symbol -> Geometric Index)
371
+ inv_gray_i = np.zeros(width, dtype=int)
372
+ inv_gray_i[gray_i_seq] = np.arange(width)
373
+
374
+ inv_gray_q = np.zeros(height, dtype=int)
375
+ inv_gray_q[gray_q_seq] = np.arange(height)
376
+
377
+ # 3. Decompose input Symbols into I and Q components
378
+ s = np.arange(order)
379
+ mask_q = (1 << m) - 1
380
+ idx_i_sym = s >> m
381
+ idx_q_sym = s & mask_q # This is the Gray-coded Symbol Value for Q
382
+
383
+ # 4. Map Symbols to Geometric Indices (0..Width-1, 0..Height-1)
384
+ geo_i = inv_gray_i[idx_i_sym]
385
+ geo_q = inv_gray_q[idx_q_sym]
386
+
387
+ # 5. Determine Folding Parameters
388
+ n_shift = 0
389
+ if n >= 3:
390
+ n_shift = 2 ** (n - 3)
391
+
392
+ if n_shift == 0:
393
+ # Fallback
394
+ val_i = (-width + 1 + 2 * geo_i).astype(float)
395
+ val_q = (-height + 1 + 2 * geo_q).astype(float)
396
+ return val_i + 1j * val_q
397
+
398
+ # 6. Identify Wings
399
+ mask_left = geo_i < n_shift
400
+ mask_right = geo_i >= (width - n_shift)
401
+
402
+ # 7. Calculate Rotation/Translation
403
+ center_offset = (width - height) // 2
404
+
405
+ # Left Wing -> Top Cap
406
+ rotated_i_left = geo_q + center_offset
407
+ rotated_q_left = height + (n_shift - 1 - geo_i)
408
+
409
+ # Right Wing -> Bottom Cap
410
+ rotated_i_right = geo_q + center_offset
411
+ rotated_q_right = -1 - (geo_i - (width - n_shift))
412
+
413
+ # Apply Updates
414
+ geo_i_final = geo_i.copy()
415
+ geo_q_final = geo_q.copy()
416
+
417
+ geo_i_final = np.where(mask_left, rotated_i_left, geo_i_final)
418
+ geo_i_final = np.where(mask_right, rotated_i_right, geo_i_final)
419
+
420
+ geo_q_final = np.where(mask_left, rotated_q_left, geo_q_final)
421
+ geo_q_final = np.where(mask_right, rotated_q_right, geo_q_final)
422
+
423
+ # Convert Final Geometric Indices to Values
424
+ final_i_vals = (-width + 1 + 2 * geo_i_final).astype(float)
425
+ final_q_vals = (-height + 1 + 2 * geo_q_final).astype(float)
426
+
427
+ constellation = final_i_vals + 1j * final_q_vals
428
+
429
+ return constellation
commkit/mapping/llr.py ADDED
@@ -0,0 +1,253 @@
1
+ """
2
+ Soft-decision demapping (log-likelihood ratios).
3
+
4
+ JAX JIT-compiled max-log and exact LLR kernels, with optional probabilistic-
5
+ shaping prior. Sign convention: positive LLR -> bit 0 more likely.
6
+ """
7
+
8
+ from typing import Any
9
+
10
+ import numpy as np
11
+
12
+ from ..backend import ArrayType, _get_jax, dispatch, is_jax_array, to_jax
13
+ from ..logger import logger
14
+ from .gray import gray_constellation
15
+
16
+ __all__ = ["compute_llr"]
17
+
18
+ # Lazy cache for JIT-compiled soft demapping kernels
19
+ _JITTED_SOFT_DEMAP: dict[str, Any] = {}
20
+
21
+
22
+ def _get_jitted_soft_demap():
23
+ """
24
+ Returns JIT-compiled maxlog and exact LLR computation functions.
25
+
26
+ Functions are defined and compiled lazily on first call to avoid
27
+ importing JAX at module load time.
28
+ """
29
+ if not _JITTED_SOFT_DEMAP:
30
+ jax, jnp, _ = _get_jax()
31
+ if jax is None:
32
+ raise ImportError(
33
+ "JAX is required for soft demapping. Install with: pip install jax"
34
+ )
35
+
36
+ @jax.jit
37
+ def maxlog(symbols, constellation, bits_table_t, sigma_sq, log_pmf):
38
+ """Max-log LLR with PS prior.
39
+
40
+ symbols (N,), constellation (M,), bits_table_t (k, M), log_pmf (M,).
41
+ Uniform case: pass log_pmf = jnp.zeros(M) - constant offset cancels.
42
+ PS case: log_pmf = log P(sₘ).
43
+
44
+ Effective metric: eff_m = d_m/σ² - log P(sₘ)
45
+ LLR_k = min_{b=1} eff - min_{b=0} eff
46
+ """
47
+ distances_sq = (
48
+ jnp.abs(symbols[:, None] - constellation[None, :]) ** 2
49
+ ) # (N, M)
50
+ eff = distances_sq / sigma_sq - log_pmf[None, :] # (N, M)
51
+
52
+ def bit_llr(bit_row): # (M,)
53
+ d0 = jnp.where(bit_row == 0, eff, jnp.inf) # (N, M)
54
+ d1 = jnp.where(bit_row == 1, eff, jnp.inf) # (N, M)
55
+ return jnp.min(d1, axis=1) - jnp.min(d0, axis=1) # (N,)
56
+
57
+ return jax.vmap(bit_llr)(bits_table_t).T # (k, N) -> (N, k)
58
+
59
+ @jax.jit
60
+ def exact(symbols, constellation, bits_table_t, sigma_sq, log_pmf):
61
+ """Exact LLR with PS prior via log-sum-exp.
62
+
63
+ symbols (N,), constellation (M,), bits_table_t (k, M), log_pmf (M,).
64
+ Uniform case: pass log_pmf = jnp.zeros(M).
65
+ PS case: log_pmf = log P(sₘ).
66
+
67
+ log_terms_m = log P(sₘ) - d_m/σ²
68
+ LLR_k = LSE_{b=0}(log_terms) - LSE_{b=1}(log_terms)
69
+ """
70
+ distances_sq = (
71
+ jnp.abs(symbols[:, None] - constellation[None, :]) ** 2
72
+ ) # (N, M)
73
+ log_terms = log_pmf[None, :] - distances_sq / sigma_sq # (N, M)
74
+
75
+ def bit_llr(bit_row): # (M,)
76
+ e0 = jnp.where(bit_row == 0, log_terms, -jnp.inf) # (N, M)
77
+ e1 = jnp.where(bit_row == 1, log_terms, -jnp.inf) # (N, M)
78
+ return jax.scipy.special.logsumexp(
79
+ e0, axis=1
80
+ ) - jax.scipy.special.logsumexp(e1, axis=1) # (N,)
81
+
82
+ return jax.vmap(bit_llr)(bits_table_t).T # (k, N) -> (N, k)
83
+
84
+ _JITTED_SOFT_DEMAP["maxlog"] = maxlog
85
+ _JITTED_SOFT_DEMAP["exact"] = exact
86
+
87
+ return _JITTED_SOFT_DEMAP["maxlog"], _JITTED_SOFT_DEMAP["exact"]
88
+
89
+
90
+ def compute_llr(
91
+ symbols: ArrayType,
92
+ modulation: str,
93
+ order: int,
94
+ noise_var: float,
95
+ method: str = "maxlog",
96
+ unipolar: bool = False,
97
+ output: str = "jax",
98
+ pmf: np.ndarray | None = None,
99
+ ) -> ArrayType:
100
+ """
101
+ Compute Log-Likelihood Ratios (LLRs) for soft-decision decoding.
102
+
103
+ Positive LLR -> bit 0 more likely; negative -> bit 1; magnitude = confidence.
104
+ JAX JIT-compiled with ``jax.vmap`` over bit positions; fully differentiable.
105
+
106
+ Parameters
107
+ ----------
108
+ symbols : array_like
109
+ Received noisy symbols. Shape: (..., N_symbols). NumPy, CuPy, or JAX.
110
+ modulation : {"psk", "qam", "ask"}
111
+ Modulation type.
112
+ order : int
113
+ Modulation order.
114
+ noise_var : float
115
+ Complex noise variance sigma^2 referenced to the normalised
116
+ constellation (unit avg power). sigma^2 = 10^(-EsN0_dB / 10).
117
+ method : {"maxlog", "exact"}, default "maxlog"
118
+ LLR algorithm. ``"maxlog"`` is faster; ``"exact"`` uses log-sum-exp.
119
+ unipolar : bool, default False
120
+ Use unipolar constellation for ASK/PAM.
121
+ output : {"jax", "input", "numpy"}, default "jax"
122
+ Output array type. ``"jax"`` preserves differentiability;
123
+ ``"input"`` matches the input backend; ``"numpy"`` forces NumPy.
124
+ pmf : np.ndarray, optional
125
+ Symbol PMF of shape ``(order,)`` for PS-QAM. Pass
126
+ ``maxwell_boltzmann(order, nu)`` to incorporate the non-uniform prior.
127
+ ``None`` assumes uniform prior.
128
+
129
+ Returns
130
+ -------
131
+ array_like
132
+ LLR values. Shape: (..., N_symbols * log2(order)).
133
+ Array type determined by ``output``.
134
+
135
+ Notes
136
+ -----
137
+ Max-Log: LLR_k ≈ (1/sigma^2) * (min_{S_1^k} |r-s|^2 - min_{S_0^k} |r-s|^2).
138
+ Exact: LLR_k = log sum_{S_0^k} exp(-|r-s|^2/sigma^2) - log sum_{S_1^k} ...
139
+
140
+ For PS-QAM, ``symbols`` must be on the same scale as
141
+ ``gray_constellation`` (unit avg power). After
142
+ ``resolve_symbols`` the receiver renormalises;
143
+ use ``gmi`` instead for correct scale.
144
+ """
145
+ logger.debug(
146
+ "Computing LLRs for %s %s-level (method=%s, output=%s).",
147
+ modulation.upper(),
148
+ order,
149
+ method,
150
+ output,
151
+ )
152
+
153
+ k = int(np.log2(order))
154
+ if 2**k != order:
155
+ raise ValueError(f"Order must be a power of 2, got {order}")
156
+ if method not in ("maxlog", "exact"):
157
+ raise ValueError(f"Unknown method: {method}. Use 'maxlog' or 'exact'.")
158
+ if output not in ("jax", "input", "numpy"):
159
+ raise ValueError(f"Unknown output: {output!r}. Use 'jax', 'input', or 'numpy'.")
160
+
161
+ # Convert to JAX if not already
162
+ jax, jnp, _ = _get_jax()
163
+ if jax is None:
164
+ raise ImportError(
165
+ "JAX is required for LLR computation. Install with: pip install jax"
166
+ )
167
+
168
+ # Build constellation and bits table on CPU once - shared by both paths.
169
+ is_complex = symbols.dtype.kind == "c"
170
+ const = gray_constellation(modulation, order, unipolar=unipolar).astype(
171
+ "complex64" if is_complex else "float32"
172
+ )
173
+ bits_table_np = (
174
+ (
175
+ (
176
+ np.arange(order, dtype="int32")[:, None]
177
+ >> np.arange(k - 1, -1, -1, dtype="int32")
178
+ )
179
+ & 1
180
+ )
181
+ .astype("int32")
182
+ .T
183
+ )
184
+ sigma_np = np.float32(max(noise_var, 1e-20))
185
+
186
+ # Build log_pmf: zeros = uniform (constant offset cancels in LLR difference).
187
+ if pmf is not None:
188
+ log_pmf_np = np.log(np.clip(np.asarray(pmf, dtype=np.float32), 1e-40, None))
189
+ else:
190
+ log_pmf_np = np.zeros(order, dtype=np.float32)
191
+
192
+ # JAX path
193
+ jax_module, jnp, _ = _get_jax()
194
+ if is_jax_array(symbols):
195
+ assert jnp is not None
196
+ if hasattr(symbols, "shape"):
197
+ original_shape = symbols.shape
198
+ else:
199
+ # Fallback for JAX tracers or odd objects
200
+ original_shape = jnp.shape(symbols)
201
+
202
+ jax_symbols_flat = symbols.flatten()
203
+ constellation_jax = jnp.asarray(const)
204
+ bits_table_t_jax = jnp.asarray(bits_table_np)
205
+ sigma_sq = jnp.asarray(sigma_np)
206
+ log_pmf_jax = jnp.asarray(log_pmf_np)
207
+
208
+ # NumPy/CuPy path
209
+ else:
210
+ symbols, xp, _ = dispatch(symbols)
211
+ original_shape = symbols.shape
212
+
213
+ jax_symbols_flat = to_jax(
214
+ symbols, dtype="complex64" if is_complex else "float32"
215
+ ).flatten()
216
+ device = jax_symbols_flat.device
217
+
218
+ assert jax_module is not None
219
+ assert jnp is not None
220
+ # device_put accepts NumPy arrays directly - no intermediate jnp.asarray needed
221
+ constellation_jax = jax_module.device_put(const, device)
222
+ bits_table_t_jax = jax_module.device_put(bits_table_np, device)
223
+ sigma_sq = jax_module.device_put(jnp.asarray(sigma_np), device)
224
+ log_pmf_jax = jax_module.device_put(log_pmf_np, device)
225
+
226
+ # Compute LLRs via JIT-compiled kernels
227
+ maxlog_fn, exact_fn = _get_jitted_soft_demap()
228
+ if method == "maxlog":
229
+ llrs = maxlog_fn(
230
+ jax_symbols_flat, constellation_jax, bits_table_t_jax, sigma_sq, log_pmf_jax
231
+ )
232
+ else:
233
+ llrs = exact_fn(
234
+ jax_symbols_flat, constellation_jax, bits_table_t_jax, sigma_sq, log_pmf_jax
235
+ )
236
+
237
+ # Reshape to match input structure
238
+ flat_llrs = llrs.flatten()
239
+ if len(original_shape) > 1:
240
+ new_shape = list(original_shape)
241
+ new_shape[-1] = new_shape[-1] * k
242
+ flat_llrs = flat_llrs.reshape(new_shape)
243
+
244
+ # Convert output to the requested backend
245
+ if output == "jax":
246
+ return flat_llrs
247
+ elif output == "numpy":
248
+ return np.asarray(flat_llrs)
249
+ else: # output == "input"
250
+ if is_jax_array(symbols):
251
+ return flat_llrs # already JAX
252
+ # xp is NumPy or CuPy - convert via NumPy intermediate
253
+ return xp.asarray(np.asarray(flat_llrs))