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 +22 -0
- softlora/chase.py +338 -0
- softlora/chirp.py +22 -0
- softlora/coding.py +474 -0
- softlora/decoder.py +1551 -0
- softlora/gr_frame_sync.py +619 -0
- softlora/io.py +244 -0
- softlora/packet.py +244 -0
- softlora/py.typed +0 -0
- softlora/sync.py +372 -0
- softlora/utils.py +166 -0
- softlora-0.3.0.dist-info/METADATA +157 -0
- softlora-0.3.0.dist-info/RECORD +16 -0
- softlora-0.3.0.dist-info/WHEEL +5 -0
- softlora-0.3.0.dist-info/licenses/LICENSE +19 -0
- softlora-0.3.0.dist-info/top_level.txt +1 -0
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
|