cortexforge 0.1.0__tar.gz
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-0.1.0/PKG-INFO +16 -0
- cortexforge-0.1.0/README.md +65 -0
- cortexforge-0.1.0/pyproject.toml +41 -0
- cortexforge-0.1.0/setup.cfg +4 -0
- cortexforge-0.1.0/src/cortexforge/cli/__init__.py +0 -0
- cortexforge-0.1.0/src/cortexforge/cli/forge.py +58 -0
- cortexforge-0.1.0/src/cortexforge/cli/planner.py +45 -0
- cortexforge-0.1.0/src/cortexforge/datasets/__init__.py +17 -0
- cortexforge-0.1.0/src/cortexforge/datasets/api.py +88 -0
- cortexforge-0.1.0/src/cortexforge/datasets/hash.py +11 -0
- cortexforge-0.1.0/src/cortexforge/datasets/local.py +87 -0
- cortexforge-0.1.0/src/cortexforge/datasets/manifest.py +68 -0
- cortexforge-0.1.0/src/cortexforge/datasets/types.py +33 -0
- cortexforge-0.1.0/src/cortexforge/forge/__init__.py +0 -0
- cortexforge-0.1.0/src/cortexforge/forge/main.py +25 -0
- cortexforge-0.1.0/src/cortexforge/forge/radio/__init__.py +0 -0
- cortexforge-0.1.0/src/cortexforge/forge/radio/rx.py +100 -0
- cortexforge-0.1.0/src/cortexforge/forge/radio/rx_recorder.py +40 -0
- cortexforge-0.1.0/src/cortexforge/forge/radio/tx.py +77 -0
- cortexforge-0.1.0/src/cortexforge/forge/radio/tx_burst.py +107 -0
- cortexforge-0.1.0/src/cortexforge/forge/radio/waveforms.py +42 -0
- cortexforge-0.1.0/src/cortexforge/forge/radio/waveforms_analog.py +85 -0
- cortexforge-0.1.0/src/cortexforge/forge/radio/waveforms_numerique.py +247 -0
- cortexforge-0.1.0/src/cortexforge/forge/utils/__init__.py +0 -0
- cortexforge-0.1.0/src/cortexforge/forge/utils/compute_baseline.py +64 -0
- cortexforge-0.1.0/src/cortexforge/forge/utils/load_timeline.py +36 -0
- cortexforge-0.1.0/src/cortexforge/forge/utils/node_identity.py +15 -0
- cortexforge-0.1.0/src/cortexforge/forge/utils/node_layout.py +45 -0
- cortexforge-0.1.0/src/cortexforge/forge/utils/sigmf/hash.py +10 -0
- cortexforge-0.1.0/src/cortexforge/forge/utils/sigmf/sigmf_annotations.py +90 -0
- cortexforge-0.1.0/src/cortexforge/forge/utils/sigmf/sigmf_captures.py +21 -0
- cortexforge-0.1.0/src/cortexforge/forge/utils/sigmf/sigmf_global.py +13 -0
- cortexforge-0.1.0/src/cortexforge/forge/utils/sigmf_writer.py +58 -0
- cortexforge-0.1.0/src/cortexforge/forge/utils/sync_barrier/rx_barrier_server.py +46 -0
- cortexforge-0.1.0/src/cortexforge/forge/utils/sync_barrier/sync_config.py +8 -0
- cortexforge-0.1.0/src/cortexforge/forge/utils/sync_barrier/tx_barrier_client.py +38 -0
- cortexforge-0.1.0/src/cortexforge/forge/utils/uhd_time.py +15 -0
- cortexforge-0.1.0/src/cortexforge/planner/__init__.py +0 -0
- cortexforge-0.1.0/src/cortexforge/planner/generators/__init__.py +0 -0
- cortexforge-0.1.0/src/cortexforge/planner/generators/cortexlab_scenario.py +44 -0
- cortexforge-0.1.0/src/cortexforge/planner/generators/experiment_scenario.py +191 -0
- cortexforge-0.1.0/src/cortexforge/planner/main.py +57 -0
- cortexforge-0.1.0/src/cortexforge/utils/__init__.py +0 -0
- cortexforge-0.1.0/src/cortexforge/utils/loader.py +35 -0
- cortexforge-0.1.0/src/cortexforge/utils/logger.py +21 -0
- cortexforge-0.1.0/src/cortexforge.egg-info/PKG-INFO +16 -0
- cortexforge-0.1.0/src/cortexforge.egg-info/SOURCES.txt +49 -0
- cortexforge-0.1.0/src/cortexforge.egg-info/dependency_links.txt +1 -0
- cortexforge-0.1.0/src/cortexforge.egg-info/entry_points.txt +3 -0
- cortexforge-0.1.0/src/cortexforge.egg-info/requires.txt +12 -0
- cortexforge-0.1.0/src/cortexforge.egg-info/top_level.txt +1 -0
|
@@ -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"
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# CorteXForge
|
|
2
|
+
This project is a framework designed to automate the generation and execution of radio dataset experiments on the [Slices/CorteXlab](https://www.cortexlab.fr/doku.php?id=start) testbed.
|
|
3
|
+
It relies on the [GNU Radio](https://www.gnuradio.org) environment to record labeled transmissions of various signals.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
This project is organized into two main components:
|
|
7
|
+
- Scenario generation: this part produces configuration files describing the experiment setup. It creates:
|
|
8
|
+
- a `scenario.yaml` file defining which nodes will be used on CorteXlab;
|
|
9
|
+
- an `timeline.csv` file orchestrating the role and sequence of these nodes.
|
|
10
|
+
- Experiment execution: this part deploys and executes the generates experiment definitions (`timeline.csv`) directly on the [Slices/CorteXlab](https://www.cortexlab.fr/doku.php?id=start) nodes.
|
|
11
|
+
|
|
12
|
+
## Quick start (User Guide) :rocket:
|
|
13
|
+
|
|
14
|
+
### 1. Scenario Generator
|
|
15
|
+
:warning: This module is implemented in Python 3.13 !
|
|
16
|
+
|
|
17
|
+
The scenario generator can be executed locally before deployment in Slices/CorteXlab. It allows configuration of experimental parameters such as:
|
|
18
|
+
- selected nodes to be used
|
|
19
|
+
- time of recording (in seconds)
|
|
20
|
+
|
|
21
|
+
### Example usage
|
|
22
|
+
- ```git clone https://github.com/Andreaj42/CorteXForge.git```
|
|
23
|
+
- ```python3.13 -m venv .venv```
|
|
24
|
+
- ```source .venv/bin/activate```
|
|
25
|
+
- ```pip install -e .[planner]```
|
|
26
|
+
- ```cortexforge-planner --nodes-path confis/nodes.yaml --duration 600 --output-path my/path/on/cortexlab```
|
|
27
|
+
|
|
28
|
+
### 2. Forge
|
|
29
|
+
:warning: This part must be executed directly on the Slices/CorteXlab testbed !
|
|
30
|
+
|
|
31
|
+
Each nodes defined before in the previous stage will run a GNU Radio flowgraph according to the configuration.
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
### Example usage
|
|
35
|
+
First, connect to the testbed:
|
|
36
|
+
- ```ssh username@gw.cortexlab.fr```
|
|
37
|
+
|
|
38
|
+
Next, book the testbed with your selected nodes (nodes: 5, 10, and 31 here) for the time of recording (increase the value):
|
|
39
|
+
- ```oarsub -l {"network_address in ('mnode5.cortexlab.fr', 'mnode10.cortexlab.fr', 'mnode31.cortexlab.fr')"}/nodes=3,walltime=0:20:00 -r "2025-10-12 21:03:00"```
|
|
40
|
+
|
|
41
|
+
To delete a job, use:
|
|
42
|
+
- ```oardel job_id```
|
|
43
|
+
|
|
44
|
+
And move the previously generated ```experiment``` folder into your **Cortexlab** home, then run:
|
|
45
|
+
- ```minus task create experiment -f```
|
|
46
|
+
- ```minus task submit experiment.task```
|
|
47
|
+
|
|
48
|
+
To monitor your experiment, use:
|
|
49
|
+
- ```minus testbed status```
|
|
50
|
+
- ```minus log -d```
|
|
51
|
+
|
|
52
|
+
## Developer Guide :hammer_and_wrench:
|
|
53
|
+
|
|
54
|
+
Now clone the project:
|
|
55
|
+
- ```git clone https://github.com/Andreaj42/CorteXForge.git```
|
|
56
|
+
- ```cd forge```
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
### Docker Images :whale:
|
|
60
|
+
To simplify deployment and ensure reproductibility, we generated a Docker image.
|
|
61
|
+
This image extends the standard CorteXlab toolchain and adds the required dependencies for forge.
|
|
62
|
+
|
|
63
|
+
## Useful links :link:
|
|
64
|
+
- [xp.cortexlab.fr](xp.cortexlab.fr/app)
|
|
65
|
+
- [wiki.cortexlab.fr](wiki.cortexlab.fr)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "cortexforge"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "RF generator dataset based on SLICES/CorteXlab"
|
|
9
|
+
authors = [
|
|
10
|
+
{ name = "Andrea Joly", email = "andrea.joly@inria.fr" }
|
|
11
|
+
]
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
dependencies = [
|
|
14
|
+
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
[project.optional-dependencies]
|
|
19
|
+
planner = [
|
|
20
|
+
"numpy==2.3.4",
|
|
21
|
+
"pandas==2.3.3",
|
|
22
|
+
"python-dateutil==2.9.0.post0",
|
|
23
|
+
"pytz==2025.2",
|
|
24
|
+
"PyYAML==6.0.3",
|
|
25
|
+
"six==1.17.0",
|
|
26
|
+
"tzdata==2025.2",
|
|
27
|
+
]
|
|
28
|
+
forge = [
|
|
29
|
+
"sigmf==1.2.12"
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
[project.scripts]
|
|
34
|
+
cortexforge-planner = "cortexforge.planner.main:main"
|
|
35
|
+
cortexforge-forge = "cortexforge.forge.main:main"
|
|
36
|
+
|
|
37
|
+
[tool.setuptools]
|
|
38
|
+
package-dir = {"" = "src"}
|
|
39
|
+
|
|
40
|
+
[tool.setuptools.packages.find]
|
|
41
|
+
where = ["src"]
|
|
File without changes
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""CLI argument parser for CorteXForge forge."""
|
|
2
|
+
|
|
3
|
+
from argparse import ArgumentParser, Namespace
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def build_parser() -> ArgumentParser:
|
|
8
|
+
"""
|
|
9
|
+
Build and configure the cli argument parser for CorteXForge forge.
|
|
10
|
+
|
|
11
|
+
Returns:
|
|
12
|
+
ArgumentParser: Configured argument parser instance with
|
|
13
|
+
"""
|
|
14
|
+
parser = ArgumentParser(prog="CorteXForge forge", description="Dataset Generator")
|
|
15
|
+
sub = parser.add_subparsers(
|
|
16
|
+
dest="role",
|
|
17
|
+
required=True,
|
|
18
|
+
help="Radio role for this node (rx=receiver, tx=transmitter)",
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
rx = sub.add_parser("rx", help="Receiver mode")
|
|
22
|
+
rx.add_argument(
|
|
23
|
+
"--frequency", type=int, required=True, help="Receiver center frequency (Hz)"
|
|
24
|
+
)
|
|
25
|
+
rx.add_argument(
|
|
26
|
+
"--sample-rate", type=int, required=True, help="Receiver sample rate (Sps)"
|
|
27
|
+
)
|
|
28
|
+
rx.add_argument("--gain", type=int, required=True, help="Receiver gain (dB)")
|
|
29
|
+
rx.add_argument(
|
|
30
|
+
"--duration", type=int, required=True, help="Capture duration (seconds)"
|
|
31
|
+
)
|
|
32
|
+
rx.add_argument("--timeline", type=Path, required=True, help="Path to timeline CSV")
|
|
33
|
+
rx.add_argument(
|
|
34
|
+
"--output-path",
|
|
35
|
+
type=Path,
|
|
36
|
+
required=True,
|
|
37
|
+
help=(
|
|
38
|
+
"Path to output directory for results "
|
|
39
|
+
"(e.g. /cortexlab/homes/<username>/out)"
|
|
40
|
+
),
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
tx = sub.add_parser("tx", help="Transmitter")
|
|
44
|
+
tx.add_argument("--timeline", type=Path, required=True, help="Path to timeline CSV")
|
|
45
|
+
tx.add_argument(
|
|
46
|
+
"--record-node", type=str, required=True, help="Select the recorder node"
|
|
47
|
+
)
|
|
48
|
+
tx.add_argument(
|
|
49
|
+
"--frequency", type=int, required=True, help="Transmitter center frequency (Hz)"
|
|
50
|
+
)
|
|
51
|
+
tx.add_argument("--gain", type=int, required=True, help="Transmitter gain (dB)")
|
|
52
|
+
return parser
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def parse_args(argv: list[str] | None = None) -> Namespace:
|
|
56
|
+
"""Parse command line arguments."""
|
|
57
|
+
parser = build_parser()
|
|
58
|
+
return parser.parse_args(argv)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""CLI argument parser for CorteXForge planner."""
|
|
2
|
+
|
|
3
|
+
from argparse import ArgumentParser, Namespace
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def build_parser() -> ArgumentParser:
|
|
8
|
+
"""
|
|
9
|
+
Build and configure the cli argument parser for CorteXForge planner.
|
|
10
|
+
|
|
11
|
+
Returns:
|
|
12
|
+
ArgumentParser: Configured argument parser instance with
|
|
13
|
+
"""
|
|
14
|
+
parser = ArgumentParser(prog="CorteXForge planner", description="Dataset Generator")
|
|
15
|
+
parser.add_argument(
|
|
16
|
+
"--username", required=True, type=str, help="username on CorteXlab"
|
|
17
|
+
)
|
|
18
|
+
parser.add_argument(
|
|
19
|
+
"--duration", type=int, default=60, help="Experiment duration in seconds"
|
|
20
|
+
)
|
|
21
|
+
parser.add_argument(
|
|
22
|
+
"--rx-frequency", type=int, default=2450000000, help="Receiver frequency"
|
|
23
|
+
)
|
|
24
|
+
parser.add_argument("--rx-gain", type=int, default=10, help="Receiver gain")
|
|
25
|
+
parser.add_argument(
|
|
26
|
+
"--rx-sample-rate", type=int, default=250000, help="Receiver sample-rate"
|
|
27
|
+
)
|
|
28
|
+
parser.add_argument(
|
|
29
|
+
"--overlapping",
|
|
30
|
+
action="store_true",
|
|
31
|
+
help="Allow overlapping signals in timeline",
|
|
32
|
+
)
|
|
33
|
+
parser.add_argument(
|
|
34
|
+
"--nodes-path",
|
|
35
|
+
type=Path,
|
|
36
|
+
default="configs/nodes.yaml",
|
|
37
|
+
help="Path to nodes.yaml file",
|
|
38
|
+
)
|
|
39
|
+
return parser
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def parse_args(argv: list[str] | None = None) -> Namespace:
|
|
43
|
+
"""Parse command line arguments."""
|
|
44
|
+
parser = build_parser()
|
|
45
|
+
return parser.parse_args(argv)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from .api import (
|
|
2
|
+
describe_dataset,
|
|
3
|
+
list_datasets,
|
|
4
|
+
list_datasets_with_versions,
|
|
5
|
+
list_versions,
|
|
6
|
+
load_dataset,
|
|
7
|
+
load_manifest,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"load_dataset",
|
|
12
|
+
"list_datasets",
|
|
13
|
+
"list_datasets_with_versions",
|
|
14
|
+
"describe_dataset",
|
|
15
|
+
"load_manifest",
|
|
16
|
+
"list_versions",
|
|
17
|
+
]
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from .local import load_local_dataset
|
|
4
|
+
from .manifest import load_manifest
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def load_dataset(
|
|
8
|
+
name: str,
|
|
9
|
+
version: str,
|
|
10
|
+
split: str,
|
|
11
|
+
root: str | Path,
|
|
12
|
+
verify: bool = True,
|
|
13
|
+
):
|
|
14
|
+
dataset_dir = Path(root) / name / version
|
|
15
|
+
return load_local_dataset(dataset_dir=dataset_dir, split=split, verify=verify)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def describe_dataset(
|
|
19
|
+
name: str,
|
|
20
|
+
version: str,
|
|
21
|
+
root: str | Path,
|
|
22
|
+
):
|
|
23
|
+
dataset_dir = Path(root) / name / version
|
|
24
|
+
return load_manifest(dataset_dir / "manifest.json")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def list_datasets(root: str | Path) -> list[str]:
|
|
28
|
+
root = Path(root)
|
|
29
|
+
|
|
30
|
+
if not root.exists():
|
|
31
|
+
return []
|
|
32
|
+
|
|
33
|
+
datasets = []
|
|
34
|
+
|
|
35
|
+
for dataset_dir in root.iterdir():
|
|
36
|
+
if not dataset_dir.is_dir():
|
|
37
|
+
continue
|
|
38
|
+
|
|
39
|
+
has_valid_version = any(
|
|
40
|
+
(version_dir / "manifest.json").exists()
|
|
41
|
+
for version_dir in dataset_dir.iterdir()
|
|
42
|
+
if version_dir.is_dir()
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
if has_valid_version:
|
|
46
|
+
datasets.append(dataset_dir.name)
|
|
47
|
+
|
|
48
|
+
return sorted(datasets)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def list_versions(name: str, root: str | Path) -> list[str]:
|
|
52
|
+
root = Path(root)
|
|
53
|
+
dataset_dir = root / name
|
|
54
|
+
|
|
55
|
+
if not dataset_dir.exists() or not dataset_dir.is_dir():
|
|
56
|
+
return []
|
|
57
|
+
|
|
58
|
+
versions = [
|
|
59
|
+
version_dir.name
|
|
60
|
+
for version_dir in dataset_dir.iterdir()
|
|
61
|
+
if version_dir.is_dir() and (version_dir / "manifest.json").exists()
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
return sorted(versions)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def list_datasets_with_versions(root: str | Path) -> dict[str, list[str]]:
|
|
68
|
+
root = Path(root)
|
|
69
|
+
|
|
70
|
+
if not root.exists():
|
|
71
|
+
return {}
|
|
72
|
+
|
|
73
|
+
result = {}
|
|
74
|
+
|
|
75
|
+
for dataset_dir in root.iterdir():
|
|
76
|
+
if not dataset_dir.is_dir():
|
|
77
|
+
continue
|
|
78
|
+
|
|
79
|
+
versions = [
|
|
80
|
+
version_dir.name
|
|
81
|
+
for version_dir in dataset_dir.iterdir()
|
|
82
|
+
if (version_dir / "manifest.json").exists()
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
if versions:
|
|
86
|
+
result[dataset_dir.name] = sorted(versions)
|
|
87
|
+
|
|
88
|
+
return result
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def _sha512_hex(path: Path, chunk_size: int = 1024 * 1024) -> str:
|
|
6
|
+
"""Compute SHA-512 hash of a file and return it as a hex string."""
|
|
7
|
+
h = hashlib.sha512()
|
|
8
|
+
with path.open("rb") as f:
|
|
9
|
+
for chunk in iter(lambda: f.read(chunk_size), b""):
|
|
10
|
+
h.update(chunk)
|
|
11
|
+
return h.hexdigest()
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from .hash import _sha512_hex
|
|
5
|
+
from .manifest import load_manifest
|
|
6
|
+
from .types import LocalDataset
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def validate_sigmf_recording(meta_path: Path) -> None:
|
|
10
|
+
if not meta_path.exists():
|
|
11
|
+
raise FileNotFoundError(f"Missing SigMF metadata: {meta_path}")
|
|
12
|
+
|
|
13
|
+
# Load JSON
|
|
14
|
+
with meta_path.open("r", encoding="utf-8") as f:
|
|
15
|
+
meta = json.load(f)
|
|
16
|
+
|
|
17
|
+
if "global" not in meta:
|
|
18
|
+
raise ValueError(f"Invalid SigMF meta (missing 'global'): {meta_path}")
|
|
19
|
+
|
|
20
|
+
global_meta = meta["global"]
|
|
21
|
+
|
|
22
|
+
# ---- core:data_file ----
|
|
23
|
+
data_file = global_meta.get("core:data_file")
|
|
24
|
+
if not data_file:
|
|
25
|
+
raise ValueError(f"Missing 'core:data_file' in {meta_path}")
|
|
26
|
+
|
|
27
|
+
data_path = meta_path.parent / data_file
|
|
28
|
+
|
|
29
|
+
if not data_path.exists():
|
|
30
|
+
raise FileNotFoundError(
|
|
31
|
+
f"SigMF data file not found: {data_path} (referenced in {meta_path})"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
# ---- SHA512 ----
|
|
35
|
+
expected_hash = global_meta.get("core:sha512")
|
|
36
|
+
|
|
37
|
+
if expected_hash:
|
|
38
|
+
actual_hash = _sha512_hex(data_path)
|
|
39
|
+
|
|
40
|
+
if actual_hash != expected_hash:
|
|
41
|
+
raise ValueError(
|
|
42
|
+
f"SHA512 mismatch for {data_path}\n"
|
|
43
|
+
f"Expected: {expected_hash}\n"
|
|
44
|
+
f"Got: {actual_hash}"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _associated_data_path(meta_path: Path) -> Path:
|
|
49
|
+
if meta_path.name.endswith(".sigmf-meta"):
|
|
50
|
+
return meta_path.with_name(meta_path.name.replace(".sigmf-meta", ".sigmf-data"))
|
|
51
|
+
raise ValueError(f"Expected a .sigmf-meta file, got: {meta_path}")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def load_local_dataset(
|
|
55
|
+
dataset_dir: str | Path,
|
|
56
|
+
split: str,
|
|
57
|
+
verify: bool = True,
|
|
58
|
+
) -> LocalDataset:
|
|
59
|
+
dataset_dir = Path(dataset_dir)
|
|
60
|
+
manifest = load_manifest(dataset_dir / "manifest.json")
|
|
61
|
+
|
|
62
|
+
if split not in manifest.splits:
|
|
63
|
+
raise ValueError(f"Unknown split: {split!r}")
|
|
64
|
+
|
|
65
|
+
recordings = []
|
|
66
|
+
for rel_path in manifest.splits[split].recordings:
|
|
67
|
+
meta_path = dataset_dir / rel_path
|
|
68
|
+
|
|
69
|
+
if verify:
|
|
70
|
+
validate_sigmf_recording(meta_path)
|
|
71
|
+
|
|
72
|
+
if not meta_path.exists():
|
|
73
|
+
raise FileNotFoundError(f"Missing SigMF metadata file: {meta_path}")
|
|
74
|
+
|
|
75
|
+
data_path = _associated_data_path(meta_path)
|
|
76
|
+
if not data_path.exists():
|
|
77
|
+
raise FileNotFoundError(f"Missing SigMF data file: {data_path}")
|
|
78
|
+
|
|
79
|
+
recordings.append(meta_path)
|
|
80
|
+
|
|
81
|
+
return LocalDataset(
|
|
82
|
+
name=manifest.name,
|
|
83
|
+
version=manifest.version,
|
|
84
|
+
split=split,
|
|
85
|
+
root=dataset_dir,
|
|
86
|
+
recordings=recordings,
|
|
87
|
+
)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from .types import DatasetManifest, GeneratorManifest, SplitManifest
|
|
5
|
+
|
|
6
|
+
REQUIRED_TOP_LEVEL_FIELDS = {"name", "version", "format", "generator", "splits"}
|
|
7
|
+
REQUIRED_GENERATOR_FIELDS = {"name", "version"}
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def validate_manifest(path: str | Path) -> None:
|
|
11
|
+
path = Path(path)
|
|
12
|
+
|
|
13
|
+
if not path.exists():
|
|
14
|
+
raise FileNotFoundError(f"Manifest not found: {path}")
|
|
15
|
+
|
|
16
|
+
with path.open("r", encoding="utf-8") as f:
|
|
17
|
+
raw = json.load(f)
|
|
18
|
+
|
|
19
|
+
missing = REQUIRED_TOP_LEVEL_FIELDS - raw.keys()
|
|
20
|
+
if missing:
|
|
21
|
+
raise ValueError(f"Missing required manifest fields: {sorted(missing)}")
|
|
22
|
+
|
|
23
|
+
generator = raw["generator"]
|
|
24
|
+
if not isinstance(generator, dict):
|
|
25
|
+
raise ValueError("'generator' must be a dictionary")
|
|
26
|
+
|
|
27
|
+
missing_generator = REQUIRED_GENERATOR_FIELDS - generator.keys()
|
|
28
|
+
if missing_generator:
|
|
29
|
+
raise ValueError(
|
|
30
|
+
f"Missing required generator fields: {sorted(missing_generator)}"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
splits = raw["splits"]
|
|
34
|
+
if not isinstance(splits, dict) or not splits:
|
|
35
|
+
raise ValueError("'splits' must be a non-empty dictionary")
|
|
36
|
+
|
|
37
|
+
for split_name, split_data in splits.items():
|
|
38
|
+
if not isinstance(split_data, dict):
|
|
39
|
+
raise ValueError(f"Split '{split_name}' must be a dictionary")
|
|
40
|
+
|
|
41
|
+
if "recordings" not in split_data:
|
|
42
|
+
raise ValueError(f"Split '{split_name}' is missing 'recordings'")
|
|
43
|
+
|
|
44
|
+
if not isinstance(split_data["recordings"], list):
|
|
45
|
+
raise ValueError(f"Split '{split_name}' field 'recordings' must be a list")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def load_manifest(path: str | Path) -> DatasetManifest:
|
|
49
|
+
path = Path(path)
|
|
50
|
+
validate_manifest(path)
|
|
51
|
+
with path.open("r", encoding="utf-8") as f:
|
|
52
|
+
raw = json.load(f)
|
|
53
|
+
|
|
54
|
+
splits = {
|
|
55
|
+
split_name: SplitManifest(recordings=split_data["recordings"])
|
|
56
|
+
for split_name, split_data in raw["splits"].items()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
generator = GeneratorManifest(**raw["generator"])
|
|
60
|
+
|
|
61
|
+
return DatasetManifest(
|
|
62
|
+
name=raw["name"],
|
|
63
|
+
version=raw["version"],
|
|
64
|
+
description=raw.get("description", ""),
|
|
65
|
+
format=raw["format"],
|
|
66
|
+
generator=generator,
|
|
67
|
+
splits=splits,
|
|
68
|
+
)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Dict, List
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(slots=True)
|
|
7
|
+
class SplitManifest:
|
|
8
|
+
recordings: List[str]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(slots=True)
|
|
12
|
+
class GeneratorManifest:
|
|
13
|
+
name: str
|
|
14
|
+
version: str
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(slots=True)
|
|
18
|
+
class DatasetManifest:
|
|
19
|
+
name: str
|
|
20
|
+
version: str
|
|
21
|
+
description: str
|
|
22
|
+
format: str
|
|
23
|
+
generator: GeneratorManifest
|
|
24
|
+
splits: Dict[str, SplitManifest]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(slots=True)
|
|
28
|
+
class LocalDataset:
|
|
29
|
+
name: str
|
|
30
|
+
version: str
|
|
31
|
+
split: str
|
|
32
|
+
root: Path
|
|
33
|
+
recordings: List[Path]
|
|
File without changes
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from cortexforge.cli.forge import parse_args
|
|
2
|
+
from cortexforge.utils.logger import setup_logger
|
|
3
|
+
|
|
4
|
+
logger = setup_logger()
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def main() -> None:
|
|
8
|
+
args = parse_args()
|
|
9
|
+
logger.info("Starting CorteXForge...")
|
|
10
|
+
logger.info(f"Args={args}")
|
|
11
|
+
|
|
12
|
+
if args.role == "rx":
|
|
13
|
+
from cortexforge.forge.radio.rx import main as rx_main
|
|
14
|
+
|
|
15
|
+
rx_main(args)
|
|
16
|
+
elif args.role == "tx":
|
|
17
|
+
from cortexforge.forge.radio.tx import main as tx_main
|
|
18
|
+
|
|
19
|
+
tx_main(args)
|
|
20
|
+
else:
|
|
21
|
+
raise ValueError(f"Unknown role: {args.role}")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
if __name__ == "__main__":
|
|
25
|
+
main()
|
|
File without changes
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
from logging import getLogger
|
|
2
|
+
from time import sleep
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from gnuradio import uhd
|
|
5
|
+
|
|
6
|
+
from cortexforge.forge.radio.rx_recorder import RxRecorder
|
|
7
|
+
from cortexforge.forge.utils.sigmf_writer import write_sigmf
|
|
8
|
+
from cortexforge.forge.utils.compute_baseline import compute_baseline
|
|
9
|
+
from cortexforge.forge.utils.load_timeline import load_timeline
|
|
10
|
+
from cortexforge.forge.utils.sync_barrier.rx_barrier_server import RxBarrierServer
|
|
11
|
+
from cortexforge.forge.utils.sync_barrier.sync_config import SyncConfig
|
|
12
|
+
from cortexforge.forge.utils.uhd_time import arm_time_reset_next_pps
|
|
13
|
+
from cortexforge.forge.utils.sigmf.sigmf_annotations import timeline_to_sigmf_annotations
|
|
14
|
+
from cortexforge.forge.utils.node_identity import get_node_name
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
logger = getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def main(args):
|
|
21
|
+
out_dir = args.output_path
|
|
22
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
23
|
+
|
|
24
|
+
timeline = load_timeline(args.timeline)
|
|
25
|
+
|
|
26
|
+
radios = {str(ev["radio"]) for ev in timeline if ev.get("radio")}
|
|
27
|
+
|
|
28
|
+
logger.debug(f"Radios in timeline: {radios}, expecting {len(radios)} to synchronize")
|
|
29
|
+
|
|
30
|
+
raw_path = out_dir / "temp.cf32"
|
|
31
|
+
|
|
32
|
+
tb = RxRecorder(
|
|
33
|
+
usrp_args="",
|
|
34
|
+
freq=args.frequency,
|
|
35
|
+
rate=args.sample_rate,
|
|
36
|
+
gain=args.gain,
|
|
37
|
+
out_path=str(raw_path),
|
|
38
|
+
)
|
|
39
|
+
cfg = SyncConfig(
|
|
40
|
+
server_host=get_node_name(),
|
|
41
|
+
port_reg=5555,
|
|
42
|
+
port_pub=5556,
|
|
43
|
+
expected_tx=len(radios),
|
|
44
|
+
)
|
|
45
|
+
barrier = RxBarrierServer(cfg)
|
|
46
|
+
barrier.wait_for_all()
|
|
47
|
+
|
|
48
|
+
barrier.broadcast({"type": "GO"})
|
|
49
|
+
arm_time_reset_next_pps(tb.src)
|
|
50
|
+
|
|
51
|
+
capture_start_uhd = 1.0
|
|
52
|
+
if hasattr(tb.src, "set_start_time"):
|
|
53
|
+
tb.src.set_start_time(uhd.time_spec(capture_start_uhd))
|
|
54
|
+
logger.info("RX stream scheduled at UHD t=%.3f s", capture_start_uhd)
|
|
55
|
+
rx_uhd_t0 = capture_start_uhd
|
|
56
|
+
else:
|
|
57
|
+
rx_uhd_t0 = None
|
|
58
|
+
logger.warning("RX source has no set_start_time(); using runtime t0 estimate")
|
|
59
|
+
|
|
60
|
+
tb.start()
|
|
61
|
+
if rx_uhd_t0 is None:
|
|
62
|
+
rx_uhd_t0 = tb.src.get_time_now().get_real_secs()
|
|
63
|
+
|
|
64
|
+
while tb.src.get_time_now().get_real_secs() < rx_uhd_t0 + args.duration:
|
|
65
|
+
sleep(0.001)
|
|
66
|
+
|
|
67
|
+
tb.stop()
|
|
68
|
+
tb.wait()
|
|
69
|
+
|
|
70
|
+
stats = compute_baseline(
|
|
71
|
+
path=str(raw_path),
|
|
72
|
+
sample_rate=args.sample_rate
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
stamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
|
|
76
|
+
base_path = out_dir / f"{stamp}"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
logger.info(f"Recording stats: {stats}")
|
|
80
|
+
data_path, meta_path = write_sigmf(
|
|
81
|
+
base_path=str(base_path),
|
|
82
|
+
data_file=str(raw_path),
|
|
83
|
+
stat=stats,
|
|
84
|
+
sample_rate=args.sample_rate,
|
|
85
|
+
center_freq=args.frequency,
|
|
86
|
+
hardware=tb.src.get_usrp_info().get('mboard_id'),
|
|
87
|
+
author="Andrea Joly",
|
|
88
|
+
description="CorteXForge recording",
|
|
89
|
+
gain=args.gain,
|
|
90
|
+
annotations = timeline_to_sigmf_annotations(
|
|
91
|
+
events=timeline,
|
|
92
|
+
rx_sample_rate=args.sample_rate,
|
|
93
|
+
rx_uhd_t0=rx_uhd_t0,
|
|
94
|
+
tx_center_freq=args.frequency,
|
|
95
|
+
tx_gain=args.gain,
|
|
96
|
+
rx_data_path=str(raw_path),
|
|
97
|
+
baseline_stat=stats,
|
|
98
|
+
),
|
|
99
|
+
)
|
|
100
|
+
logger.info(f"SigMF written: {data_path} and {meta_path}")
|