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.
Files changed (46) hide show
  1. cortexforge/cli/__init__.py +0 -0
  2. cortexforge/cli/forge.py +58 -0
  3. cortexforge/cli/planner.py +45 -0
  4. cortexforge/datasets/__init__.py +17 -0
  5. cortexforge/datasets/api.py +88 -0
  6. cortexforge/datasets/hash.py +11 -0
  7. cortexforge/datasets/local.py +87 -0
  8. cortexforge/datasets/manifest.py +68 -0
  9. cortexforge/datasets/types.py +33 -0
  10. cortexforge/forge/__init__.py +0 -0
  11. cortexforge/forge/main.py +25 -0
  12. cortexforge/forge/radio/__init__.py +0 -0
  13. cortexforge/forge/radio/rx.py +100 -0
  14. cortexforge/forge/radio/rx_recorder.py +40 -0
  15. cortexforge/forge/radio/tx.py +77 -0
  16. cortexforge/forge/radio/tx_burst.py +107 -0
  17. cortexforge/forge/radio/waveforms.py +42 -0
  18. cortexforge/forge/radio/waveforms_analog.py +85 -0
  19. cortexforge/forge/radio/waveforms_numerique.py +247 -0
  20. cortexforge/forge/utils/__init__.py +0 -0
  21. cortexforge/forge/utils/compute_baseline.py +64 -0
  22. cortexforge/forge/utils/load_timeline.py +36 -0
  23. cortexforge/forge/utils/node_identity.py +15 -0
  24. cortexforge/forge/utils/node_layout.py +45 -0
  25. cortexforge/forge/utils/sigmf/hash.py +10 -0
  26. cortexforge/forge/utils/sigmf/sigmf_annotations.py +90 -0
  27. cortexforge/forge/utils/sigmf/sigmf_captures.py +21 -0
  28. cortexforge/forge/utils/sigmf/sigmf_global.py +13 -0
  29. cortexforge/forge/utils/sigmf_writer.py +58 -0
  30. cortexforge/forge/utils/sync_barrier/rx_barrier_server.py +46 -0
  31. cortexforge/forge/utils/sync_barrier/sync_config.py +8 -0
  32. cortexforge/forge/utils/sync_barrier/tx_barrier_client.py +38 -0
  33. cortexforge/forge/utils/uhd_time.py +15 -0
  34. cortexforge/planner/__init__.py +0 -0
  35. cortexforge/planner/generators/__init__.py +0 -0
  36. cortexforge/planner/generators/cortexlab_scenario.py +44 -0
  37. cortexforge/planner/generators/experiment_scenario.py +191 -0
  38. cortexforge/planner/main.py +57 -0
  39. cortexforge/utils/__init__.py +0 -0
  40. cortexforge/utils/loader.py +35 -0
  41. cortexforge/utils/logger.py +21 -0
  42. cortexforge-0.1.0.dist-info/METADATA +16 -0
  43. cortexforge-0.1.0.dist-info/RECORD +46 -0
  44. cortexforge-0.1.0.dist-info/WHEEL +5 -0
  45. cortexforge-0.1.0.dist-info/entry_points.txt +3 -0
  46. 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()