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,90 @@
|
|
|
1
|
+
import math
|
|
2
|
+
|
|
3
|
+
from cortexforge.forge.utils.node_layout import distance
|
|
4
|
+
from cortexforge.forge.utils.node_identity import get_node_name
|
|
5
|
+
from cortexforge.forge.utils.compute_baseline import MIN_LINEAR_POWER, measure_window_power
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def make_sigmf_annotations(annotations):
|
|
9
|
+
return sorted(annotations, key=lambda a: a["core:sample_start"])
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def theoretical_bandwidth_hz(ev):
|
|
13
|
+
modulation = ev["modulation"].upper()
|
|
14
|
+
symbol_rate = float(ev["symbol_rate"])
|
|
15
|
+
rolloff = float(ev["rolloff"])
|
|
16
|
+
|
|
17
|
+
# Default occupied RF bandwidth.
|
|
18
|
+
bw = (1.0 + rolloff) * symbol_rate
|
|
19
|
+
|
|
20
|
+
if modulation == "AM-SSB":
|
|
21
|
+
return 0.5 * bw
|
|
22
|
+
# Règle de Carson
|
|
23
|
+
if modulation == "FM":
|
|
24
|
+
sample_rate = float(ev["sample_rate_sps"])
|
|
25
|
+
freq_dev_hz = min(symbol_rate * 0.25, sample_rate * 0.20)
|
|
26
|
+
message_bw = 0.5 * bw
|
|
27
|
+
return 2.0 * (freq_dev_hz + message_bw)
|
|
28
|
+
|
|
29
|
+
return bw
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def timeline_to_sigmf_annotations(
|
|
33
|
+
events, rx_sample_rate, rx_uhd_t0, tx_center_freq, tx_gain, rx_data_path=None, baseline_stat=None
|
|
34
|
+
):
|
|
35
|
+
ann = []
|
|
36
|
+
baseline_mean_power = None
|
|
37
|
+
if baseline_stat is not None:
|
|
38
|
+
baseline_mean_power = float(baseline_stat.get("mean_power", 0.0))
|
|
39
|
+
|
|
40
|
+
for ev in events:
|
|
41
|
+
start = int((ev["t_start_s"] - rx_uhd_t0) * rx_sample_rate)
|
|
42
|
+
count = int(ev["duration_s"] * rx_sample_rate)
|
|
43
|
+
|
|
44
|
+
modulation = ev["modulation"].upper()
|
|
45
|
+
bw = theoretical_bandwidth_hz(ev)
|
|
46
|
+
f0 = tx_center_freq
|
|
47
|
+
|
|
48
|
+
if modulation == "AM-SSB":
|
|
49
|
+
f_low = f0
|
|
50
|
+
f_high = f0 + bw
|
|
51
|
+
else:
|
|
52
|
+
f_low = f0 - bw / 2.0
|
|
53
|
+
f_high = f0 + bw / 2.0
|
|
54
|
+
|
|
55
|
+
annotation = {
|
|
56
|
+
"core:sample_start": start,
|
|
57
|
+
"core:sample_count": count,
|
|
58
|
+
"core:freq_lower_edge": f_low,
|
|
59
|
+
"core:freq_upper_edge": f_high,
|
|
60
|
+
"core:label": ev["modulation"],
|
|
61
|
+
"cortexforge:transmitter": ev["radio"],
|
|
62
|
+
"cortexforge:distance_m": distance(ev["radio"], get_node_name()),
|
|
63
|
+
"cortexforge:tx_gain_db": tx_gain,
|
|
64
|
+
"cortexforge:amplitude": ev["amplitude"],
|
|
65
|
+
"cortexforge:symbol_rate": ev["symbol_rate"],
|
|
66
|
+
"cortexforge:rolloff": ev["rolloff"],
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if rx_data_path is not None and baseline_mean_power is not None and start >= 0 and count > 0:
|
|
70
|
+
try:
|
|
71
|
+
burst_stats = measure_window_power(
|
|
72
|
+
rx_data_path,
|
|
73
|
+
sample_start=start,
|
|
74
|
+
sample_count=count,
|
|
75
|
+
)
|
|
76
|
+
total_mean_power = burst_stats["mean_power"]
|
|
77
|
+
signal_mean_power = max(total_mean_power - baseline_mean_power, MIN_LINEAR_POWER)
|
|
78
|
+
annotation["cortexforge:rx_total_power_dbfs"] = burst_stats["power_dbfs"]
|
|
79
|
+
annotation["cortexforge:rx_signal_power_dbfs"] = (
|
|
80
|
+
10.0 * math.log10(signal_mean_power / 2.0)
|
|
81
|
+
)
|
|
82
|
+
annotation["cortexforge:snr_db"] = 10.0 * math.log10(
|
|
83
|
+
signal_mean_power / max(baseline_mean_power, MIN_LINEAR_POWER)
|
|
84
|
+
)
|
|
85
|
+
except ValueError:
|
|
86
|
+
pass
|
|
87
|
+
|
|
88
|
+
ann.append(annotation)
|
|
89
|
+
|
|
90
|
+
return make_sigmf_annotations(ann)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from datetime import datetime, timezone
|
|
2
|
+
|
|
3
|
+
from cortexforge.forge.utils.node_identity import get_node_name
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def make_sigmf_captures(center_freq: float, gain: float, hardware: str, stat):
|
|
7
|
+
return [
|
|
8
|
+
{
|
|
9
|
+
"core:datetime": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
|
10
|
+
"core:frequency": float(center_freq),
|
|
11
|
+
"core:sample_start": 0,
|
|
12
|
+
"core:hw": hardware,
|
|
13
|
+
"cortexforge:node": get_node_name(),
|
|
14
|
+
"cortexforge:gain": gain,
|
|
15
|
+
"cortexforge:baseline": {
|
|
16
|
+
"sample_start": stat["skip_samples"],
|
|
17
|
+
"sample_count": stat["win_samples"],
|
|
18
|
+
"power_dbfs": stat["power_dbfs"],
|
|
19
|
+
},
|
|
20
|
+
}
|
|
21
|
+
]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from cortexforge.forge.utils.sigmf.hash import _sha512_hex
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def make_sigmf_global(author: str, sample_rate: float, data_path, description: str):
|
|
5
|
+
return {
|
|
6
|
+
"core:author": author,
|
|
7
|
+
"core:description": description,
|
|
8
|
+
"core:recorder": "CorteXForge",
|
|
9
|
+
"core:datatype": "cf32_le",
|
|
10
|
+
"core:sample_rate": float(sample_rate),
|
|
11
|
+
"core:data_file": data_path.name,
|
|
12
|
+
"core:sha512": _sha512_hex(data_path),
|
|
13
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from json import dumps
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from cortexforge.forge.utils.sigmf.sigmf_annotations import make_sigmf_annotations
|
|
5
|
+
from cortexforge.forge.utils.sigmf.sigmf_captures import make_sigmf_captures
|
|
6
|
+
from cortexforge.forge.utils.sigmf.sigmf_global import make_sigmf_global
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def write_sigmf(
|
|
10
|
+
base_path: str,
|
|
11
|
+
data_file: str,
|
|
12
|
+
sample_rate: float,
|
|
13
|
+
center_freq: float,
|
|
14
|
+
gain: float,
|
|
15
|
+
stat,
|
|
16
|
+
annotations,
|
|
17
|
+
hardware: str,
|
|
18
|
+
description: str = "CorteXForge capture",
|
|
19
|
+
author: str = "CorteXForge",
|
|
20
|
+
):
|
|
21
|
+
"""
|
|
22
|
+
base_path: path without extension
|
|
23
|
+
data_file: existing IQ file path (fc32 raw)
|
|
24
|
+
Creates:
|
|
25
|
+
- base_path.sigmf-data
|
|
26
|
+
- base_path.sigmf-meta
|
|
27
|
+
"""
|
|
28
|
+
base = Path(base_path)
|
|
29
|
+
data_src = Path(data_file)
|
|
30
|
+
|
|
31
|
+
meta_path = base.with_suffix(".sigmf-meta")
|
|
32
|
+
data_path = base.with_suffix(".sigmf-data")
|
|
33
|
+
|
|
34
|
+
# Move the data to .sigmf-data if needed
|
|
35
|
+
if data_src.resolve() != data_path.resolve():
|
|
36
|
+
data_path.parent.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
data_src.replace(data_path)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
meta = {
|
|
41
|
+
"global": make_sigmf_global(
|
|
42
|
+
author=author,
|
|
43
|
+
sample_rate=sample_rate,
|
|
44
|
+
data_path=data_path,
|
|
45
|
+
description=description,
|
|
46
|
+
),
|
|
47
|
+
"captures": make_sigmf_captures(
|
|
48
|
+
center_freq=center_freq,
|
|
49
|
+
gain=gain,
|
|
50
|
+
hardware=hardware,
|
|
51
|
+
stat=stat,
|
|
52
|
+
),
|
|
53
|
+
"annotations": make_sigmf_annotations(annotations),
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
meta_path.parent.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
meta_path.write_text(dumps(meta, indent=2))
|
|
58
|
+
return str(data_path), str(meta_path)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import time
|
|
3
|
+
import zmq
|
|
4
|
+
from logging import getLogger
|
|
5
|
+
from cortexforge.forge.utils.sync_barrier.sync_config import SyncConfig
|
|
6
|
+
|
|
7
|
+
logger = getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class RxBarrierServer:
|
|
11
|
+
def __init__(self, cfg: SyncConfig):
|
|
12
|
+
self.cfg = cfg
|
|
13
|
+
self.ctx = zmq.Context.instance()
|
|
14
|
+
|
|
15
|
+
self.rep = self.ctx.socket(zmq.REP)
|
|
16
|
+
self.rep.bind(f"tcp://*:{cfg.port_reg}")
|
|
17
|
+
logger.info("[RX][REP] bind tcp://*:%s", cfg.port_reg)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
self.pub = self.ctx.socket(zmq.PUB)
|
|
21
|
+
self.pub.bind(f"tcp://*:{cfg.port_pub}")
|
|
22
|
+
logger.info("[RX][PUB] bind tcp://*:%s", cfg.port_pub)
|
|
23
|
+
|
|
24
|
+
self.registered = set()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def wait_for_all(self):
|
|
28
|
+
logger.info("[RX] Waiting for %d TX registrations...", self.cfg.expected_tx)
|
|
29
|
+
while len(self.registered) < self.cfg.expected_tx:
|
|
30
|
+
raw = self.rep.recv()
|
|
31
|
+
now = time.time()
|
|
32
|
+
logger.info("[RX][REP][recv %.6f] %r", now, raw)
|
|
33
|
+
|
|
34
|
+
data = json.loads(raw.decode("utf-8"))
|
|
35
|
+
node = data.get("node", "unknown")
|
|
36
|
+
typ = data.get("type")
|
|
37
|
+
logger.info("[RX] HELLO from node=%s type=%s", node, typ)
|
|
38
|
+
|
|
39
|
+
self.registered.add(node)
|
|
40
|
+
reply = {"ok": True, "registered": sorted(self.registered)}
|
|
41
|
+
self.rep.send_string(json.dumps(reply))
|
|
42
|
+
logger.info("[RX][REP][send %.6f] %s", time.time(), reply)
|
|
43
|
+
|
|
44
|
+
def broadcast(self, payload: dict) -> None:
|
|
45
|
+
logger.info("[RX][PUB] broadcast %s", payload)
|
|
46
|
+
self.pub.send_string(json.dumps(payload))
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import time
|
|
3
|
+
import zmq
|
|
4
|
+
from logging import getLogger
|
|
5
|
+
from cortexforge.forge.utils.sync_barrier.sync_config import SyncConfig
|
|
6
|
+
|
|
7
|
+
logger = getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TxBarrierClient:
|
|
11
|
+
def __init__(self, cfg: SyncConfig, node_name: str):
|
|
12
|
+
self.cfg = cfg
|
|
13
|
+
self.node_name = node_name
|
|
14
|
+
self.ctx = zmq.Context.instance()
|
|
15
|
+
|
|
16
|
+
self.req = self.ctx.socket(zmq.REQ)
|
|
17
|
+
ep_req = f"tcp://{cfg.server_host}:{cfg.port_reg}"
|
|
18
|
+
self.req.connect(ep_req)
|
|
19
|
+
logger.info("[TX:%s][REQ] connect %s", node_name, ep_req)
|
|
20
|
+
|
|
21
|
+
self.sub = self.ctx.socket(zmq.SUB)
|
|
22
|
+
ep_sub = f"tcp://{cfg.server_host}:{cfg.port_pub}"
|
|
23
|
+
self.sub.connect(ep_sub)
|
|
24
|
+
self.sub.setsockopt_string(zmq.SUBSCRIBE, "")
|
|
25
|
+
logger.info("[TX:%s][SUB] connect %s + SUBSCRIBE ''", node_name, ep_sub)
|
|
26
|
+
|
|
27
|
+
def register(self):
|
|
28
|
+
msg = {"type": "HELLO", "node": self.node_name}
|
|
29
|
+
self.req.send_string(json.dumps(msg))
|
|
30
|
+
logger.info("[TX:%s][REQ][send %.6f] %s", self.node_name, time.time(), msg)
|
|
31
|
+
|
|
32
|
+
rep = self.req.recv_string()
|
|
33
|
+
logger.info("[TX:%s][REQ][recv %.6f] %s", self.node_name, time.time(), rep)
|
|
34
|
+
|
|
35
|
+
def wait_broadcast(self):
|
|
36
|
+
raw = self.sub.recv_string()
|
|
37
|
+
logger.info("[TX:%s][SUB][recv %.6f] %s", self.node_name, time.time(), raw)
|
|
38
|
+
return json.loads(raw)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from gnuradio import uhd
|
|
3
|
+
|
|
4
|
+
def wait_for_pps_edge(usrp_block, poll_s=0.0001):
|
|
5
|
+
last = usrp_block.get_time_last_pps().get_real_secs()
|
|
6
|
+
while True:
|
|
7
|
+
time.sleep(poll_s)
|
|
8
|
+
cur = usrp_block.get_time_last_pps().get_real_secs()
|
|
9
|
+
if cur != last:
|
|
10
|
+
return cur
|
|
11
|
+
|
|
12
|
+
def arm_time_reset_next_pps(usrp_block):
|
|
13
|
+
wait_for_pps_edge(usrp_block)
|
|
14
|
+
usrp_block.set_time_next_pps(uhd.time_spec(0.0))
|
|
15
|
+
wait_for_pps_edge(usrp_block)
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import List
|
|
4
|
+
|
|
5
|
+
from yaml import safe_dump
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def generate_cortexlab_scenario(
|
|
11
|
+
nodes: List[str],
|
|
12
|
+
duration: int,
|
|
13
|
+
image: str,
|
|
14
|
+
rx_command: str,
|
|
15
|
+
tx_command: str,
|
|
16
|
+
description: str = "Dataset Generator",
|
|
17
|
+
output_path: str = "scenario.yaml",
|
|
18
|
+
):
|
|
19
|
+
data = {"description": description, "duration": duration, "nodes": {}}
|
|
20
|
+
rx_node = nodes[0]
|
|
21
|
+
data["nodes"][rx_node.replace("mnode", "node")] = {
|
|
22
|
+
"container": [
|
|
23
|
+
{
|
|
24
|
+
"image": image,
|
|
25
|
+
"command": rx_command,
|
|
26
|
+
}
|
|
27
|
+
]
|
|
28
|
+
}
|
|
29
|
+
for node in nodes[1:]:
|
|
30
|
+
data["nodes"][node.replace("mnode", "node")] = {
|
|
31
|
+
"container": [
|
|
32
|
+
{
|
|
33
|
+
"image": image,
|
|
34
|
+
"command": tx_command,
|
|
35
|
+
}
|
|
36
|
+
]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
out_path = Path(output_path)
|
|
40
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
with out_path.open("w") as f:
|
|
42
|
+
safe_dump(data, f, sort_keys=False)
|
|
43
|
+
|
|
44
|
+
logger.info(f"Cortexlab scenario written to {out_path}")
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import random
|
|
2
|
+
from typing import List
|
|
3
|
+
|
|
4
|
+
import pandas as pd
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ExperimentScenario:
|
|
8
|
+
"""
|
|
9
|
+
Generate a pseudo-random experiment schedule as a CSV.
|
|
10
|
+
|
|
11
|
+
Constraints:
|
|
12
|
+
- Signals start no earlier than warmup_time.
|
|
13
|
+
- Signals must end before or at the total experiment duration.
|
|
14
|
+
- The receiver node and its params are fixed for the whole experiment.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
nodes: List[str],
|
|
20
|
+
duration: float,
|
|
21
|
+
rx_sample_rate: int,
|
|
22
|
+
warmup_time: float = 2.0,
|
|
23
|
+
):
|
|
24
|
+
if len(nodes) < 2:
|
|
25
|
+
raise ValueError("You must provide at least one RX node and one TX node.")
|
|
26
|
+
|
|
27
|
+
if warmup_time >= duration:
|
|
28
|
+
raise ValueError("warmup_time must be strictly less than total duration.")
|
|
29
|
+
|
|
30
|
+
self.nodes = nodes
|
|
31
|
+
self.duration = duration
|
|
32
|
+
self.rx_sample_rate = rx_sample_rate
|
|
33
|
+
self.warmup_time = warmup_time
|
|
34
|
+
self.rx_node = nodes[0]
|
|
35
|
+
self.tx_nodes = nodes[1:]
|
|
36
|
+
|
|
37
|
+
self.modulations = [
|
|
38
|
+
"AM-DSB",
|
|
39
|
+
"AM-SSB",
|
|
40
|
+
"FM",
|
|
41
|
+
"OOK",
|
|
42
|
+
"PAM4",
|
|
43
|
+
"4ASK",
|
|
44
|
+
"8ASK",
|
|
45
|
+
"BPSK",
|
|
46
|
+
"QPSK",
|
|
47
|
+
"8PSK",
|
|
48
|
+
"16PSK",
|
|
49
|
+
"32PSK",
|
|
50
|
+
"CPFSK",
|
|
51
|
+
"GFSK",
|
|
52
|
+
"16QAM",
|
|
53
|
+
"32QAM_RECT",
|
|
54
|
+
"32QAM_CROSS",
|
|
55
|
+
"64QAM",
|
|
56
|
+
"128QAM_RECT",
|
|
57
|
+
"128QAM_CROSS",
|
|
58
|
+
"256QAM",
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
self.duration_range_s = (0.002, 0.020)
|
|
62
|
+
|
|
63
|
+
@staticmethod
|
|
64
|
+
def _intervals_overlap(
|
|
65
|
+
start_a: float,
|
|
66
|
+
duration_a: float,
|
|
67
|
+
start_b: float,
|
|
68
|
+
duration_b: float,
|
|
69
|
+
) -> bool:
|
|
70
|
+
end_a = start_a + duration_a
|
|
71
|
+
end_b = start_b + duration_b
|
|
72
|
+
return start_a < end_b and start_b < end_a
|
|
73
|
+
|
|
74
|
+
def _find_non_overlapping_start(
|
|
75
|
+
self,
|
|
76
|
+
signal_duration: float,
|
|
77
|
+
scheduled_intervals: List[tuple],
|
|
78
|
+
rng: random.Random,
|
|
79
|
+
max_attempts: int = 1000,
|
|
80
|
+
) -> float:
|
|
81
|
+
latest_start = self.duration - signal_duration
|
|
82
|
+
if latest_start < self.warmup_time:
|
|
83
|
+
raise ValueError(
|
|
84
|
+
"Signal duration is too large for the available experiment window."
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
for _ in range(max_attempts):
|
|
88
|
+
candidate_start = round(rng.uniform(self.warmup_time, latest_start), 6)
|
|
89
|
+
|
|
90
|
+
has_overlap = any(
|
|
91
|
+
self._intervals_overlap(
|
|
92
|
+
candidate_start,
|
|
93
|
+
signal_duration,
|
|
94
|
+
existing_start,
|
|
95
|
+
existing_duration,
|
|
96
|
+
)
|
|
97
|
+
for existing_start, existing_duration in scheduled_intervals
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
if not has_overlap:
|
|
101
|
+
return candidate_start
|
|
102
|
+
|
|
103
|
+
raise RuntimeError(
|
|
104
|
+
"Unable to place a non-overlapping signal. "
|
|
105
|
+
"Try reducing n_signals, reducing signal durations, "
|
|
106
|
+
"or increasing the experiment duration."
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
def generate_table(
|
|
110
|
+
self,
|
|
111
|
+
n_signals: int = 100,
|
|
112
|
+
allow_overlap: bool = True,
|
|
113
|
+
seed: int | None = None,
|
|
114
|
+
) -> pd.DataFrame:
|
|
115
|
+
"""
|
|
116
|
+
Generate a pandas DataFrame with the experiment timeline.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
n_signals: Number of signals to generate.
|
|
120
|
+
allow_overlap: If False, generated signals will not overlap in time.
|
|
121
|
+
seed: Optional seed for reproducibility.
|
|
122
|
+
"""
|
|
123
|
+
rng = random.Random(seed)
|
|
124
|
+
|
|
125
|
+
rows = []
|
|
126
|
+
scheduled_intervals = []
|
|
127
|
+
|
|
128
|
+
for _ in range(n_signals):
|
|
129
|
+
signal_node = rng.choice(self.tx_nodes)
|
|
130
|
+
signal_duration = round(rng.uniform(*self.duration_range_s), 6)
|
|
131
|
+
signal_modulation = rng.choice(self.modulations)
|
|
132
|
+
|
|
133
|
+
if allow_overlap:
|
|
134
|
+
signal_start_time = round(
|
|
135
|
+
rng.uniform(self.warmup_time, self.duration - signal_duration), 6
|
|
136
|
+
)
|
|
137
|
+
else:
|
|
138
|
+
signal_start_time = self._find_non_overlapping_start(
|
|
139
|
+
signal_duration=signal_duration,
|
|
140
|
+
scheduled_intervals=scheduled_intervals,
|
|
141
|
+
rng=rng,
|
|
142
|
+
)
|
|
143
|
+
scheduled_intervals.append((signal_start_time, signal_duration))
|
|
144
|
+
|
|
145
|
+
rows.append(
|
|
146
|
+
{
|
|
147
|
+
"radio": signal_node,
|
|
148
|
+
"start_time": signal_start_time,
|
|
149
|
+
"duration_s": signal_duration,
|
|
150
|
+
"modulation": signal_modulation,
|
|
151
|
+
"amplitude": 0.5,
|
|
152
|
+
"roll_off": 0.35,
|
|
153
|
+
"symbol_rate": 3_125_000,
|
|
154
|
+
"sample_rate_sps": 25_000_000,
|
|
155
|
+
}
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
df = pd.DataFrame(
|
|
159
|
+
rows,
|
|
160
|
+
columns=[
|
|
161
|
+
"radio",
|
|
162
|
+
"start_time",
|
|
163
|
+
"duration_s",
|
|
164
|
+
"modulation",
|
|
165
|
+
"amplitude",
|
|
166
|
+
"roll_off",
|
|
167
|
+
"symbol_rate",
|
|
168
|
+
"sample_rate_sps",
|
|
169
|
+
],
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
df = df.sort_values("start_time").reset_index(drop=True)
|
|
173
|
+
return df
|
|
174
|
+
|
|
175
|
+
def to_csv(
|
|
176
|
+
self,
|
|
177
|
+
output_path: str,
|
|
178
|
+
n_signals: int = 100,
|
|
179
|
+
allow_overlap: bool = True,
|
|
180
|
+
seed: int | None = None,
|
|
181
|
+
):
|
|
182
|
+
"""
|
|
183
|
+
Generate the table and write it directly to a CSV file.
|
|
184
|
+
"""
|
|
185
|
+
df = self.generate_table(
|
|
186
|
+
n_signals=n_signals,
|
|
187
|
+
allow_overlap=allow_overlap,
|
|
188
|
+
seed=seed,
|
|
189
|
+
)
|
|
190
|
+
df.index.name = "id"
|
|
191
|
+
df.to_csv(output_path)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from cortexforge.cli.planner import parse_args
|
|
2
|
+
from cortexforge.planner.generators.cortexlab_scenario import (
|
|
3
|
+
generate_cortexlab_scenario,
|
|
4
|
+
)
|
|
5
|
+
from cortexforge.planner.generators.experiment_scenario import ExperimentScenario
|
|
6
|
+
from cortexforge.utils.loader import load_nodes
|
|
7
|
+
from cortexforge.utils.logger import setup_logger
|
|
8
|
+
|
|
9
|
+
logger = setup_logger()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def main() -> None:
|
|
13
|
+
logger.info("Starting scenario generation...")
|
|
14
|
+
args = parse_args()
|
|
15
|
+
nodes = load_nodes(args.nodes_path)
|
|
16
|
+
|
|
17
|
+
scenario = ExperimentScenario(
|
|
18
|
+
nodes=nodes,
|
|
19
|
+
duration=args.duration,
|
|
20
|
+
rx_sample_rate=args.rx_sample_rate,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
df = scenario.generate_table(n_signals=100, allow_overlap=args.overlapping, seed=42)
|
|
24
|
+
print(df.head())
|
|
25
|
+
|
|
26
|
+
scenario.to_csv(
|
|
27
|
+
"configs/timeline.csv", n_signals=100, allow_overlap=args.overlapping, seed=42
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
generate_cortexlab_scenario(
|
|
31
|
+
nodes=nodes,
|
|
32
|
+
duration=args.duration + 30,
|
|
33
|
+
image="ghcr.io/andreaj42/cortexforge:latest",
|
|
34
|
+
rx_command=(
|
|
35
|
+
f'bash -lc "cortexforge-forge rx '
|
|
36
|
+
f"--duration {args.duration} "
|
|
37
|
+
f"--frequency {args.rx_frequency} "
|
|
38
|
+
f"--gain {args.rx_gain} "
|
|
39
|
+
f"--sample-rate {args.rx_sample_rate} "
|
|
40
|
+
f"--output-path /cortexlab/homes/{args.username}/out/ "
|
|
41
|
+
f'--timeline /cortexlab/homes/{args.username}/timeline.csv"'
|
|
42
|
+
),
|
|
43
|
+
tx_command=(
|
|
44
|
+
f'bash -lc "cortexforge-forge tx '
|
|
45
|
+
f"--timeline /cortexlab/homes/{args.username}/timeline.csv "
|
|
46
|
+
f"--record-node {nodes[0]} "
|
|
47
|
+
f"--frequency {args.rx_frequency} "
|
|
48
|
+
f'--gain {args.rx_gain}"'
|
|
49
|
+
),
|
|
50
|
+
description="Dataset Generator",
|
|
51
|
+
output_path="configs/scenario.yaml",
|
|
52
|
+
)
|
|
53
|
+
logger.info("Scenario generation completed.")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
if __name__ == "__main__":
|
|
57
|
+
main()
|
|
File without changes
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import yaml
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def load_yaml(path: Path) -> dict:
|
|
10
|
+
"""Load a YAML file and return its contents as a dictionary."""
|
|
11
|
+
if not path.exists():
|
|
12
|
+
raise FileNotFoundError(f"File not found: {path}")
|
|
13
|
+
with path.open("r") as f:
|
|
14
|
+
return yaml.safe_load(f)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def load_nodes(path: Path = "configs/nodes.yaml") -> list[str]:
|
|
18
|
+
"""
|
|
19
|
+
Load node IDs from a YAML configuration configs/nodes.yaml file.
|
|
20
|
+
|
|
21
|
+
Expected YAML format:
|
|
22
|
+
nodes:
|
|
23
|
+
- id: mnode1
|
|
24
|
+
- id: mnode2
|
|
25
|
+
...
|
|
26
|
+
"""
|
|
27
|
+
data = load_yaml(path)
|
|
28
|
+
|
|
29
|
+
nodes = [node["id"] for node in data["nodes"]]
|
|
30
|
+
|
|
31
|
+
if len(nodes) < 2:
|
|
32
|
+
raise ValueError("At least two nodes are required")
|
|
33
|
+
|
|
34
|
+
logger.info("Loaded %d nodes: %s", len(nodes), nodes)
|
|
35
|
+
return nodes
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def setup_logger(level: int = logging.INFO) -> logging.Logger:
|
|
5
|
+
"""
|
|
6
|
+
Configure logging for CorteXforge.
|
|
7
|
+
"""
|
|
8
|
+
logger = logging.getLogger()
|
|
9
|
+
logger.setLevel(level)
|
|
10
|
+
|
|
11
|
+
if not logger.handlers:
|
|
12
|
+
console_handler = logging.StreamHandler()
|
|
13
|
+
console_handler.setLevel(level)
|
|
14
|
+
formatter = logging.Formatter(
|
|
15
|
+
"[%(asctime)s] [%(levelname)s][%(filename)s]: %(message)s",
|
|
16
|
+
datefmt="%Y-%m-%d %H:%M:%S",
|
|
17
|
+
)
|
|
18
|
+
console_handler.setFormatter(formatter)
|
|
19
|
+
logger.addHandler(console_handler)
|
|
20
|
+
|
|
21
|
+
return logger
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cortexforge
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: RF generator dataset based on SLICES/CorteXlab
|
|
5
|
+
Author-email: Andrea Joly <andrea.joly@inria.fr>
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Provides-Extra: planner
|
|
8
|
+
Requires-Dist: numpy==2.3.4; extra == "planner"
|
|
9
|
+
Requires-Dist: pandas==2.3.3; extra == "planner"
|
|
10
|
+
Requires-Dist: python-dateutil==2.9.0.post0; extra == "planner"
|
|
11
|
+
Requires-Dist: pytz==2025.2; extra == "planner"
|
|
12
|
+
Requires-Dist: PyYAML==6.0.3; extra == "planner"
|
|
13
|
+
Requires-Dist: six==1.17.0; extra == "planner"
|
|
14
|
+
Requires-Dist: tzdata==2025.2; extra == "planner"
|
|
15
|
+
Provides-Extra: forge
|
|
16
|
+
Requires-Dist: sigmf==1.2.12; extra == "forge"
|