snappnt 0.1.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.
- snappnt/__init__.py +12 -0
- snappnt/__main__.py +3 -0
- snappnt/cli.py +523 -0
- snappnt/eval/__init__.py +5 -0
- snappnt/eval/linkbudget.py +137 -0
- snappnt/eval/pd_curve.py +111 -0
- snappnt/frontend/__init__.py +6 -0
- snappnt/frontend/device.py +50 -0
- snappnt/frontend/devices/__init__.py +1 -0
- snappnt/frontend/devices/b206mini_i.yaml +9 -0
- snappnt/frontend/devices/b210_clone.yaml +9 -0
- snappnt/frontend/devices/esp32c3.yaml +11 -0
- snappnt/frontend/devices/esp32c61.yaml +12 -0
- snappnt/frontend/devices/hackrf_one.yaml +8 -0
- snappnt/frontend/freqplan.py +62 -0
- snappnt/io/__init__.py +6 -0
- snappnt/io/convert.py +119 -0
- snappnt/io/espsdr_capture.py +268 -0
- snappnt/io/espsdr_client.py +271 -0
- snappnt/io/espsdr_iq.py +71 -0
- snappnt/io/generators.py +57 -0
- snappnt/io/sigmf_io.py +251 -0
- snappnt/rx/__init__.py +5 -0
- snappnt/rx/acquisition.py +370 -0
- snappnt/signals/__init__.py +6 -0
- snappnt/signals/base.py +78 -0
- snappnt/signals/catalog/__init__.py +1 -0
- snappnt/signals/catalog/cband_bpsk_generic.yaml +15 -0
- snappnt/signals/catalog/gps_l1ca.yaml +14 -0
- snappnt/signals/catalog/navic_l5_sps.yaml +14 -0
- snappnt/signals/catalog/navic_s_sps.yaml +15 -0
- snappnt/signals/codes/__init__.py +38 -0
- snappnt/signals/codes/generic.py +20 -0
- snappnt/signals/codes/gps.py +37 -0
- snappnt/signals/codes/lfsr.py +64 -0
- snappnt/signals/codes/navic.py +62 -0
- snappnt/sim/__init__.py +6 -0
- snappnt/sim/export.py +42 -0
- snappnt/sim/generate.py +182 -0
- snappnt/sim/impairments.py +98 -0
- snappnt/sim/leo.py +68 -0
- snappnt/sim/scenario.py +262 -0
- snappnt-0.1.0.dist-info/METADATA +181 -0
- snappnt-0.1.0.dist-info/RECORD +47 -0
- snappnt-0.1.0.dist-info/WHEEL +4 -0
- snappnt-0.1.0.dist-info/entry_points.txt +2 -0
- snappnt-0.1.0.dist-info/licenses/LICENSE +24 -0
snappnt/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""snappnt: snapshot PNT receiver toolkit for low-cost front ends.
|
|
2
|
+
|
|
3
|
+
Layers (see docs/design/architecture.md):
|
|
4
|
+
signals what signal to receive (carrier, chip rate, spreading codes)
|
|
5
|
+
frontend how it is received (device limits, frequency plan)
|
|
6
|
+
io how data moves between layers (SigMF files, device I/O)
|
|
7
|
+
sim synthetic signals with known truth
|
|
8
|
+
rx acquisition and measurements
|
|
9
|
+
eval detection-probability sweeps and truth comparison
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
__version__ = "0.1.0"
|
snappnt/__main__.py
ADDED
snappnt/cli.py
ADDED
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
"""Command line: ``snappnt <command> ...``
|
|
2
|
+
|
|
3
|
+
codes print code properties (first chips in ICD octal form)
|
|
4
|
+
sim scenario YAML -> SigMF (+ optional generator playback file)
|
|
5
|
+
acquire SigMF -> acquisition table (and truth comparison if present)
|
|
6
|
+
sweep scenario YAML -> detection probability vs C/N0 (CSV)
|
|
7
|
+
capture ESP-SDR board on a serial port -> SigMF (one file per capture)
|
|
8
|
+
convert raw I/Q file of UHD or HackRF -> SigMF
|
|
9
|
+
info list signals and devices
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _parse_range(text: str) -> list[float]:
|
|
22
|
+
"""'40:60:2' -> [40, 42, ..., 60]; '45,50' -> [45, 50]."""
|
|
23
|
+
if ":" in text:
|
|
24
|
+
a, b, s = (float(v) for v in text.split(":"))
|
|
25
|
+
return list(np.arange(a, b + s / 2, s))
|
|
26
|
+
return [float(v) for v in text.split(",")]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _parse_prns(text: str) -> list[int]:
|
|
30
|
+
out: list[int] = []
|
|
31
|
+
for part in text.split(","):
|
|
32
|
+
if "-" in part:
|
|
33
|
+
a, b = part.split("-")
|
|
34
|
+
out += list(range(int(a), int(b) + 1))
|
|
35
|
+
else:
|
|
36
|
+
out.append(int(part))
|
|
37
|
+
return out
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def cmd_info(_: argparse.Namespace) -> int:
|
|
41
|
+
from snappnt.frontend import list_devices
|
|
42
|
+
from snappnt.signals import list_signals, load_signal
|
|
43
|
+
|
|
44
|
+
print("signals:")
|
|
45
|
+
for name in list_signals():
|
|
46
|
+
s = load_signal(name)
|
|
47
|
+
print(f" {name:22s} {s.carrier_hz / 1e6:10.3f} MHz {s.chip_rate_hz / 1e6:.3f} Mcps")
|
|
48
|
+
print("devices:")
|
|
49
|
+
for name in list_devices():
|
|
50
|
+
print(f" {name}")
|
|
51
|
+
return 0
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def cmd_codes(a: argparse.Namespace) -> int:
|
|
55
|
+
from snappnt.signals import get_code, load_signal
|
|
56
|
+
from snappnt.signals.codes.lfsr import first_chips_octal
|
|
57
|
+
|
|
58
|
+
spec = load_signal(a.signal)
|
|
59
|
+
prns = _parse_prns(a.prn) if a.prn else list(spec.prns())
|
|
60
|
+
for prn in prns:
|
|
61
|
+
c = get_code(spec, prn)
|
|
62
|
+
octal = first_chips_octal(c)
|
|
63
|
+
print(f"PRN {prn:3d} length {c.size} first10(octal) {octal} sum {int(c.sum()):+d}")
|
|
64
|
+
return 0
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def cmd_sim(a: argparse.Namespace) -> int:
|
|
68
|
+
from snappnt.io import write_sigmf
|
|
69
|
+
from snappnt.signals import load_signal
|
|
70
|
+
from snappnt.sim import generate, load_scenario
|
|
71
|
+
from snappnt.sim.export import write_hackrf_int8, write_uhd_sc16
|
|
72
|
+
|
|
73
|
+
scn = load_scenario(a.scenario)
|
|
74
|
+
x, truth = generate(scn)
|
|
75
|
+
spec = load_signal(scn.signal)
|
|
76
|
+
out = Path(a.output or f"out/{scn.name}")
|
|
77
|
+
center_hz = (
|
|
78
|
+
scn.frequency_plan.tuned_hz
|
|
79
|
+
if scn.frequency_plan is not None
|
|
80
|
+
else spec.carrier_hz - scn.receiver.baseband_offset_hz
|
|
81
|
+
)
|
|
82
|
+
base = write_sigmf(
|
|
83
|
+
out,
|
|
84
|
+
x,
|
|
85
|
+
scn.receiver.sample_rate_hz,
|
|
86
|
+
center_frequency_hz=center_hz,
|
|
87
|
+
description=f"snappnt simulation: {scn.name}",
|
|
88
|
+
truth=truth,
|
|
89
|
+
)
|
|
90
|
+
print(f"wrote {base}.sigmf-meta / .sigmf-data ({x.size} samples)")
|
|
91
|
+
if a.hackrf:
|
|
92
|
+
p = write_hackrf_int8(x, base.with_name(base.name + ".hackrf.i8"))
|
|
93
|
+
print(f"wrote {p} (hackrf_transfer -t, int8 I/Q)")
|
|
94
|
+
if a.uhd:
|
|
95
|
+
p = write_uhd_sc16(x, base.with_name(base.name + ".uhd.sc16"))
|
|
96
|
+
print(f"wrote {p} (tx_samples_from_file --type short)")
|
|
97
|
+
return 0
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def cmd_acquire(a: argparse.Namespace) -> int:
|
|
101
|
+
from snappnt.eval import is_correct
|
|
102
|
+
from snappnt.eval.pd_curve import CODE_DOPPLER_LO_ERROR
|
|
103
|
+
from snappnt.io import get_truth, read_sigmf
|
|
104
|
+
from snappnt.rx import acquire
|
|
105
|
+
from snappnt.signals import load_signal
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
x, meta = read_sigmf(a.file, start_s=a.start_s, duration_s=a.duration_s)
|
|
109
|
+
except ValueError as e:
|
|
110
|
+
print(f"error: {e}", file=sys.stderr)
|
|
111
|
+
return 2
|
|
112
|
+
fs = float(meta["global"]["core:sample_rate"])
|
|
113
|
+
truth = get_truth(meta)
|
|
114
|
+
signal = a.signal or (truth or {}).get("signal")
|
|
115
|
+
if not signal:
|
|
116
|
+
print("error: --signal is required (no truth annotation to read it from)", file=sys.stderr)
|
|
117
|
+
return 2
|
|
118
|
+
spec = load_signal(signal)
|
|
119
|
+
if a.code_doppler and ((truth or {}).get("frequency_plan") or {}).get("lo_hz") is not None:
|
|
120
|
+
print(f"error: {CODE_DOPPLER_LO_ERROR}", file=sys.stderr)
|
|
121
|
+
return 2
|
|
122
|
+
center = (truth or {}).get("baseband_offset_hz", 0.0) if a.center is None else a.center
|
|
123
|
+
prns = _parse_prns(a.prn) if a.prn else list(spec.prns())
|
|
124
|
+
# The truth code phase refers to the first sample of the file, so it is compared only
|
|
125
|
+
# when the segment starts there.
|
|
126
|
+
start_sample = round(a.start_s * fs)
|
|
127
|
+
truth_by_prn = {}
|
|
128
|
+
if start_sample == 0:
|
|
129
|
+
truth_by_prn = {s["prn"]: s for s in (truth or {}).get("satellites", [])}
|
|
130
|
+
|
|
131
|
+
print(
|
|
132
|
+
f"{spec.name}: {x.size} samples at {fs / 1e6:g} MSa/s ({x.size / fs * 1e3:.3f} ms)"
|
|
133
|
+
+ (f" from sample {start_sample} ({a.start_s:g} s)" if start_sample else "")
|
|
134
|
+
)
|
|
135
|
+
if start_sample and truth is not None:
|
|
136
|
+
print("truth refers to the first sample of the file; not compared for this segment")
|
|
137
|
+
print(" PRN det code[chip] freq[Hz] metric thr C/N0est truth")
|
|
138
|
+
for prn in prns:
|
|
139
|
+
r = acquire(
|
|
140
|
+
x, fs, spec, prn,
|
|
141
|
+
center_offset_hz=center,
|
|
142
|
+
freq_range_hz=(-a.freq_span, a.freq_span),
|
|
143
|
+
n_blocks=a.blocks,
|
|
144
|
+
pfa=a.pfa,
|
|
145
|
+
remove_dc=a.remove_dc,
|
|
146
|
+
code_doppler=a.code_doppler,
|
|
147
|
+
) # fmt: skip
|
|
148
|
+
t = truth_by_prn.get(prn)
|
|
149
|
+
mark = ""
|
|
150
|
+
if t is not None:
|
|
151
|
+
mark = "OK" if r.detected and is_correct(r, t, spec.code_length, 0.5) else "--"
|
|
152
|
+
print(
|
|
153
|
+
f" {prn:3d} {'yes' if r.detected else ' no'} {r.code_phase_chips:9.2f} "
|
|
154
|
+
f"{r.freq_offset_hz:9.0f} {r.metric:7.1f} {r.threshold:5.1f} "
|
|
155
|
+
f"{r.cn0_dbhz_est:6.1f} {mark}"
|
|
156
|
+
)
|
|
157
|
+
return 0
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def cmd_sweep(a: argparse.Namespace) -> int:
|
|
161
|
+
from snappnt.eval import sweep, write_csv
|
|
162
|
+
from snappnt.eval.pd_curve import CODE_DOPPLER_LO_ERROR
|
|
163
|
+
from snappnt.sim import load_scenario
|
|
164
|
+
|
|
165
|
+
scn = load_scenario(a.scenario)
|
|
166
|
+
if a.code_doppler and scn.frequency_plan is not None and scn.frequency_plan.lo_hz is not None:
|
|
167
|
+
print(f"error: {CODE_DOPPLER_LO_ERROR}", file=sys.stderr)
|
|
168
|
+
return 2
|
|
169
|
+
pts = sweep(
|
|
170
|
+
scn,
|
|
171
|
+
_parse_range(a.cn0),
|
|
172
|
+
a.trials,
|
|
173
|
+
freq_range_hz=(-a.freq_span, a.freq_span),
|
|
174
|
+
n_blocks=a.blocks,
|
|
175
|
+
pfa=a.pfa,
|
|
176
|
+
seed=a.seed,
|
|
177
|
+
remove_dc=a.remove_dc,
|
|
178
|
+
code_doppler=a.code_doppler,
|
|
179
|
+
)
|
|
180
|
+
print(" C/N0 Pd Pwrong metric")
|
|
181
|
+
for p in pts:
|
|
182
|
+
print(f" {p.cn0_dbhz:5.1f} {p.p_detect:5.2f} {p.p_wrong:5.2f} {p.mean_metric:7.2f}")
|
|
183
|
+
if a.output:
|
|
184
|
+
print(f"wrote {write_csv(pts, a.output)}")
|
|
185
|
+
return 0
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _parse_loss(text: str) -> float:
|
|
189
|
+
"""A loss in dB, written as `<dB>` or `<label>=<dB>`."""
|
|
190
|
+
try:
|
|
191
|
+
return float(text.rsplit("=", 1)[-1])
|
|
192
|
+
except ValueError:
|
|
193
|
+
raise argparse.ArgumentTypeError(f"not a loss in dB: {text!r}") from None
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def cmd_link_budget(a: argparse.Namespace) -> int:
|
|
197
|
+
from snappnt.eval.linkbudget import evaluate_branches
|
|
198
|
+
|
|
199
|
+
if a.ref_nf_db is None and a.ref_loss:
|
|
200
|
+
print("error: --ref-loss needs --ref-nf-db", file=sys.stderr)
|
|
201
|
+
return 2
|
|
202
|
+
branches = {"ESP32": (a.esp32_loss or [], a.esp32_nf_db)}
|
|
203
|
+
if a.ref_nf_db is not None:
|
|
204
|
+
branches["reference"] = (a.ref_loss or [], a.ref_nf_db)
|
|
205
|
+
for name, (own, _) in branches.items():
|
|
206
|
+
if not (a.loss or own):
|
|
207
|
+
print(
|
|
208
|
+
f"error: no losses given for the {name} path; pass --loss, or --loss 0 "
|
|
209
|
+
"if there really is none",
|
|
210
|
+
file=sys.stderr,
|
|
211
|
+
)
|
|
212
|
+
return 2
|
|
213
|
+
try:
|
|
214
|
+
results = evaluate_branches(
|
|
215
|
+
a.gen_dbm, a.loss or [], branches, a.scenario_cn0_dbhz, a.margin_db
|
|
216
|
+
)
|
|
217
|
+
except ValueError as e:
|
|
218
|
+
print(f"error: {e}", file=sys.stderr)
|
|
219
|
+
return 2
|
|
220
|
+
for r in results:
|
|
221
|
+
print(f"[{r.name}]")
|
|
222
|
+
print(f" input level {r.p_in_dbm:8.2f} dBm")
|
|
223
|
+
print(f" receiver NF {r.nf_db:8.2f} dB (input)")
|
|
224
|
+
print(f" receiver noise {r.noise_dbm_hz:8.2f} dBm/Hz")
|
|
225
|
+
print(f" C/N0, no added noise {r.cn0_dbhz:8.2f} dB-Hz")
|
|
226
|
+
c = r.noise_check
|
|
227
|
+
if c is not None:
|
|
228
|
+
print(f" injected noise {c.injected_dbm_hz:8.2f} dBm/Hz")
|
|
229
|
+
print(f" injected - receiver {c.ratio_db:8.2f} dB (margin {a.margin_db:.1f} dB)")
|
|
230
|
+
print(f" C/N0 at receiver {c.effective_cn0_dbhz:8.2f} dB-Hz")
|
|
231
|
+
print(f" C/N0 error {c.error_db:8.2f} dB")
|
|
232
|
+
print(
|
|
233
|
+
" check " + ("ok" if c.ok else "FAILED: receiver noise is too high")
|
|
234
|
+
)
|
|
235
|
+
if a.scenario_cn0_dbhz is not None and not all(r.noise_check.ok for r in results):
|
|
236
|
+
return 1
|
|
237
|
+
return 0
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def cmd_capture(a: argparse.Namespace) -> int:
|
|
241
|
+
from snappnt.io.espsdr_capture import (
|
|
242
|
+
LIVE_ONLY_CHECKS,
|
|
243
|
+
capture_paths,
|
|
244
|
+
command_plan,
|
|
245
|
+
existing_outputs,
|
|
246
|
+
parse_gain_reply,
|
|
247
|
+
save_capture_sigmf,
|
|
248
|
+
utc_now,
|
|
249
|
+
)
|
|
250
|
+
from snappnt.io.espsdr_client import (
|
|
251
|
+
EspSdrClient,
|
|
252
|
+
EspSdrDamagedCapture,
|
|
253
|
+
EspSdrError,
|
|
254
|
+
EspSdrTimeout,
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
gain = None if a.gain == "auto" else _parse_gain(a.gain)
|
|
258
|
+
if gain == -1:
|
|
259
|
+
print("error: --gain must be 'auto' or a non-negative integer index", file=sys.stderr)
|
|
260
|
+
return 2
|
|
261
|
+
try:
|
|
262
|
+
plan = command_plan(
|
|
263
|
+
frequency_hz=a.freq_hz,
|
|
264
|
+
sample_rate_hz=a.rate_sps,
|
|
265
|
+
n_samples=a.samples,
|
|
266
|
+
gain=gain,
|
|
267
|
+
bandwidth_mhz=a.bandwidth_mhz,
|
|
268
|
+
bits=a.bits,
|
|
269
|
+
count=a.count,
|
|
270
|
+
)
|
|
271
|
+
paths = capture_paths(a.output, a.count)
|
|
272
|
+
except ValueError as e:
|
|
273
|
+
print(f"error: {e}", file=sys.stderr)
|
|
274
|
+
return 2
|
|
275
|
+
if a.dry_run:
|
|
276
|
+
print("\n".join(plan))
|
|
277
|
+
print(f"note: {LIVE_ONLY_CHECKS}", file=sys.stderr)
|
|
278
|
+
return 0
|
|
279
|
+
if not a.overwrite and (found := existing_outputs(paths)):
|
|
280
|
+
print(
|
|
281
|
+
f"error: {found[0]} exists (and {len(found) - 1} more); "
|
|
282
|
+
"choose another --output or pass --overwrite",
|
|
283
|
+
file=sys.stderr,
|
|
284
|
+
)
|
|
285
|
+
return 2
|
|
286
|
+
if a.port is None:
|
|
287
|
+
print("error: the serial port is required unless --dry-run is given", file=sys.stderr)
|
|
288
|
+
return 2
|
|
289
|
+
|
|
290
|
+
try:
|
|
291
|
+
client = EspSdrClient(port=a.port)
|
|
292
|
+
except (EspSdrError, EspSdrTimeout, OSError) as e:
|
|
293
|
+
print(f"error: cannot open the board: {e}", file=sys.stderr)
|
|
294
|
+
return 1
|
|
295
|
+
done = 0
|
|
296
|
+
try:
|
|
297
|
+
info = client.info()
|
|
298
|
+
client.tune(a.freq_hz)
|
|
299
|
+
if a.bandwidth_mhz is not None:
|
|
300
|
+
client.set_bandwidth_mhz(a.bandwidth_mhz)
|
|
301
|
+
if gain is None:
|
|
302
|
+
client.set_gain_hardware()
|
|
303
|
+
else:
|
|
304
|
+
client.set_gain_manual(gain)
|
|
305
|
+
client.set_sample_rate(a.rate_sps)
|
|
306
|
+
requested = ("hardware", None) if gain is None else ("manual", gain)
|
|
307
|
+
for path in paths:
|
|
308
|
+
lpf_reply = client.lpf() # right before each capture; see command_plan
|
|
309
|
+
gain_reply = client.gain()
|
|
310
|
+
state = parse_gain_reply(gain_reply)
|
|
311
|
+
if state is not None and (state.mode, state.index) != requested:
|
|
312
|
+
print(
|
|
313
|
+
f"warning: capture {done + 1} of {a.count}: the board reports "
|
|
314
|
+
f"{gain_reply!r}, not the requested gain; recorded as reported",
|
|
315
|
+
file=sys.stderr,
|
|
316
|
+
)
|
|
317
|
+
when = utc_now()
|
|
318
|
+
cap = client.capture(a.samples, bits=a.bits)
|
|
319
|
+
try:
|
|
320
|
+
base = save_capture_sigmf(
|
|
321
|
+
cap,
|
|
322
|
+
path,
|
|
323
|
+
firmware_info=info,
|
|
324
|
+
gain=gain,
|
|
325
|
+
analog_bandwidth_mhz=a.bandwidth_mhz,
|
|
326
|
+
host_time_utc=when,
|
|
327
|
+
lpf_reply=lpf_reply,
|
|
328
|
+
gain_reply=gain_reply,
|
|
329
|
+
description="snappnt capture",
|
|
330
|
+
)
|
|
331
|
+
except OSError as e:
|
|
332
|
+
print(f"error: cannot write capture {done + 1} of {a.count}: {e}", file=sys.stderr)
|
|
333
|
+
return 1
|
|
334
|
+
done += 1
|
|
335
|
+
print(f"wrote {base}.sigmf-meta / .sigmf-data ({cap.samples.size} samples)")
|
|
336
|
+
except EspSdrDamagedCapture as e:
|
|
337
|
+
print(f"error: damaged capture {done + 1} of {a.count}: {e}", file=sys.stderr)
|
|
338
|
+
try:
|
|
339
|
+
client.resync(99)
|
|
340
|
+
except (EspSdrError, EspSdrTimeout):
|
|
341
|
+
pass
|
|
342
|
+
return 1
|
|
343
|
+
except (EspSdrError, EspSdrTimeout, ValueError) as e:
|
|
344
|
+
print(f"error: capture {done + 1} of {a.count}: {e}", file=sys.stderr)
|
|
345
|
+
return 1
|
|
346
|
+
finally:
|
|
347
|
+
try:
|
|
348
|
+
client.release()
|
|
349
|
+
except (EspSdrTimeout, OSError):
|
|
350
|
+
pass
|
|
351
|
+
client.close()
|
|
352
|
+
return 0
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def cmd_convert(a: argparse.Namespace) -> int:
|
|
356
|
+
from snappnt.io import convert_iq
|
|
357
|
+
|
|
358
|
+
try:
|
|
359
|
+
base = convert_iq(
|
|
360
|
+
a.input,
|
|
361
|
+
a.output,
|
|
362
|
+
a.format,
|
|
363
|
+
a.rate_sps,
|
|
364
|
+
a.freq_hz,
|
|
365
|
+
a.device,
|
|
366
|
+
description=a.description,
|
|
367
|
+
overwrite=a.overwrite,
|
|
368
|
+
)
|
|
369
|
+
except (ValueError, FileExistsError, OSError) as e:
|
|
370
|
+
print(f"error: {e}", file=sys.stderr)
|
|
371
|
+
return 2
|
|
372
|
+
print(f"wrote {base}.sigmf-meta / .sigmf-data")
|
|
373
|
+
return 0
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _parse_gain(text: str) -> int:
|
|
377
|
+
try:
|
|
378
|
+
value = int(text)
|
|
379
|
+
except ValueError:
|
|
380
|
+
return -1
|
|
381
|
+
return value if value >= 0 else -1
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
385
|
+
p = argparse.ArgumentParser(prog="snappnt", description=__doc__.split("\n")[0])
|
|
386
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
387
|
+
|
|
388
|
+
s = sub.add_parser("info", help="list signals and devices")
|
|
389
|
+
s.set_defaults(func=cmd_info)
|
|
390
|
+
|
|
391
|
+
s = sub.add_parser("codes", help="print spreading-code properties")
|
|
392
|
+
s.add_argument("--signal", default="navic_s_sps")
|
|
393
|
+
s.add_argument("--prn", help="e.g. 1-14 or 1,5,9 (default: all)")
|
|
394
|
+
s.set_defaults(func=cmd_codes)
|
|
395
|
+
|
|
396
|
+
s = sub.add_parser("sim", help="generate a snapshot from a scenario")
|
|
397
|
+
s.add_argument("scenario")
|
|
398
|
+
s.add_argument("-o", "--output", help="output base path (default out/<scenario name>)")
|
|
399
|
+
s.add_argument("--hackrf", action="store_true", help="also write int8 file for hackrf_transfer")
|
|
400
|
+
s.add_argument("--uhd", action="store_true", help="also write sc16 file for UHD playback")
|
|
401
|
+
s.set_defaults(func=cmd_sim)
|
|
402
|
+
|
|
403
|
+
for name, func, helptext in (
|
|
404
|
+
("acquire", cmd_acquire, "acquire PRNs in a SigMF recording"),
|
|
405
|
+
("sweep", cmd_sweep, "detection probability vs C/N0"),
|
|
406
|
+
):
|
|
407
|
+
s = sub.add_parser(name, help=helptext)
|
|
408
|
+
if name == "acquire":
|
|
409
|
+
s.add_argument("file", help="SigMF base path, .sigmf-meta or .sigmf-data")
|
|
410
|
+
s.add_argument("--signal")
|
|
411
|
+
s.add_argument("--prn")
|
|
412
|
+
s.add_argument("--center", type=float, help="carrier offset in baseband [Hz]")
|
|
413
|
+
s.add_argument(
|
|
414
|
+
"--start-s",
|
|
415
|
+
type=float,
|
|
416
|
+
default=0.0,
|
|
417
|
+
help="start of the segment to acquire, from the first sample of the file [s]",
|
|
418
|
+
)
|
|
419
|
+
s.add_argument(
|
|
420
|
+
"--duration-s",
|
|
421
|
+
type=float,
|
|
422
|
+
help="length of the segment to acquire [s] (default: to the end of the file)",
|
|
423
|
+
)
|
|
424
|
+
else:
|
|
425
|
+
s.add_argument("scenario")
|
|
426
|
+
s.add_argument("--cn0", default="40:60:2", help="start:stop:step or list [dB-Hz]")
|
|
427
|
+
s.add_argument("--trials", type=int, default=20)
|
|
428
|
+
s.add_argument("--seed", type=int, default=0)
|
|
429
|
+
s.add_argument("-o", "--output", help="CSV path")
|
|
430
|
+
s.add_argument("--freq-span", type=float, default=50e3, help="search +/- [Hz]")
|
|
431
|
+
s.add_argument("--blocks", type=int, default=1, help="non-coherent blocks")
|
|
432
|
+
s.add_argument("--pfa", type=float, default=1e-3)
|
|
433
|
+
s.add_argument(
|
|
434
|
+
"--remove-dc",
|
|
435
|
+
choices=("none", "mean", "linear"),
|
|
436
|
+
default="none",
|
|
437
|
+
help="subtract the DC offset estimated from the snapshot before acquisition",
|
|
438
|
+
)
|
|
439
|
+
s.add_argument(
|
|
440
|
+
"--code-doppler",
|
|
441
|
+
action="store_true",
|
|
442
|
+
help="scale the replica chip rate with the frequency bin (direct reception only)",
|
|
443
|
+
)
|
|
444
|
+
s.set_defaults(func=func)
|
|
445
|
+
|
|
446
|
+
s = sub.add_parser("link-budget", help="input level and C/N0 for a conducted test")
|
|
447
|
+
s.add_argument(
|
|
448
|
+
"--gen-dbm",
|
|
449
|
+
type=float,
|
|
450
|
+
required=True,
|
|
451
|
+
help="generator output of the signal alone, without added noise [dBm]",
|
|
452
|
+
)
|
|
453
|
+
s.add_argument(
|
|
454
|
+
"--loss",
|
|
455
|
+
type=_parse_loss,
|
|
456
|
+
action="append",
|
|
457
|
+
metavar="[LABEL=]DB",
|
|
458
|
+
help="loss shared by both receivers [dB], repeatable",
|
|
459
|
+
)
|
|
460
|
+
s.add_argument(
|
|
461
|
+
"--esp32-loss",
|
|
462
|
+
type=_parse_loss,
|
|
463
|
+
action="append",
|
|
464
|
+
metavar="[LABEL=]DB",
|
|
465
|
+
help="loss on the ESP32 branch only (splitter output, attenuator, DC block) [dB]",
|
|
466
|
+
)
|
|
467
|
+
s.add_argument("--esp32-nf-db", type=float, required=True, help="ESP32 noise figure [dB]")
|
|
468
|
+
s.add_argument(
|
|
469
|
+
"--ref-loss",
|
|
470
|
+
type=_parse_loss,
|
|
471
|
+
action="append",
|
|
472
|
+
metavar="[LABEL=]DB",
|
|
473
|
+
help="loss on the reference receiver branch only [dB]",
|
|
474
|
+
)
|
|
475
|
+
s.add_argument("--ref-nf-db", type=float, help="reference receiver noise figure; enables it")
|
|
476
|
+
s.add_argument("--scenario-cn0-dbhz", type=float, help="C/N0 set in software, to check")
|
|
477
|
+
s.add_argument("--margin-db", type=float, default=10.0, help="required noise ratio [dB]")
|
|
478
|
+
s.set_defaults(func=cmd_link_budget)
|
|
479
|
+
|
|
480
|
+
s = sub.add_parser("capture", help="capture snapshots from an ESP-SDR board to SigMF")
|
|
481
|
+
s.add_argument("port", nargs="?", help="serial port (not needed with --dry-run)")
|
|
482
|
+
s.add_argument("--freq-hz", type=float, default=2492e6, help="whole MHz, 100 to 6000")
|
|
483
|
+
s.add_argument("--rate-sps", type=float, default=80e6)
|
|
484
|
+
s.add_argument("-n", "--samples", type=int, default=16380)
|
|
485
|
+
s.add_argument("--gain", default="auto", help="'auto' (hardware AGC) or a gain index")
|
|
486
|
+
s.add_argument(
|
|
487
|
+
"--bandwidth-mhz",
|
|
488
|
+
type=float,
|
|
489
|
+
help="analog bandwidth (default: unchanged, recorded as unknown)",
|
|
490
|
+
)
|
|
491
|
+
s.add_argument("--count", type=int, default=1, help="number of captures, one file each")
|
|
492
|
+
s.add_argument("-o", "--output", default="out/capture", help="output base path")
|
|
493
|
+
s.add_argument("--bits", type=int, choices=(8, 10), default=10)
|
|
494
|
+
s.add_argument("--overwrite", action="store_true", help="replace existing output files")
|
|
495
|
+
s.add_argument("--dry-run", action="store_true", help="print the commands, send nothing")
|
|
496
|
+
s.set_defaults(func=cmd_capture)
|
|
497
|
+
|
|
498
|
+
s = sub.add_parser("convert", help="convert a raw UHD or HackRF I/Q file to SigMF")
|
|
499
|
+
s.add_argument("input", help="raw interleaved I/Q file")
|
|
500
|
+
s.add_argument("-o", "--output", required=True, help="output base path")
|
|
501
|
+
s.add_argument(
|
|
502
|
+
"--format",
|
|
503
|
+
required=True,
|
|
504
|
+
choices=("uhd-short", "uhd-float", "hackrf"),
|
|
505
|
+
help="uhd-short: int16 and uhd-float: float32 (both little endian, as written on x86 "
|
|
506
|
+
"and ARM hosts); hackrf: signed int8",
|
|
507
|
+
)
|
|
508
|
+
s.add_argument("--rate-sps", type=float, required=True, help="sample rate of the recording")
|
|
509
|
+
s.add_argument("--freq-hz", type=float, required=True, help="centre frequency [Hz]")
|
|
510
|
+
s.add_argument("--device", required=True, help="recording device, stored in core:hw")
|
|
511
|
+
s.add_argument("--description", default="", help="free text stored in core:description")
|
|
512
|
+
s.add_argument("--overwrite", action="store_true", help="replace existing output files")
|
|
513
|
+
s.set_defaults(func=cmd_convert)
|
|
514
|
+
return p
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def main(argv: list[str] | None = None) -> int:
|
|
518
|
+
args = build_parser().parse_args(argv)
|
|
519
|
+
return int(args.func(args) or 0)
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
if __name__ == "__main__":
|
|
523
|
+
sys.exit(main())
|
snappnt/eval/__init__.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Input level and C/N0 for a conducted test (generator, attenuators, receiver).
|
|
2
|
+
|
|
3
|
+
All levels are in dBm, losses in dB (positive numbers), noise densities in dBm/Hz and C/N0 in
|
|
4
|
+
dB-Hz. The receiver's noise density is k*T0 + NF with T0 = 290 K, which is -174 dBm/Hz + NF
|
|
5
|
+
(the usual convention for a noise figure). The signal power is taken as the power in the
|
|
6
|
+
received band; implementation losses of the receiver (quantisation, filter loss) are ignored.
|
|
7
|
+
|
|
8
|
+
The generator power is the power of the signal alone. During playback of a file that also
|
|
9
|
+
contains software-added noise, a power meter reads signal plus noise, which is not this value.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import math
|
|
15
|
+
from collections.abc import Sequence
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
|
|
18
|
+
BOLTZMANN_J_PER_K = 1.380649e-23
|
|
19
|
+
REFERENCE_TEMPERATURE_K = 290.0
|
|
20
|
+
# Own choice, not a source value: a margin of 10 dB gives a C/N0 error of about 0.4 dB.
|
|
21
|
+
DEFAULT_MARGIN_DB = 10.0
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _finite(**values: float) -> None:
|
|
25
|
+
for name, v in values.items():
|
|
26
|
+
if not math.isfinite(v):
|
|
27
|
+
raise ValueError(f"{name} must be a finite number, got {v}")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _db_sum(levels_db: Sequence[float]) -> float:
|
|
31
|
+
return 10.0 * math.log10(sum(10.0 ** (x / 10.0) for x in levels_db))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def input_level_dbm(generator_dbm: float, losses_db: Sequence[float]) -> float:
|
|
35
|
+
"""Signal-only generator power minus the losses on the path (all losses >= 0 dB)."""
|
|
36
|
+
_finite(generator_dbm=generator_dbm, **{f"losses_db[{i}]": x for i, x in enumerate(losses_db)})
|
|
37
|
+
if any(x < 0 for x in losses_db):
|
|
38
|
+
raise ValueError("losses must be zero or positive (in dB)")
|
|
39
|
+
return generator_dbm - sum(losses_db)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def noise_density_dbm_hz(nf_db: float, temperature_k: float = REFERENCE_TEMPERATURE_K) -> float:
|
|
43
|
+
"""Receiver noise density 10*log10(k*T/1 mW per Hz) + NF; -174 dBm/Hz + NF at 290 K."""
|
|
44
|
+
_finite(nf_db=nf_db, temperature_k=temperature_k)
|
|
45
|
+
if temperature_k <= 0:
|
|
46
|
+
raise ValueError("temperature_k must be positive")
|
|
47
|
+
return 10.0 * math.log10(BOLTZMANN_J_PER_K * temperature_k / 1e-3) + nf_db
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def cn0_dbhz(p_in_dbm: float, nf_db: float) -> float:
|
|
51
|
+
"""C/N0 of a noise-free signal at p_in_dbm, limited by the receiver's own noise."""
|
|
52
|
+
_finite(p_in_dbm=p_in_dbm)
|
|
53
|
+
return p_in_dbm - noise_density_dbm_hz(nf_db)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class NoiseCheck:
|
|
58
|
+
"""Result of the software-noise check (noise is added in the playback file)."""
|
|
59
|
+
|
|
60
|
+
injected_dbm_hz: float # N0_inj = P_in - scenario C/N0
|
|
61
|
+
receiver_dbm_hz: float
|
|
62
|
+
ratio_db: float # injected minus receiver noise density
|
|
63
|
+
ok: bool # ratio_db >= margin_db
|
|
64
|
+
effective_cn0_dbhz: float # what the receiver sees with both noises
|
|
65
|
+
error_db: float # scenario C/N0 minus effective C/N0 (>= 0)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def check_injected_noise(
|
|
69
|
+
scenario_cn0_dbhz: float,
|
|
70
|
+
p_in_dbm: float,
|
|
71
|
+
nf_db: float,
|
|
72
|
+
margin_db: float = DEFAULT_MARGIN_DB,
|
|
73
|
+
) -> NoiseCheck:
|
|
74
|
+
"""Check that noise added in software dominates the receiver's own noise.
|
|
75
|
+
|
|
76
|
+
p_in_dbm is the signal-only power at the receiver input.
|
|
77
|
+
"""
|
|
78
|
+
_finite(
|
|
79
|
+
scenario_cn0_dbhz=scenario_cn0_dbhz, p_in_dbm=p_in_dbm, nf_db=nf_db, margin_db=margin_db
|
|
80
|
+
)
|
|
81
|
+
n_inj = p_in_dbm - scenario_cn0_dbhz
|
|
82
|
+
n_rx = noise_density_dbm_hz(nf_db)
|
|
83
|
+
ratio = n_inj - n_rx
|
|
84
|
+
effective = p_in_dbm - _db_sum([n_inj, n_rx])
|
|
85
|
+
return NoiseCheck(
|
|
86
|
+
n_inj, n_rx, ratio, ratio >= margin_db, effective, scenario_cn0_dbhz - effective
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True)
|
|
91
|
+
class LinkBudget:
|
|
92
|
+
"""One receiver branch."""
|
|
93
|
+
|
|
94
|
+
name: str
|
|
95
|
+
p_in_dbm: float
|
|
96
|
+
nf_db: float
|
|
97
|
+
noise_dbm_hz: float
|
|
98
|
+
cn0_dbhz: float # noise-free signal, receiver noise only
|
|
99
|
+
noise_check: NoiseCheck | None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def evaluate(
|
|
103
|
+
name: str,
|
|
104
|
+
generator_dbm: float,
|
|
105
|
+
losses_db: Sequence[float],
|
|
106
|
+
nf_db: float,
|
|
107
|
+
scenario_cn0_dbhz: float | None = None,
|
|
108
|
+
margin_db: float = DEFAULT_MARGIN_DB,
|
|
109
|
+
) -> LinkBudget:
|
|
110
|
+
p_in = input_level_dbm(generator_dbm, losses_db)
|
|
111
|
+
check = (
|
|
112
|
+
None
|
|
113
|
+
if scenario_cn0_dbhz is None
|
|
114
|
+
else check_injected_noise(scenario_cn0_dbhz, p_in, nf_db, margin_db)
|
|
115
|
+
)
|
|
116
|
+
return LinkBudget(name, p_in, nf_db, noise_density_dbm_hz(nf_db), cn0_dbhz(p_in, nf_db), check)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def evaluate_branches(
|
|
120
|
+
generator_dbm: float,
|
|
121
|
+
common_losses_db: Sequence[float],
|
|
122
|
+
branches: dict[str, tuple[Sequence[float], float]],
|
|
123
|
+
scenario_cn0_dbhz: float | None = None,
|
|
124
|
+
margin_db: float = DEFAULT_MARGIN_DB,
|
|
125
|
+
) -> list[LinkBudget]:
|
|
126
|
+
"""Evaluate several receivers; branches maps name -> (branch losses in dB, NF in dB)."""
|
|
127
|
+
return [
|
|
128
|
+
evaluate(
|
|
129
|
+
name,
|
|
130
|
+
generator_dbm,
|
|
131
|
+
[*common_losses_db, *losses],
|
|
132
|
+
nf_db,
|
|
133
|
+
scenario_cn0_dbhz,
|
|
134
|
+
margin_db,
|
|
135
|
+
)
|
|
136
|
+
for name, (losses, nf_db) in branches.items()
|
|
137
|
+
]
|