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
|
File without changes
|
cortexforge/cli/forge.py
ADDED
|
@@ -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}")
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from gnuradio import blocks, gr, uhd
|
|
2
|
+
from logging import getLogger
|
|
3
|
+
|
|
4
|
+
logger = getLogger(__name__)
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class RxRecorder(gr.top_block):
|
|
8
|
+
"""
|
|
9
|
+
GNU Radio top block for recording from a USRP device.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
def __init__(
|
|
13
|
+
self, usrp_args: str, freq: float, rate: float, gain: float, out_path: str
|
|
14
|
+
) -> None:
|
|
15
|
+
super().__init__("Rx Recorder")
|
|
16
|
+
|
|
17
|
+
self.rx_channel = 0
|
|
18
|
+
|
|
19
|
+
self.src = uhd.usrp_source(
|
|
20
|
+
usrp_args,
|
|
21
|
+
uhd.stream_args(cpu_format="fc32", channels=[self.rx_channel]),
|
|
22
|
+
)
|
|
23
|
+
self.src.set_clock_source("external", self.rx_channel)
|
|
24
|
+
self.src.set_time_source("external", self.rx_channel)
|
|
25
|
+
self.src.set_samp_rate(rate)
|
|
26
|
+
self.src.set_center_freq(freq, self.rx_channel)
|
|
27
|
+
self._try_set_rx_agc(False)
|
|
28
|
+
self.src.set_gain(gain, self.rx_channel)
|
|
29
|
+
self.src.set_antenna("TX/RX", self.rx_channel)
|
|
30
|
+
self.sink = blocks.file_sink(gr.sizeof_gr_complex, out_path, False)
|
|
31
|
+
self.sink.set_unbuffered(False)
|
|
32
|
+
self.connect(self.src, self.sink)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _try_set_rx_agc(self, enable: bool = False) -> None:
|
|
36
|
+
try:
|
|
37
|
+
self.src.set_rx_agc(enable)
|
|
38
|
+
except RuntimeError as e:
|
|
39
|
+
logger.warning("RX AGC not supported by this radio; msg: %s", str(e))
|
|
40
|
+
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from logging import getLogger
|
|
3
|
+
|
|
4
|
+
from cortexforge.forge.radio.tx_burst import TxTimeline
|
|
5
|
+
from cortexforge.forge.radio.waveforms import make_burst
|
|
6
|
+
from cortexforge.forge.utils.load_timeline import load_timeline
|
|
7
|
+
from cortexforge.forge.utils.node_identity import get_node_name
|
|
8
|
+
from cortexforge.forge.utils.sync_barrier.sync_config import SyncConfig
|
|
9
|
+
from cortexforge.forge.utils.sync_barrier.tx_barrier_client import TxBarrierClient
|
|
10
|
+
from cortexforge.forge.utils.uhd_time import arm_time_reset_next_pps
|
|
11
|
+
|
|
12
|
+
logger = getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def main(args):
|
|
16
|
+
node_name = get_node_name()
|
|
17
|
+
timeline_all = load_timeline(args.timeline)
|
|
18
|
+
timeline = [ev for ev in timeline_all if ev.get("radio") == node_name]
|
|
19
|
+
|
|
20
|
+
if not timeline:
|
|
21
|
+
logger.warning("No TX events found for node %s.", node_name)
|
|
22
|
+
return
|
|
23
|
+
|
|
24
|
+
# Prepare I/Q burst
|
|
25
|
+
events_with_iq = []
|
|
26
|
+
for ev in timeline:
|
|
27
|
+
iq = make_burst(
|
|
28
|
+
modulation=ev["modulation"],
|
|
29
|
+
sample_rate=ev["sample_rate_sps"],
|
|
30
|
+
symbol_rate=ev["symbol_rate"],
|
|
31
|
+
duration_s=ev["duration_s"],
|
|
32
|
+
rolloff=ev["rolloff"],
|
|
33
|
+
amplitude=ev["amplitude"],
|
|
34
|
+
).astype("complex64")
|
|
35
|
+
|
|
36
|
+
ev2 = dict(ev)
|
|
37
|
+
ev2["iq"] = iq
|
|
38
|
+
events_with_iq.append(ev2)
|
|
39
|
+
|
|
40
|
+
logger.info(
|
|
41
|
+
f"event start={ev['t_start_s']} dur={ev['duration_s']} "
|
|
42
|
+
f"modulation={ev['modulation']}"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
tb = TxTimeline(
|
|
46
|
+
usrp_args="",
|
|
47
|
+
rate=events_with_iq[0]["sample_rate_sps"],
|
|
48
|
+
center_freq=args.frequency,
|
|
49
|
+
gain=args.gain,
|
|
50
|
+
events_with_iq=events_with_iq,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
cfg = SyncConfig(server_host=args.record_node, port_reg=5555, port_pub=5556)
|
|
54
|
+
client = TxBarrierClient(cfg, node_name=node_name)
|
|
55
|
+
client.register()
|
|
56
|
+
|
|
57
|
+
msg = client.wait_broadcast()
|
|
58
|
+
assert msg["type"] == "GO"
|
|
59
|
+
|
|
60
|
+
# logger.info(f"UHD before reset: {tb.sink.get_time_now().get_real_secs():.6f} s")
|
|
61
|
+
|
|
62
|
+
arm_time_reset_next_pps(tb.sink)
|
|
63
|
+
# logger.info(f"UHD after reset: {tb.sink.get_time_now().get_real_secs():.6f} s")
|
|
64
|
+
|
|
65
|
+
tb.start()
|
|
66
|
+
# logger.info(f"UHD time at start: {tb.sink.get_time_now().get_real_secs():.6f} s")
|
|
67
|
+
|
|
68
|
+
# 5) Attendre jusqu’à la fin du dernier burst
|
|
69
|
+
last_ev = max(events_with_iq, key=lambda e: e["t_start_s"] + e["duration_s"])
|
|
70
|
+
t_end = float(last_ev["t_start_s"] + last_ev["duration_s"]) + 1.0
|
|
71
|
+
|
|
72
|
+
while tb.sink.get_time_now().get_real_secs() < t_end:
|
|
73
|
+
time.sleep(0.001)
|
|
74
|
+
|
|
75
|
+
tb.stop()
|
|
76
|
+
tb.wait()
|
|
77
|
+
logger.info("Timeline complete.")
|