cortexforge 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.
- cortexforge/cli/__init__.py +0 -0
- cortexforge/cli/forge.py +58 -0
- cortexforge/cli/planner.py +45 -0
- cortexforge/datasets/__init__.py +17 -0
- cortexforge/datasets/api.py +88 -0
- cortexforge/datasets/hash.py +11 -0
- cortexforge/datasets/local.py +87 -0
- cortexforge/datasets/manifest.py +68 -0
- cortexforge/datasets/types.py +33 -0
- cortexforge/forge/__init__.py +0 -0
- cortexforge/forge/main.py +25 -0
- cortexforge/forge/radio/__init__.py +0 -0
- cortexforge/forge/radio/rx.py +100 -0
- cortexforge/forge/radio/rx_recorder.py +40 -0
- cortexforge/forge/radio/tx.py +77 -0
- cortexforge/forge/radio/tx_burst.py +107 -0
- cortexforge/forge/radio/waveforms.py +42 -0
- cortexforge/forge/radio/waveforms_analog.py +85 -0
- cortexforge/forge/radio/waveforms_numerique.py +247 -0
- cortexforge/forge/utils/__init__.py +0 -0
- cortexforge/forge/utils/compute_baseline.py +64 -0
- cortexforge/forge/utils/load_timeline.py +36 -0
- cortexforge/forge/utils/node_identity.py +15 -0
- cortexforge/forge/utils/node_layout.py +45 -0
- cortexforge/forge/utils/sigmf/hash.py +10 -0
- cortexforge/forge/utils/sigmf/sigmf_annotations.py +90 -0
- cortexforge/forge/utils/sigmf/sigmf_captures.py +21 -0
- cortexforge/forge/utils/sigmf/sigmf_global.py +13 -0
- cortexforge/forge/utils/sigmf_writer.py +58 -0
- cortexforge/forge/utils/sync_barrier/rx_barrier_server.py +46 -0
- cortexforge/forge/utils/sync_barrier/sync_config.py +8 -0
- cortexforge/forge/utils/sync_barrier/tx_barrier_client.py +38 -0
- cortexforge/forge/utils/uhd_time.py +15 -0
- cortexforge/planner/__init__.py +0 -0
- cortexforge/planner/generators/__init__.py +0 -0
- cortexforge/planner/generators/cortexlab_scenario.py +44 -0
- cortexforge/planner/generators/experiment_scenario.py +191 -0
- cortexforge/planner/main.py +57 -0
- cortexforge/utils/__init__.py +0 -0
- cortexforge/utils/loader.py +35 -0
- cortexforge/utils/logger.py +21 -0
- cortexforge-0.1.0.dist-info/METADATA +16 -0
- cortexforge-0.1.0.dist-info/RECORD +46 -0
- cortexforge-0.1.0.dist-info/WHEEL +5 -0
- cortexforge-0.1.0.dist-info/entry_points.txt +3 -0
- cortexforge-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import pmt
|
|
3
|
+
import math
|
|
4
|
+
from gnuradio import gr, uhd
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class BurstScheduler(gr.sync_block):
|
|
8
|
+
"""
|
|
9
|
+
Sort un flux complexe et place des tags UHD:
|
|
10
|
+
- tx_time (PMT tuple (int_secs, frac_secs)) au 1er sample du burst
|
|
11
|
+
- tx_sob au 1er sample
|
|
12
|
+
- tx_eob au dernier sample
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__(self, events, sample_rate, amplitude_scale=1.0):
|
|
16
|
+
gr.sync_block.__init__(
|
|
17
|
+
self,
|
|
18
|
+
name="BurstScheduler",
|
|
19
|
+
in_sig=None,
|
|
20
|
+
out_sig=[np.complex64],
|
|
21
|
+
)
|
|
22
|
+
self.events = list(events)
|
|
23
|
+
self.fs = float(sample_rate)
|
|
24
|
+
self.amp_scale = float(amplitude_scale)
|
|
25
|
+
|
|
26
|
+
# Pré-génère tous les bursts
|
|
27
|
+
self.bursts = []
|
|
28
|
+
for ev in self.events:
|
|
29
|
+
iq = ev["iq"].astype(np.complex64) * self.amp_scale
|
|
30
|
+
t0 = float(ev["t_start_s"])
|
|
31
|
+
self.bursts.append((t0, iq))
|
|
32
|
+
|
|
33
|
+
self.cur_idx = 0
|
|
34
|
+
self.cur_pos = 0
|
|
35
|
+
self.finished = False
|
|
36
|
+
|
|
37
|
+
def _tag_tx_time(self, abs_offset, t0_s):
|
|
38
|
+
# UHD attend tx_time = (int_secs, frac_secs)
|
|
39
|
+
int_s = int(math.floor(t0_s))
|
|
40
|
+
frac_s = float(t0_s - int_s)
|
|
41
|
+
key = pmt.intern("tx_time")
|
|
42
|
+
val = pmt.make_tuple(pmt.from_long(int_s), pmt.from_double(frac_s))
|
|
43
|
+
self.add_item_tag(0, abs_offset, key, val)
|
|
44
|
+
|
|
45
|
+
def _tag_flag(self, abs_offset, key_str):
|
|
46
|
+
self.add_item_tag(0, abs_offset, pmt.intern(key_str), pmt.PMT_T)
|
|
47
|
+
|
|
48
|
+
def work(self, input_items, output_items):
|
|
49
|
+
out = output_items[0]
|
|
50
|
+
n = len(out)
|
|
51
|
+
out[:] = 0
|
|
52
|
+
|
|
53
|
+
if self.finished:
|
|
54
|
+
return -1 # Fin de flux
|
|
55
|
+
|
|
56
|
+
produced = 0
|
|
57
|
+
while produced < n and not self.finished:
|
|
58
|
+
if self.cur_idx >= len(self.bursts):
|
|
59
|
+
self.finished = True
|
|
60
|
+
break
|
|
61
|
+
|
|
62
|
+
t0_s, iq = self.bursts[self.cur_idx]
|
|
63
|
+
|
|
64
|
+
# Premier sample du burst -> tags SOB + tx_time
|
|
65
|
+
if self.cur_pos == 0:
|
|
66
|
+
abs_off = self.nitems_written(0) + produced
|
|
67
|
+
self._tag_tx_time(abs_off, t0_s)
|
|
68
|
+
self._tag_flag(abs_off, "tx_sob")
|
|
69
|
+
|
|
70
|
+
remaining = len(iq) - self.cur_pos
|
|
71
|
+
space = n - produced
|
|
72
|
+
k = min(remaining, space)
|
|
73
|
+
|
|
74
|
+
out[produced : produced + k] = iq[self.cur_pos : self.cur_pos + k]
|
|
75
|
+
self.cur_pos += k
|
|
76
|
+
produced += k
|
|
77
|
+
|
|
78
|
+
# Dernier sample du burst -> tag EOB sur ce sample
|
|
79
|
+
if self.cur_pos >= len(iq):
|
|
80
|
+
abs_off_last = self.nitems_written(0) + produced - 1
|
|
81
|
+
self._tag_flag(abs_off_last, "tx_eob")
|
|
82
|
+
self.cur_idx += 1
|
|
83
|
+
self.cur_pos = 0
|
|
84
|
+
|
|
85
|
+
return produced
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class TxTimeline(gr.top_block):
|
|
89
|
+
def __init__(self, usrp_args, rate, center_freq, gain, events_with_iq):
|
|
90
|
+
super().__init__("TxTimeline")
|
|
91
|
+
|
|
92
|
+
self.sink = uhd.usrp_sink(
|
|
93
|
+
usrp_args,
|
|
94
|
+
uhd.stream_args(cpu_format="fc32", channels=[0]),
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
self.sink.set_clock_source("external", 0)
|
|
98
|
+
self.sink.set_time_source("external", 0)
|
|
99
|
+
|
|
100
|
+
self.sink.set_samp_rate(rate)
|
|
101
|
+
self.sink.set_center_freq(center_freq, 0)
|
|
102
|
+
self.sink.set_gain(gain, 0)
|
|
103
|
+
self.sink.set_antenna("TX/RX", 0)
|
|
104
|
+
|
|
105
|
+
self.src = BurstScheduler(events_with_iq, sample_rate=rate)
|
|
106
|
+
|
|
107
|
+
self.connect(self.src, self.sink)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from cortexforge.forge.radio.waveforms_analog import make_analog_burst
|
|
2
|
+
from cortexforge.forge.radio.waveforms_numerique import make_digital_burst
|
|
3
|
+
|
|
4
|
+
MODULATION_ALIASES = {
|
|
5
|
+
"32QAM": "32QAM_RECT",
|
|
6
|
+
"128QAM": "128QAM_RECT",
|
|
7
|
+
"4PAM": "PAM4",
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
ANALOG_MODULATIONS = {"AM-DSB", "AM-SSB", "FM"}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def make_burst(
|
|
14
|
+
modulation: str,
|
|
15
|
+
sample_rate: float,
|
|
16
|
+
symbol_rate: float,
|
|
17
|
+
duration_s: float,
|
|
18
|
+
rolloff: float,
|
|
19
|
+
amplitude: float,
|
|
20
|
+
span_symbols: int = 11,
|
|
21
|
+
):
|
|
22
|
+
modulation = MODULATION_ALIASES.get(modulation.upper(), modulation.upper())
|
|
23
|
+
if modulation in ANALOG_MODULATIONS:
|
|
24
|
+
return make_analog_burst(
|
|
25
|
+
modulation=modulation,
|
|
26
|
+
sample_rate=sample_rate,
|
|
27
|
+
symbol_rate=symbol_rate,
|
|
28
|
+
duration_s=duration_s,
|
|
29
|
+
rolloff=rolloff,
|
|
30
|
+
amplitude=amplitude,
|
|
31
|
+
span_symbols=span_symbols,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
return make_digital_burst(
|
|
35
|
+
modulation=modulation,
|
|
36
|
+
sample_rate=sample_rate,
|
|
37
|
+
symbol_rate=symbol_rate,
|
|
38
|
+
duration_s=duration_s,
|
|
39
|
+
rolloff=rolloff,
|
|
40
|
+
amplitude=amplitude,
|
|
41
|
+
span_symbols=span_symbols,
|
|
42
|
+
)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
|
|
3
|
+
from cortexforge.forge.radio.waveforms_numerique import rrc_taps
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _analytic_signal(x: np.ndarray) -> np.ndarray:
|
|
7
|
+
n = len(x)
|
|
8
|
+
X = np.fft.fft(x.astype(np.float32))
|
|
9
|
+
h = np.zeros(n, dtype=np.float32)
|
|
10
|
+
|
|
11
|
+
if n % 2 == 0:
|
|
12
|
+
h[0] = 1.0
|
|
13
|
+
h[n // 2] = 1.0
|
|
14
|
+
h[1 : n // 2] = 2.0
|
|
15
|
+
else:
|
|
16
|
+
h[0] = 1.0
|
|
17
|
+
h[1 : (n + 1) // 2] = 2.0
|
|
18
|
+
|
|
19
|
+
return np.fft.ifft(X * h).astype(np.complex64)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _make_message(
|
|
23
|
+
sample_rate: float,
|
|
24
|
+
symbol_rate: float,
|
|
25
|
+
duration_s: float,
|
|
26
|
+
rolloff: float,
|
|
27
|
+
span_symbols: int,
|
|
28
|
+
) -> np.ndarray:
|
|
29
|
+
sps = int(round(sample_rate / symbol_rate))
|
|
30
|
+
if sps < 2:
|
|
31
|
+
raise ValueError(
|
|
32
|
+
f"sps too small (sample_rate/symbol_rate={sample_rate / symbol_rate:.2f}). Increase sample_rate or decrease symbol_rate."
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
nsamp = int(round(duration_s * sample_rate))
|
|
36
|
+
nsyms = int(np.ceil(nsamp / sps))
|
|
37
|
+
|
|
38
|
+
rng = np.random.default_rng()
|
|
39
|
+
msg_syms = rng.uniform(-1.0, 1.0, size=nsyms).astype(np.float32)
|
|
40
|
+
|
|
41
|
+
up = np.zeros(nsyms * sps, dtype=np.float32)
|
|
42
|
+
up[::sps] = msg_syms
|
|
43
|
+
taps = rrc_taps(rolloff, sps, span_symbols)
|
|
44
|
+
shaped = np.convolve(up, taps, mode="same")[:nsamp]
|
|
45
|
+
|
|
46
|
+
peak = np.max(np.abs(shaped))
|
|
47
|
+
if peak > 0:
|
|
48
|
+
shaped /= peak
|
|
49
|
+
return shaped.astype(np.float32)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def make_analog_burst(
|
|
53
|
+
modulation: str,
|
|
54
|
+
sample_rate: float,
|
|
55
|
+
symbol_rate: float,
|
|
56
|
+
duration_s: float,
|
|
57
|
+
rolloff: float,
|
|
58
|
+
amplitude: float,
|
|
59
|
+
span_symbols: int,
|
|
60
|
+
) -> np.ndarray:
|
|
61
|
+
msg = _make_message(
|
|
62
|
+
sample_rate=sample_rate,
|
|
63
|
+
symbol_rate=symbol_rate,
|
|
64
|
+
duration_s=duration_s,
|
|
65
|
+
rolloff=rolloff,
|
|
66
|
+
span_symbols=span_symbols,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
if modulation == "AM-DSB":
|
|
70
|
+
carrier_leak = 0.5
|
|
71
|
+
burst = (carrier_leak + 0.5 * msg).astype(np.complex64)
|
|
72
|
+
elif modulation == "AM-SSB":
|
|
73
|
+
burst = _analytic_signal(msg)
|
|
74
|
+
peak = np.max(np.abs(burst))
|
|
75
|
+
if peak > 0:
|
|
76
|
+
burst /= peak
|
|
77
|
+
elif modulation == "FM":
|
|
78
|
+
freq_dev_hz = min(symbol_rate * 0.25, sample_rate * 0.20)
|
|
79
|
+
phase = 2 * np.pi * freq_dev_hz * np.cumsum(msg, dtype=np.float64) / sample_rate
|
|
80
|
+
burst = np.exp(1j * phase).astype(np.complex64)
|
|
81
|
+
else:
|
|
82
|
+
raise ValueError(f"Unsupported analog modulation: {modulation}")
|
|
83
|
+
|
|
84
|
+
burst *= float(amplitude)
|
|
85
|
+
return burst.astype(np.complex64)
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def rrc_taps(beta: float, sps: int, span: int) -> np.ndarray:
|
|
5
|
+
"""
|
|
6
|
+
Root Raised Cosine taps.
|
|
7
|
+
span: in symbols (total taps = span*sps + 1)
|
|
8
|
+
"""
|
|
9
|
+
N = span * sps
|
|
10
|
+
t = np.arange(-N / 2, N / 2 + 1) / sps
|
|
11
|
+
taps = np.zeros_like(t, dtype=np.float64)
|
|
12
|
+
|
|
13
|
+
for i, ti in enumerate(t):
|
|
14
|
+
if abs(ti) < 1e-12:
|
|
15
|
+
taps[i] = 1.0 - beta + (4 * beta / np.pi)
|
|
16
|
+
elif beta > 0 and abs(abs(4 * beta * ti) - 1.0) < 1e-12:
|
|
17
|
+
# t = +- 1/(4beta)
|
|
18
|
+
taps[i] = (beta / np.sqrt(2)) * (
|
|
19
|
+
(1 + 2 / np.pi) * np.sin(np.pi / (4 * beta))
|
|
20
|
+
+ (1 - 2 / np.pi) * np.cos(np.pi / (4 * beta))
|
|
21
|
+
)
|
|
22
|
+
else:
|
|
23
|
+
num = np.sin(np.pi * ti * (1 - beta)) + 4 * beta * ti * np.cos(
|
|
24
|
+
np.pi * ti * (1 + beta)
|
|
25
|
+
)
|
|
26
|
+
den = np.pi * ti * (1 - (4 * beta * ti) ** 2)
|
|
27
|
+
taps[i] = num / den
|
|
28
|
+
|
|
29
|
+
taps /= np.sqrt(np.sum(taps**2) + 1e-12)
|
|
30
|
+
return taps.astype(np.float32)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def bits(rng, nbits: int) -> np.ndarray:
|
|
34
|
+
return rng.integers(0, 2, size=nbits, dtype=np.int8)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _pam_gray_levels(nbits: int) -> np.ndarray:
|
|
38
|
+
levels = np.arange(-(2**nbits - 1), 2**nbits, 2, dtype=np.float32)
|
|
39
|
+
gray = np.arange(2**nbits, dtype=np.int32) ^ (np.arange(2**nbits, dtype=np.int32) >> 1)
|
|
40
|
+
return levels[gray]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _bits_to_int(bb: np.ndarray) -> np.ndarray:
|
|
44
|
+
weights = (1 << np.arange(bb.shape[1] - 1, -1, -1)).astype(np.int32)
|
|
45
|
+
return (bb.astype(np.int32) * weights).sum(axis=1)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _psk_symbols(b: np.ndarray, bits_per_symbol: int) -> np.ndarray:
|
|
49
|
+
idx = _bits_to_int(b.reshape(-1, bits_per_symbol))
|
|
50
|
+
phase = 2 * np.pi * idx / (2**bits_per_symbol)
|
|
51
|
+
return np.exp(1j * phase).astype(np.complex64)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _qam_symbols(b: np.ndarray, i_bits: int, q_bits: int) -> np.ndarray:
|
|
55
|
+
b = b.reshape(-1, i_bits + q_bits)
|
|
56
|
+
i_levels = _pam_gray_levels(i_bits)
|
|
57
|
+
q_levels = _pam_gray_levels(q_bits)
|
|
58
|
+
i = i_levels[_bits_to_int(b[:, :i_bits])]
|
|
59
|
+
q = q_levels[_bits_to_int(b[:, i_bits:])]
|
|
60
|
+
x = i + 1j * q
|
|
61
|
+
x /= np.sqrt(np.mean(np.abs(x) ** 2))
|
|
62
|
+
return x.astype(np.complex64)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _cross_qam_constellation(grid_size: int, corner_levels_to_remove: int) -> np.ndarray:
|
|
66
|
+
levels = np.arange(-(grid_size - 1), grid_size, 2, dtype=np.float32)
|
|
67
|
+
corner_threshold = grid_size - 2 * corner_levels_to_remove
|
|
68
|
+
constellation = np.array(
|
|
69
|
+
[
|
|
70
|
+
i + 1j * q
|
|
71
|
+
for q in levels
|
|
72
|
+
for i in levels
|
|
73
|
+
if not (abs(i) >= corner_threshold and abs(q) >= corner_threshold)
|
|
74
|
+
],
|
|
75
|
+
dtype=np.complex64,
|
|
76
|
+
)
|
|
77
|
+
return constellation
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _cross_qam_symbols(
|
|
81
|
+
b: np.ndarray,
|
|
82
|
+
bits_per_symbol: int,
|
|
83
|
+
grid_size: int,
|
|
84
|
+
corner_levels_to_remove: int,
|
|
85
|
+
) -> np.ndarray:
|
|
86
|
+
b = b.reshape(-1, bits_per_symbol)
|
|
87
|
+
constellation = _cross_qam_constellation(grid_size, corner_levels_to_remove)
|
|
88
|
+
|
|
89
|
+
idx = _bits_to_int(b)
|
|
90
|
+
x = constellation[idx].astype(np.complex64)
|
|
91
|
+
x /= np.sqrt(np.mean(np.abs(x) ** 2))
|
|
92
|
+
return x.astype(np.complex64)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _cross_32qam_symbols(b: np.ndarray) -> np.ndarray:
|
|
96
|
+
return _cross_qam_symbols(b, bits_per_symbol=5, grid_size=6, corner_levels_to_remove=1)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _cross_128qam_symbols(b: np.ndarray) -> np.ndarray:
|
|
100
|
+
return _cross_qam_symbols(b, bits_per_symbol=7, grid_size=12, corner_levels_to_remove=2)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _ask_symbols(b: np.ndarray, bits_per_symbol: int) -> np.ndarray:
|
|
104
|
+
if bits_per_symbol == 1:
|
|
105
|
+
return b.astype(np.complex64)
|
|
106
|
+
|
|
107
|
+
levels = _pam_gray_levels(bits_per_symbol)
|
|
108
|
+
x = levels[_bits_to_int(b.reshape(-1, bits_per_symbol))].astype(np.float32)
|
|
109
|
+
x /= np.sqrt(np.mean(np.abs(x) ** 2))
|
|
110
|
+
return x.astype(np.complex64)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _gaussian_taps(bt: float, sps: int, span: int) -> np.ndarray:
|
|
114
|
+
"""
|
|
115
|
+
Unit-area Gaussian filter taps for GFSK frequency pulse shaping.
|
|
116
|
+
"""
|
|
117
|
+
n = span * sps
|
|
118
|
+
t = np.arange(-n / 2, n / 2 + 1, dtype=np.float64) / sps
|
|
119
|
+
alpha = np.sqrt(2 * np.pi) * bt / np.sqrt(np.log(2))
|
|
120
|
+
taps = np.exp(-2 * (np.pi**2) * (alpha**2) * (t**2)).astype(np.float64)
|
|
121
|
+
taps /= np.sum(taps) + 1e-12
|
|
122
|
+
return taps.astype(np.float32)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _cpfsk_like_burst(
|
|
126
|
+
symbols: np.ndarray,
|
|
127
|
+
sps: int,
|
|
128
|
+
nsamp: int,
|
|
129
|
+
amplitude: float,
|
|
130
|
+
modulation_index: float,
|
|
131
|
+
gaussian_bt: float | None = None,
|
|
132
|
+
gaussian_span: int = 4,
|
|
133
|
+
) -> np.ndarray:
|
|
134
|
+
up = np.repeat(symbols.astype(np.float32), sps)
|
|
135
|
+
|
|
136
|
+
if gaussian_bt is not None:
|
|
137
|
+
freq_pulse = np.convolve(
|
|
138
|
+
up,
|
|
139
|
+
_gaussian_taps(gaussian_bt, sps, gaussian_span),
|
|
140
|
+
mode="same",
|
|
141
|
+
)
|
|
142
|
+
else:
|
|
143
|
+
freq_pulse = up
|
|
144
|
+
|
|
145
|
+
phase = np.cumsum(freq_pulse, dtype=np.float64) * (np.pi * modulation_index / sps)
|
|
146
|
+
burst = np.exp(1j * phase[:nsamp]).astype(np.complex64)
|
|
147
|
+
burst *= float(amplitude)
|
|
148
|
+
return burst.astype(np.complex64)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def map_symbols(mod: str, b: np.ndarray) -> np.ndarray:
|
|
152
|
+
if mod == "OOK":
|
|
153
|
+
return _ask_symbols(b, 1)
|
|
154
|
+
if mod == "PAM4":
|
|
155
|
+
return _ask_symbols(b, 2)
|
|
156
|
+
if mod == "4ASK":
|
|
157
|
+
return _ask_symbols(b, 2)
|
|
158
|
+
if mod == "8ASK":
|
|
159
|
+
return _ask_symbols(b, 3)
|
|
160
|
+
if mod == "BPSK":
|
|
161
|
+
return _psk_symbols(b, 1)
|
|
162
|
+
if mod == "QPSK":
|
|
163
|
+
return _psk_symbols(b, 2)
|
|
164
|
+
if mod == "8PSK":
|
|
165
|
+
return _psk_symbols(b, 3)
|
|
166
|
+
if mod == "16PSK":
|
|
167
|
+
return _psk_symbols(b, 4)
|
|
168
|
+
if mod == "32PSK":
|
|
169
|
+
return _psk_symbols(b, 5)
|
|
170
|
+
if mod == "16QAM":
|
|
171
|
+
return _qam_symbols(b, 2, 2)
|
|
172
|
+
if mod == "32QAM_RECT":
|
|
173
|
+
return _qam_symbols(b, 3, 2)
|
|
174
|
+
if mod == "32QAM_CROSS":
|
|
175
|
+
return _cross_32qam_symbols(b)
|
|
176
|
+
if mod == "64QAM":
|
|
177
|
+
return _qam_symbols(b, 3, 3)
|
|
178
|
+
if mod == "128QAM_RECT":
|
|
179
|
+
return _qam_symbols(b, 4, 3)
|
|
180
|
+
if mod == "128QAM_CROSS":
|
|
181
|
+
return _cross_128qam_symbols(b)
|
|
182
|
+
if mod == "256QAM":
|
|
183
|
+
return _qam_symbols(b, 4, 4)
|
|
184
|
+
raise ValueError(f"Unsupported modulation: {mod}")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def make_digital_burst(
|
|
188
|
+
modulation: str,
|
|
189
|
+
sample_rate: float,
|
|
190
|
+
symbol_rate: float,
|
|
191
|
+
duration_s: float,
|
|
192
|
+
rolloff: float,
|
|
193
|
+
amplitude: float,
|
|
194
|
+
span_symbols: int,
|
|
195
|
+
) -> np.ndarray:
|
|
196
|
+
sps = int(round(sample_rate / symbol_rate))
|
|
197
|
+
if sps < 2:
|
|
198
|
+
raise ValueError(
|
|
199
|
+
f"sps too small (sample_rate/symbol_rate={sample_rate / symbol_rate:.2f}). Increase sample_rate or decrease symbol_rate."
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
nsamp = int(round(duration_s * sample_rate))
|
|
203
|
+
nsyms = int(np.ceil(nsamp / sps))
|
|
204
|
+
|
|
205
|
+
rng = np.random.default_rng()
|
|
206
|
+
if modulation in {"CPFSK", "GFSK"}:
|
|
207
|
+
b = bits(rng, nsyms)
|
|
208
|
+
symbols = (2 * b.astype(np.float32)) - 1.0
|
|
209
|
+
gaussian_bt = 0.35 if modulation == "GFSK" else None
|
|
210
|
+
return _cpfsk_like_burst(
|
|
211
|
+
symbols=symbols,
|
|
212
|
+
sps=sps,
|
|
213
|
+
nsamp=nsamp,
|
|
214
|
+
amplitude=amplitude,
|
|
215
|
+
modulation_index=0.5,
|
|
216
|
+
gaussian_bt=gaussian_bt,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
bps = {
|
|
220
|
+
"OOK": 1,
|
|
221
|
+
"PAM4": 2,
|
|
222
|
+
"4ASK": 2,
|
|
223
|
+
"8ASK": 3,
|
|
224
|
+
"BPSK": 1,
|
|
225
|
+
"QPSK": 2,
|
|
226
|
+
"8PSK": 3,
|
|
227
|
+
"16PSK": 4,
|
|
228
|
+
"32PSK": 5,
|
|
229
|
+
"16QAM": 4,
|
|
230
|
+
"32QAM_RECT": 5,
|
|
231
|
+
"32QAM_CROSS": 5,
|
|
232
|
+
"64QAM": 6,
|
|
233
|
+
"128QAM_RECT": 7,
|
|
234
|
+
"128QAM_CROSS": 7,
|
|
235
|
+
"256QAM": 8,
|
|
236
|
+
}[modulation]
|
|
237
|
+
b = bits(rng, nsyms * bps)
|
|
238
|
+
syms = map_symbols(modulation, b)
|
|
239
|
+
|
|
240
|
+
up = np.zeros(nsyms * sps, dtype=np.complex64)
|
|
241
|
+
up[::sps] = syms
|
|
242
|
+
taps = rrc_taps(rolloff, sps, span_symbols)
|
|
243
|
+
shaped = np.convolve(up, taps.astype(np.complex64), mode="same")
|
|
244
|
+
|
|
245
|
+
burst = shaped[:nsamp]
|
|
246
|
+
burst *= float(amplitude)
|
|
247
|
+
return burst.astype(np.complex64)
|
|
File without changes
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import math
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
MIN_LINEAR_POWER = np.finfo(np.float32).tiny
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _dbfs_from_mean_power(mean_power):
|
|
9
|
+
return 10.0 * math.log10(max(float(mean_power), MIN_LINEAR_POWER) / 2.0)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def measure_window_power(path, sample_start, sample_count):
|
|
13
|
+
"""
|
|
14
|
+
Measure the average power of a complex float32 IQ window.
|
|
15
|
+
|
|
16
|
+
Returns a dictionary describing the effective measured window.
|
|
17
|
+
"""
|
|
18
|
+
if sample_count <= 0:
|
|
19
|
+
raise ValueError("sample_count must be strictly positive")
|
|
20
|
+
|
|
21
|
+
offset_bytes = int(sample_start) * 2 * np.dtype(np.float32).itemsize
|
|
22
|
+
count_iq = int(sample_count) * 2
|
|
23
|
+
|
|
24
|
+
x = np.fromfile(path, dtype=np.float32, count=count_iq, offset=offset_bytes)
|
|
25
|
+
if x.size < 2:
|
|
26
|
+
raise ValueError("window contains no IQ samples")
|
|
27
|
+
|
|
28
|
+
i = x[0::2]
|
|
29
|
+
q = x[1::2]
|
|
30
|
+
effective_samples = min(i.size, q.size)
|
|
31
|
+
if effective_samples == 0:
|
|
32
|
+
raise ValueError("window contains incomplete IQ samples")
|
|
33
|
+
|
|
34
|
+
mean_power = float(np.mean(i[:effective_samples] * i[:effective_samples] + q[:effective_samples] * q[:effective_samples]))
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
"sample_start": int(sample_start),
|
|
38
|
+
"sample_count": int(effective_samples),
|
|
39
|
+
"mean_power": mean_power,
|
|
40
|
+
"power_dbfs": _dbfs_from_mean_power(mean_power),
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def compute_baseline(path, sample_rate, skip=0.5, win_size=1.0):
|
|
45
|
+
"""
|
|
46
|
+
Compute the baseline (noise mean) of the given data between skip and skip + win_size in seconds.
|
|
47
|
+
|
|
48
|
+
Parameters:
|
|
49
|
+
path: The input data for which to compute the baseline.
|
|
50
|
+
sample_rate: The sample rate of the data in samples per second.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
float: The computed baseline value.
|
|
54
|
+
"""
|
|
55
|
+
skip_samples = int(skip * sample_rate)
|
|
56
|
+
win_samples = int(win_size * sample_rate)
|
|
57
|
+
stats = measure_window_power(path, sample_start=skip_samples, sample_count=win_samples)
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
"skip_samples": skip_samples,
|
|
61
|
+
"win_samples": stats["sample_count"],
|
|
62
|
+
"mean_power": stats["mean_power"],
|
|
63
|
+
"power_dbfs": stats["power_dbfs"],
|
|
64
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import csv
|
|
2
|
+
from typing import Any, Dict, List
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def load_timeline(path: str) -> List[Dict[str, Any]]:
|
|
6
|
+
"""
|
|
7
|
+
Load a TX timeline CSV file.
|
|
8
|
+
|
|
9
|
+
Returns
|
|
10
|
+
-------
|
|
11
|
+
events : list of dict
|
|
12
|
+
Each dict contains the parsed parameters for one TX event.
|
|
13
|
+
"""
|
|
14
|
+
events = []
|
|
15
|
+
|
|
16
|
+
with open(path, newline="") as f:
|
|
17
|
+
reader = csv.DictReader(f)
|
|
18
|
+
for row in reader:
|
|
19
|
+
event = {
|
|
20
|
+
"radio": row["radio"],
|
|
21
|
+
"t_start_s": float(row["start_time"]),
|
|
22
|
+
"duration_s": float(row["duration_s"]),
|
|
23
|
+
"sample_rate_sps": int(row["sample_rate_sps"]),
|
|
24
|
+
"amplitude": float(row["amplitude"]),
|
|
25
|
+
"modulation": row["modulation"],
|
|
26
|
+
"symbol_rate": float(row["symbol_rate"]),
|
|
27
|
+
"rolloff": float(row["roll_off"]),
|
|
28
|
+
}
|
|
29
|
+
if row.get("freq_hz") not in (None, ""):
|
|
30
|
+
event["freq_hz"] = int(row["freq_hz"])
|
|
31
|
+
if row.get("tx_gain_db") not in (None, ""):
|
|
32
|
+
event["tx_gain_db"] = float(row["tx_gain_db"])
|
|
33
|
+
events.append(event)
|
|
34
|
+
|
|
35
|
+
events.sort(key=lambda e: e["t_start_s"])
|
|
36
|
+
return events
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Get node name identity."""
|
|
2
|
+
|
|
3
|
+
from socket import gethostname
|
|
4
|
+
from logging import getLogger
|
|
5
|
+
|
|
6
|
+
logger = getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def get_node_name() -> str:
|
|
10
|
+
try:
|
|
11
|
+
hostname = gethostname()
|
|
12
|
+
return hostname
|
|
13
|
+
except Exception as e:
|
|
14
|
+
logger.error(f"Error getting hostname: {e}")
|
|
15
|
+
return "unknown"
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
GRID_SPACING_M = 1.8
|
|
7
|
+
|
|
8
|
+
_NODE_GRID = {
|
|
9
|
+
1: (0, 0), 3: (1, 0), 5: (2, 0), 7: (3, 0), 9: (4, 0),
|
|
10
|
+
11: (5, 0), 13: (6, 0), 15: (7, 0), 17: (8, 0), 19: (9, 0),
|
|
11
|
+
|
|
12
|
+
2: (0, 1), 4: (1, 1), 6: (2, 1), 8: (3, 1), 10: (4, 1),
|
|
13
|
+
12: (5, 1), 14: (6, 1), 16: (7, 1), 18: (8, 1), 20: (9, 1),
|
|
14
|
+
|
|
15
|
+
21: (0, 2), 23: (1, 2), 25: (2, 2), 27: (3, 2), 29: (4, 2),
|
|
16
|
+
31: (5, 2), 33: (6, 2), 35: (7, 2), 37: (8, 2), 39: (9, 2),
|
|
17
|
+
|
|
18
|
+
22: (0, 3), 24: (1, 3), 26: (2, 3), 28: (3, 3), 30: (4, 3),
|
|
19
|
+
32: (5, 3), 34: (6, 3), 36: (7, 3), 38: (8, 3), 40: (9, 3),
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
NODE_COORDS_M = {
|
|
23
|
+
node_id: (col * GRID_SPACING_M, row * GRID_SPACING_M)
|
|
24
|
+
for node_id, (col, row) in _NODE_GRID.items()
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def parse_node_id(node_name: str) -> int:
|
|
29
|
+
match = re.search(r"(\d+)$", node_name)
|
|
30
|
+
if not match:
|
|
31
|
+
raise ValueError(f"Cannot extract node id from '{node_name}'")
|
|
32
|
+
return int(match.group(1))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_node_coords_m(node_name: str) -> tuple[float, float]:
|
|
36
|
+
node_id = parse_node_id(node_name)
|
|
37
|
+
if node_id not in NODE_COORDS_M:
|
|
38
|
+
raise KeyError(f"Unknown node id: {node_id}")
|
|
39
|
+
return NODE_COORDS_M[node_id]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def distance(tx_node: str, rx_node: str) -> float:
|
|
43
|
+
tx_x, tx_y = get_node_coords_m(tx_node)
|
|
44
|
+
rx_x, rx_y = get_node_coords_m(rx_node)
|
|
45
|
+
return round(math.hypot(tx_x - rx_x, tx_y - rx_y), 3)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
import hashlib
|
|
3
|
+
|
|
4
|
+
def _sha512_hex(path: Path, chunk_size: int = 1024 * 1024) -> str:
|
|
5
|
+
"""Compute SHA-512 hash of a file and return it as a hex string."""
|
|
6
|
+
h = hashlib.sha512()
|
|
7
|
+
with path.open("rb") as f:
|
|
8
|
+
for chunk in iter(lambda: f.read(chunk_size), b""):
|
|
9
|
+
h.update(chunk)
|
|
10
|
+
return h.hexdigest()
|