softlora 0.3.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.
softlora/__init__.py ADDED
@@ -0,0 +1,22 @@
1
+ from softlora.chase import chase_decode
2
+ from softlora.decoder import DecoderSettings, LoRaDecoder
3
+ from softlora.io import (
4
+ iter_iq_chunks, load_iq, save_packets, sample_rate,
5
+ )
6
+ from softlora.packet import Packet, estimate_snr
7
+ from softlora.utils import demodulate_spectra_from
8
+
9
+ __version__ = '0.3.0'
10
+
11
+ __all__ = [
12
+ 'LoRaDecoder',
13
+ 'DecoderSettings',
14
+ 'Packet',
15
+ 'load_iq',
16
+ 'iter_iq_chunks',
17
+ 'save_packets',
18
+ 'sample_rate',
19
+ 'estimate_snr',
20
+ 'chase_decode',
21
+ 'demodulate_spectra_from',
22
+ ]
softlora/chase.py ADDED
@@ -0,0 +1,338 @@
1
+ """Chase (soft-decision) decoding for LoRa payload symbols.
2
+
3
+ Port of the Chase decoder used by the real-signal multi-receiver decoder
4
+ (``lorab_receiver``): given the per-symbol FFT power spectra of the payload,
5
+ a hard decision is taken and the least-reliable symbols/bits are probed with
6
+ alternative candidates until ``decode_fn`` accepts one.
7
+
8
+ Two stages are tried, in order:
9
+
10
+ * **Symbol-level Chase** -- each symbol's spectrum is reduced to its top-K
11
+ bins; the ratio of the top bin to the second bin is the reliability.
12
+ The max-likelihood (argmax) symbol vector is tried first; if it fails,
13
+ single, pair and triple symbol flips among the least-reliable positions are
14
+ tried, ranked by their ``-log(power ratio)`` cost.
15
+ * **Bit-level Chase** -- symbol spectra are converted to per-bit LLRs;
16
+ the least-reliable bits are flipped one at a time, then in pairs, and
17
+ each candidate is re-decoded.
18
+
19
+ ``decode_fn`` is the injected success predicate (e.g. full payload decode
20
+ with a valid CRC-16), so this module is agnostic to the exact coding scheme.
21
+ """
22
+
23
+ import math
24
+ import itertools
25
+ from typing import Callable, Dict, List, Optional, Tuple
26
+
27
+ import numpy as np
28
+
29
+ DecoderFn = Callable[[np.ndarray], bool]
30
+
31
+
32
+ def spectra_to_probs(spectra: np.ndarray) -> np.ndarray:
33
+ """Normalize per-symbol power spectra into probability vectors.
34
+
35
+ Rows with zero total power carry no information and become uniform.
36
+
37
+ Parameters
38
+ ----------
39
+ spectra : ndarray
40
+ (num_syms, N_bins) non-negative FFT power spectra.
41
+
42
+ Returns
43
+ -------
44
+ ndarray
45
+ (num_syms, N_bins) rows normalized to sum to 1.
46
+ """
47
+ probs = np.asarray(spectra, dtype=np.float64).copy()
48
+ row_sums = probs.sum(axis=1)
49
+ zero_rows = row_sums <= 0
50
+ probs[zero_rows] = 1.0
51
+ row_sums = probs.sum(axis=1)
52
+ probs /= row_sums[:, None]
53
+ return probs
54
+
55
+
56
+ def _top_k_bins(probs: np.ndarray, K: int):
57
+ """Return (top_bins, top_power) each of shape (num_syms, K)."""
58
+ num_syms = probs.shape[0]
59
+ top_bins = np.zeros((num_syms, K), dtype=int)
60
+ top_power = np.zeros((num_syms, K))
61
+ for m in range(num_syms):
62
+ idx_sorted = np.argsort(probs[m])[::-1]
63
+ top_bins[m] = idx_sorted[:K]
64
+ top_power[m] = probs[m, idx_sorted[:K]]
65
+ return top_bins, top_power
66
+
67
+
68
+ def _reliability(top_power: np.ndarray) -> np.ndarray:
69
+ """Reliability = top1 / top2 power ratio per symbol (inf if top2 ~ 0)."""
70
+ second = top_power[:, 1]
71
+ return np.where(second > 0, top_power[:, 0] / (second + 1e-300), np.inf)
72
+
73
+
74
+ def _spectra_to_bit_llrs(probs: np.ndarray, sf: int, offset: int) -> np.ndarray:
75
+ """Convert symbol spectra to per-bit LLRs (MSB-first).
76
+
77
+ For each symbol the spectrum is rolled by ``-offset`` into the common
78
+ reference frame, then each bit's LLR is ``log(P(bit=0) / P(bit=1))``
79
+ computed by summing probabilities over bins with that bit set.
80
+ """
81
+ num_syms = probs.shape[0]
82
+ de2bi = ((np.arange(2 ** sf)[:, None] >> np.arange(sf - 1, -1, -1)) & 1)
83
+ bit_llr = np.zeros((num_syms, sf))
84
+ for m in range(num_syms):
85
+ prob_shifted = np.roll(probs[m], -offset)
86
+ prob_shifted = np.maximum(prob_shifted, 1e-12)
87
+ for b in range(sf):
88
+ mask0 = de2bi[:, b] == 0
89
+ mask1 = de2bi[:, b] == 1
90
+ p0 = float(np.sum(prob_shifted[mask0]))
91
+ p1 = float(np.sum(prob_shifted[mask1]))
92
+ bit_llr[m, b] = math.log(p0 / (p1 + 1e-300))
93
+ return bit_llr
94
+
95
+
96
+ def _bit_llrs_to_symbols(bit_llr: np.ndarray, sf: int) -> np.ndarray:
97
+ """Convert an (num_syms, sf) LLR array to symbol indices (MSB-first)."""
98
+ hard_bits = (bit_llr < 0).astype(int)
99
+ weights = (1 << np.arange(sf - 1, -1, -1))
100
+ return hard_bits @ weights
101
+
102
+
103
+ def _chase_symbol_candidates(
104
+ probs: np.ndarray,
105
+ top_bins: np.ndarray,
106
+ top_power: np.ndarray,
107
+ reliability: np.ndarray,
108
+ hard_syms: np.ndarray,
109
+ offset: int,
110
+ N_bins: int,
111
+ K: int,
112
+ reliability_thresh: float,
113
+ max_flip_pos: int,
114
+ max_attempts: int,
115
+ ):
116
+ """Yield (candidate_symbols, label) for symbol-level Chase, in cost order.
117
+
118
+ The max-likelihood (argmax) vector is emitted first, followed by single,
119
+ pair and triple flips among the least-reliable positions ranked by
120
+ flipping cost.
121
+ """
122
+ num_syms = probs.shape[0]
123
+ yield hard_syms.copy(), "hard"
124
+
125
+ rel_order = np.argsort(reliability)
126
+ flip_pos = rel_order[reliability[rel_order] < reliability_thresh]
127
+ flip_pos = flip_pos[:max_flip_pos]
128
+ nf = len(flip_pos)
129
+
130
+ if nf == 0:
131
+ return
132
+
133
+ def is_valid(p: int, r: int) -> bool:
134
+ return top_power[p, r] >= top_power[p, 0] * 0.03
135
+
136
+ def rank_cost(p: int, r: int) -> float:
137
+ return -math.log(top_power[p, r] / (top_power[p, 0] + 1e-300))
138
+
139
+ singles: List[Tuple] = []
140
+ for p in flip_pos:
141
+ for r in range(1, K):
142
+ if not is_valid(p, r):
143
+ continue
144
+ singles.append((p, r, rank_cost(p, r)))
145
+ singles.sort(key=lambda x: x[2])
146
+
147
+ pairs: List[Tuple] = []
148
+ for p1, p2 in itertools.combinations(flip_pos, 2):
149
+ for r1 in range(1, K):
150
+ if not is_valid(p1, r1):
151
+ continue
152
+ c1 = rank_cost(p1, r1)
153
+ for r2 in range(1, K):
154
+ if not is_valid(p2, r2):
155
+ continue
156
+ c2 = rank_cost(p2, r2)
157
+ pairs.append((p1, r1, p2, r2, c1 + c2))
158
+ pairs.sort(key=lambda x: x[4])
159
+
160
+ nf3 = min(nf, 4)
161
+ triples: List[Tuple] = []
162
+ for p1, p2, p3 in itertools.combinations(flip_pos[:nf3], 3):
163
+ for r1 in range(1, K):
164
+ if not is_valid(p1, r1):
165
+ continue
166
+ c1 = rank_cost(p1, r1)
167
+ for r2 in range(1, K):
168
+ if not is_valid(p2, r2):
169
+ continue
170
+ c2 = rank_cost(p2, r2)
171
+ for r3 in range(1, K):
172
+ if not is_valid(p3, r3):
173
+ continue
174
+ c3 = rank_cost(p3, r3)
175
+ triples.append((p1, r1, p2, r2, p3, r3, c1 + c2 + c3))
176
+ triples.sort(key=lambda x: x[6])
177
+
178
+ ranked: List[Tuple[str, tuple]] = []
179
+ for s in singles:
180
+ ranked.append(('single', s))
181
+ for p in pairs:
182
+ ranked.append(('pair', p))
183
+ for t in triples:
184
+ ranked.append(('triple', t))
185
+ ranked.sort(key=lambda x: x[1][-1])
186
+
187
+ for tag, row in ranked[:max_attempts]:
188
+ cand = hard_syms.copy()
189
+ if tag == 'single':
190
+ p, r, _ = row
191
+ cand[p] = (top_bins[p, r] - offset) % N_bins
192
+ label = f"1-flip pos={p} rank={r}"
193
+ elif tag == 'pair':
194
+ p1, r1, p2, r2, _ = row
195
+ cand[p1] = (top_bins[p1, r1] - offset) % N_bins
196
+ cand[p2] = (top_bins[p2, r2] - offset) % N_bins
197
+ label = f"2-flip pos=[{p1} {p2}] ranks=[{r1} {r2}]"
198
+ else:
199
+ p1, r1, p2, r2, p3, r3, _ = row
200
+ cand[p1] = (top_bins[p1, r1] - offset) % N_bins
201
+ cand[p2] = (top_bins[p2, r2] - offset) % N_bins
202
+ cand[p3] = (top_bins[p3, r3] - offset) % N_bins
203
+ label = f"3-flip pos=[{p1} {p2} {p3}]"
204
+ yield cand, label
205
+
206
+
207
+ def _chase_bit_candidates(
208
+ probs: np.ndarray,
209
+ offset: int,
210
+ sf: int,
211
+ max_bit_flips: int,
212
+ max_pair_flips: int,
213
+ ):
214
+ """Yield (candidate_symbols, label) for bit-level Chase, in cost order.
215
+
216
+ The bit-reconstructed hard vector (symbols rebuilt from per-bit LLR
217
+ signs, which may differ from the per-symbol argmax) is emitted first,
218
+ followed by single-bit and bit-pair flips of the least-reliable bits.
219
+ """
220
+ bit_llr = _spectra_to_bit_llrs(probs, sf, offset)
221
+ hard_bits_vec = (bit_llr.ravel() < 0).astype(int)
222
+ llr_flat = np.abs(bit_llr).ravel()
223
+ bit_order = np.argsort(llr_flat)
224
+ n_flips = min(len(hard_bits_vec), max_bit_flips)
225
+ bit_weights = 1 << np.arange(sf - 1, -1, -1)
226
+
227
+ def bits_to_syms(bvec: np.ndarray) -> np.ndarray:
228
+ return bvec.reshape(-1, sf) @ bit_weights
229
+
230
+ yield bits_to_syms(hard_bits_vec), "hard-bits"
231
+
232
+ for fi in range(n_flips):
233
+ flipped = hard_bits_vec.copy()
234
+ flipped[bit_order[fi]] ^= 1
235
+ yield bits_to_syms(flipped), f"bit-flip {fi + 1}"
236
+
237
+ n_pair = min(max_pair_flips, n_flips)
238
+ for i in range(n_pair):
239
+ for j in range(i + 1, n_pair):
240
+ flipped = hard_bits_vec.copy()
241
+ flipped[bit_order[i]] ^= 1
242
+ flipped[bit_order[j]] ^= 1
243
+ yield bits_to_syms(flipped), f"2-bit-flip {i},{j}"
244
+
245
+
246
+ def chase_decode(
247
+ spectra: np.ndarray,
248
+ decode_fn: DecoderFn,
249
+ *,
250
+ sf: int,
251
+ offset: int = 0,
252
+ K: int = 5,
253
+ reliability_thresh: float = 15.0,
254
+ max_flip_pos: int = 6,
255
+ max_attempts: int = 120,
256
+ max_bit_flips: int = 40,
257
+ max_pair_flips: int = 15,
258
+ enable_bit_chase: bool = True,
259
+ ) -> Tuple[bool, Optional[np.ndarray], Dict[str, object]]:
260
+ """Chase-decode payload symbols from their FFT power spectra.
261
+
262
+ Parameters
263
+ ----------
264
+ spectra : ndarray
265
+ (num_syms, N_bins) per-symbol FFT power spectra. The hard symbol
266
+ value for symbol *m* is taken as ``argmax(spectra[m]) - offset``.
267
+ decode_fn : callable
268
+ ``decode_fn(symbols) -> bool`` returning True when a candidate symbol
269
+ vector decodes correctly (e.g. CRC-16 valid). The first accepted
270
+ candidate is returned.
271
+ sf : int
272
+ Spreading factor (needed for the bit-level stage).
273
+ offset : int
274
+ Bin offset applied when converting spectra to symbol values.
275
+ ``0`` in the standard receiver reference frame.
276
+ K : int
277
+ Number of top bins considered per symbol.
278
+ reliability_thresh : float
279
+ Symbols with reliability below this value are treated as ambiguous
280
+ flip positions.
281
+ max_flip_pos : int
282
+ Maximum number of least-reliable symbol positions to flip.
283
+ max_attempts : int
284
+ Maximum symbol-level candidates tried.
285
+ max_bit_flips : int
286
+ Maximum number of single-bit flips tried in the bit-level stage.
287
+ max_pair_flips : int
288
+ Maximum number of bit-pair flips tried in the bit-level stage.
289
+ enable_bit_chase : bool
290
+ Set False to skip the bit-level stage.
291
+
292
+ Returns
293
+ -------
294
+ ok : bool
295
+ True if a candidate decoded successfully.
296
+ symbols : ndarray or None
297
+ The accepted symbol vector (``None`` if not ok).
298
+ info : dict
299
+ Debug info: attempts, labels tried, and which stage succeeded.
300
+ """
301
+ num_syms, N_bins = spectra.shape
302
+ probs = spectra_to_probs(spectra)
303
+
304
+ info: Dict[str, object] = {
305
+ 'attempts': 0,
306
+ 'labels': [],
307
+ 'stage': None,
308
+ 'num_syms': num_syms,
309
+ }
310
+
311
+ def try_candidate(cand: np.ndarray, label: str) -> bool:
312
+ info['attempts'] = int(info['attempts']) + 1
313
+ info['labels'].append(label)
314
+ return bool(decode_fn(cand))
315
+
316
+ top_bins, top_power = _top_k_bins(probs, K)
317
+ reliability = _reliability(top_power)
318
+ hard_syms = (top_bins[:, 0] - offset) % N_bins
319
+
320
+ for cand, label in _chase_symbol_candidates(
321
+ probs, top_bins, top_power, reliability, hard_syms, offset, N_bins,
322
+ K, reliability_thresh, max_flip_pos, max_attempts,
323
+ ):
324
+ if try_candidate(cand, label):
325
+ info['stage'] = 'symbol'
326
+ return True, cand, info
327
+
328
+ if enable_bit_chase:
329
+ for cand, label in _chase_bit_candidates(
330
+ probs, offset, sf,
331
+ max_bit_flips, max_pair_flips,
332
+ ):
333
+ if try_candidate(cand, label):
334
+ info['stage'] = 'bit'
335
+ return True, cand, info
336
+
337
+ info['stage'] = 'none'
338
+ return False, None, info
softlora/chirp.py ADDED
@@ -0,0 +1,22 @@
1
+ import numpy as np
2
+
3
+
4
+ def generate_chirps(N):
5
+ """Generate the N-sample baseband reference upchirp and downchirp.
6
+
7
+ Parameters
8
+ ----------
9
+ N : int
10
+ Samples per symbol (2**sf).
11
+
12
+ Returns
13
+ -------
14
+ upchirp : ndarray
15
+ The LoRa upchirp (symbol 0 preamble chirp).
16
+ downchirp : ndarray
17
+ The complex conjugate (downchirp), used to dechirp received windows.
18
+ """
19
+ n = np.arange(N, dtype=float)
20
+ upchirp = np.exp(1j * 2 * np.pi * (n**2 / (2 * N) - n / 2))
21
+ downchirp = np.exp(-1j * 2 * np.pi * (n**2 / (2 * N) - n / 2))
22
+ return upchirp, downchirp