dapps 0.0.7__tar.gz → 0.0.8__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.
- {dapps-0.0.7 → dapps-0.0.8}/.github/workflows/mirror.yml +1 -1
- {dapps-0.0.7 → dapps-0.0.8}/.github/workflows/tag-and-release.yml +3 -3
- dapps-0.0.8/.github/workflows/test.yml +24 -0
- dapps-0.0.8/CONTRIBUTORS +44 -0
- {dapps-0.0.7 → dapps-0.0.8}/PKG-INFO +15 -4
- {dapps-0.0.7 → dapps-0.0.8}/README.md +13 -2
- dapps-0.0.8/VERSION +1 -0
- {dapps-0.0.7 → dapps-0.0.8}/examples/spectrum_dapp.py +11 -1
- {dapps-0.0.7 → dapps-0.0.8}/pyproject.toml +4 -2
- {dapps-0.0.7 → dapps-0.0.8}/src/iq_saver/iq_saver.py +45 -7
- {dapps-0.0.7 → dapps-0.0.8}/src/spectrum/spectrum_dapp.py +21 -0
- {dapps-0.0.7 → dapps-0.0.8}/src/visualization/dashboard.py +72 -13
- {dapps-0.0.7 → dapps-0.0.8}/src/visualization/templates/index.html +120 -4
- dapps-0.0.8/tests/test_dashboard.py +43 -0
- {dapps-0.0.7 → dapps-0.0.8}/tests/test_iq_saver.py +58 -3
- dapps-0.0.7/VERSION +0 -1
- {dapps-0.0.7 → dapps-0.0.8}/.gitignore +0 -0
- {dapps-0.0.7 → dapps-0.0.8}/CONTRIBUTING.md +0 -0
- {dapps-0.0.7 → dapps-0.0.8}/LICENSE +0 -0
- {dapps-0.0.7 → dapps-0.0.8}/MANIFEST.in +0 -0
- {dapps-0.0.7 → dapps-0.0.8}/docs/dapp_uml.txt +0 -0
- {dapps-0.0.7 → dapps-0.0.8}/docs/gui_uml.txt +0 -0
- {dapps-0.0.7 → dapps-0.0.8}/examples/nvidia_dapp.py +0 -0
- {dapps-0.0.7 → dapps-0.0.8}/examples/simple_dapp.py +0 -0
- {dapps-0.0.7 → dapps-0.0.8}/src/dapp/__init__.py +0 -0
- {dapps-0.0.7 → dapps-0.0.8}/src/dapp/dapp.py +0 -0
- {dapps-0.0.7 → dapps-0.0.8}/src/e3interface/__init__.py +0 -0
- {dapps-0.0.7 → dapps-0.0.8}/src/e3interface/defs/e3.asn +0 -0
- {dapps-0.0.7 → dapps-0.0.8}/src/e3interface/defs/e3.json +0 -0
- {dapps-0.0.7 → dapps-0.0.8}/src/e3interface/e3_connector.py +0 -0
- {dapps-0.0.7 → dapps-0.0.8}/src/e3interface/e3_encoder.py +0 -0
- {dapps-0.0.7 → dapps-0.0.8}/src/e3interface/e3_interface.py +0 -0
- {dapps-0.0.7 → dapps-0.0.8}/src/e3interface/e3_logging.py +0 -0
- {dapps-0.0.7 → dapps-0.0.8}/src/iq_saver/__init__.py +0 -0
- {dapps-0.0.7 → dapps-0.0.8}/src/nvidia/__init__.py +0 -0
- {dapps-0.0.7 → dapps-0.0.8}/src/nvidia/defs/e3sm_nvidia.asn +0 -0
- {dapps-0.0.7 → dapps-0.0.8}/src/nvidia/defs/e3sm_nvidia.json +0 -0
- {dapps-0.0.7 → dapps-0.0.8}/src/nvidia/nvidia_dapp.py +0 -0
- {dapps-0.0.7 → dapps-0.0.8}/src/simple/__init__.py +0 -0
- {dapps-0.0.7 → dapps-0.0.8}/src/simple/defs/e3sm_simple.asn +0 -0
- {dapps-0.0.7 → dapps-0.0.8}/src/simple/simple_dapp.py +0 -0
- {dapps-0.0.7 → dapps-0.0.8}/src/spectrum/__init__.py +0 -0
- {dapps-0.0.7 → dapps-0.0.8}/src/spectrum/adaptive_noise_floor.py +0 -0
- {dapps-0.0.7 → dapps-0.0.8}/src/spectrum/defs/e3sm_spectrum.asn +0 -0
- {dapps-0.0.7 → dapps-0.0.8}/src/spectrum/defs/e3sm_spectrum.json +0 -0
- {dapps-0.0.7 → dapps-0.0.8}/src/spectrum/embargo_manager.py +0 -0
- {dapps-0.0.7 → dapps-0.0.8}/src/spectrum/threshold_detector.py +0 -0
- {dapps-0.0.7 → dapps-0.0.8}/src/visualization/__init__.py +0 -0
- {dapps-0.0.7 → dapps-0.0.8}/src/visualization/energy.py +0 -0
- {dapps-0.0.7 → dapps-0.0.8}/src/visualization/grafana.py +0 -0
- {dapps-0.0.7 → dapps-0.0.8}/src/visualization/influx.py +0 -0
- {dapps-0.0.7 → dapps-0.0.8}/src/visualization/iq.py +0 -0
- {dapps-0.0.7 → dapps-0.0.8}/tools/README.md +0 -0
- {dapps-0.0.7 → dapps-0.0.8}/tools/asntojson.py +0 -0
- {dapps-0.0.7 → dapps-0.0.8}/tools/generate_sm.sh +0 -0
- {dapps-0.0.7 → dapps-0.0.8}/tools/read_recording.py +0 -0
|
@@ -13,7 +13,7 @@ jobs:
|
|
|
13
13
|
tag_created: ${{ steps.set_output.outputs.tag_created }}
|
|
14
14
|
steps:
|
|
15
15
|
- name: Checkout repository
|
|
16
|
-
uses: actions/checkout@
|
|
16
|
+
uses: actions/checkout@v6
|
|
17
17
|
with:
|
|
18
18
|
fetch-depth: 0 # Ensure we fetch all tags
|
|
19
19
|
|
|
@@ -54,11 +54,11 @@ jobs:
|
|
|
54
54
|
needs: create-tag
|
|
55
55
|
if: ${{ needs.create-tag.outputs.tag_created == 'true' && github.repository == 'wineslab/dApp-library' }}
|
|
56
56
|
steps:
|
|
57
|
-
- uses: actions/checkout@
|
|
57
|
+
- uses: actions/checkout@v6
|
|
58
58
|
with:
|
|
59
59
|
persist-credentials: false
|
|
60
60
|
- name: Set up Python
|
|
61
|
-
uses: actions/setup-python@
|
|
61
|
+
uses: actions/setup-python@v6
|
|
62
62
|
with:
|
|
63
63
|
python-version: "3.8"
|
|
64
64
|
- name: Install pypa/build
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
name: Run tests
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
pull_request:
|
|
5
|
+
branches:
|
|
6
|
+
- main
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
if: github.repository != 'wineslab/dApp-library'
|
|
12
|
+
steps:
|
|
13
|
+
- uses: actions/checkout@v6
|
|
14
|
+
|
|
15
|
+
- name: Set up Python
|
|
16
|
+
uses: actions/setup-python@v6
|
|
17
|
+
with:
|
|
18
|
+
python-version: "3.12"
|
|
19
|
+
|
|
20
|
+
- name: Install hatch
|
|
21
|
+
run: pip install hatch
|
|
22
|
+
|
|
23
|
+
- name: Run tests
|
|
24
|
+
run: hatch run pytest tests/ -v
|
dapps-0.0.8/CONTRIBUTORS
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Author
|
|
2
|
+
|
|
3
|
+
- Andrea Lacava <thecave003@gmail.com>
|
|
4
|
+
- Initial dApp framework design and implementation
|
|
5
|
+
- E3 ASN.1 protocol definitions and encoder
|
|
6
|
+
- E3 connection setup and JSON/E3AP dual encoding
|
|
7
|
+
- Structured E3SM for multi-use-case support
|
|
8
|
+
- Spectrum Sharing dApp and IQ saver with SigMF compliance
|
|
9
|
+
- IQ saver file rotation
|
|
10
|
+
- Dashboard GUI and visualization
|
|
11
|
+
- Multi-PRB and multi-frequency support
|
|
12
|
+
- PyPI packaging and release workflow
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# Contributors
|
|
16
|
+
|
|
17
|
+
Listed in chronological order of first contribution.
|
|
18
|
+
We are grateful for every contribution, big or small, that helped shape this project.
|
|
19
|
+
|
|
20
|
+
- Leonardo Bonati <38320780+leonardobonati@users.noreply.github.com>
|
|
21
|
+
- Debug print cleanup and comments
|
|
22
|
+
|
|
23
|
+
- Michele Polese <michele.polese@gmail.com>
|
|
24
|
+
- README improvements and paper reference
|
|
25
|
+
|
|
26
|
+
- Filippo Olimpieri <126257005+Ninjabippo1205@users.noreply.github.com>
|
|
27
|
+
- Spectrum Classification dApp via libiq integration
|
|
28
|
+
- E2SM-dApp report message and xApp control action
|
|
29
|
+
- dAppReport generation for the Spectrum SM
|
|
30
|
+
- Scheduler control support
|
|
31
|
+
|
|
32
|
+
- Chris Wozny <woznych@gmail.com>
|
|
33
|
+
- Adaptive noise floor logic
|
|
34
|
+
- DC leakage filtering in PRB blacklist computation
|
|
35
|
+
- Visualization fixes
|
|
36
|
+
|
|
37
|
+
- Antonio Scarvaglieri <20925479+absentio@users.noreply.github.com>
|
|
38
|
+
- dApp deregistration and RAN function unsubscribe
|
|
39
|
+
- Graceful shutdown and timed execution improvements
|
|
40
|
+
|
|
41
|
+
- Ayush Patel <ayushnpatel22@gmail.com>
|
|
42
|
+
- Ground truth labeling system for IQ dataset collection
|
|
43
|
+
- Dashboard GUI improvements (display rate, waterfall threshold, label selector)
|
|
44
|
+
- SigMF annotation enhancements
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dapps
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.8
|
|
4
4
|
Summary: A library for creating and connecting dApps to Open RAN units.
|
|
5
5
|
Project-URL: Homepage, https://openrangym.com/o-ran-frameworks/dapps
|
|
6
6
|
Project-URL: Package, https://pypi.org/project/dapps/
|
|
7
7
|
Project-URL: Source, https://github.com/wineslab/dApp-library
|
|
8
8
|
Project-URL: Bug Tracker, https://github.com/wineslab/dApp-library/issues
|
|
9
|
-
Author-email: Andrea Lacava <thecave003@gmail.com
|
|
9
|
+
Author-email: Andrea Lacava <thecave003@gmail.com>
|
|
10
10
|
License-Expression: Apache-2.0
|
|
11
11
|
License-File: LICENSE
|
|
12
12
|
Keywords: 5G,O-RAN,cellular,dApps,networks
|
|
@@ -77,43 +77,54 @@ This dApp implements a spectrum sharing use case discussed in [our paper](https:
|
|
|
77
77
|
**Command-line arguments:**
|
|
78
78
|
|
|
79
79
|
**Connection Configuration:**
|
|
80
|
+
|
|
80
81
|
- `--link` - Specify the link layer to be used. Options: `zmq`, `posix` (default: `zmq`)
|
|
81
82
|
- `--transport` - Specify the transport layer to be used. Options: `ipc`, `tcp` (default: `ipc`)
|
|
82
83
|
|
|
83
84
|
**Data Collection and Control:**
|
|
85
|
+
|
|
84
86
|
- `--save-iqs` - Enable data collection mode. When set, I/Q samples will be saved to disk for later analysis using SigMF
|
|
85
87
|
- `--control` - Enable PRB control. When set, the dApp will actively control spectrum allocation based on detected interference. If not set, the dApp can still receive and implement control action from an external xApp
|
|
86
88
|
|
|
87
89
|
**Spectrum Sensing Configuration:**
|
|
90
|
+
|
|
88
91
|
- `--noise-floor-threshold` - Set the noise floor threshold (in dB) for determining the presence of incumbents and for detecting the PRBs affected. If not specified, will be auto-detected based on environment
|
|
89
92
|
- `--ota` - Specify if the setup is Over-The-Air (OTA) or on Colosseum testbed for using a precalculated noise floor threshold. If `--noise-floor-threshold` is specified, this parameter is ignored
|
|
90
93
|
|
|
91
94
|
**Visualization Options:**
|
|
95
|
+
|
|
92
96
|
- `--energy-gui` - Enable the energy spectrum visualization GUI
|
|
93
97
|
- `--iq-plotter-gui` - Enable the I/Q constellation plotter GUI
|
|
94
98
|
- `--demo-gui` - Enable the demonstration GUI showing real-time spectrum occupancy
|
|
99
|
+
- `--show-controls` - Show tunable controls (sampling threshold) in the dashboard GUI. Only meaningful with `--demo-gui`
|
|
100
|
+
- `--ground-truth LABEL` - Initial ground truth label written into IQ annotations (only used with `--save-iqs`). Updatable at runtime via the dashboard GUI when `--demo-gui` is also set. When no PRBs are detected the label is forced to `no_rfi` regardless of what is set
|
|
95
101
|
|
|
96
102
|
**Radio Configuration:**
|
|
103
|
+
|
|
97
104
|
- `--num-prbs` - Number of Physical Resource Blocks in the channel (default: 106)
|
|
98
105
|
- `--num-subcarrier-spacing` - Subcarrier spacing in kHz. Use 30 for FR1 (sub-6 GHz) (default: 30)
|
|
99
106
|
- `--e` - Enable 3/4 FFT sampling mode. Set this flag if the gNB was started with the `-E` option
|
|
100
107
|
- `--center-freq` - Center frequency in Hz (default: 3.6192e9, which is 3.6192 GHz)
|
|
101
108
|
|
|
102
109
|
**Execution Control:**
|
|
110
|
+
|
|
103
111
|
- `--timed SECONDS` - Run the dApp with a time limit (in seconds). Set to 0 for no limit (default: 0)
|
|
104
112
|
|
|
105
113
|
**AI/ML Model Configuration:**
|
|
114
|
+
|
|
106
115
|
- `--model` - Path to a pre-trained CNN model file for spectrum classification. If not provided, uses rule-based detection
|
|
107
116
|
- `--time-window` - Number of consecutive spectrum vectors to use as input for the CNN model (default: 5)
|
|
108
117
|
|
|
109
118
|
**Signal Processing Parameters:**
|
|
119
|
+
|
|
110
120
|
- `--moving-avg-window` - Window size (in samples) for the moving average filter used to smooth energy measurements and detect peaks in the spectrum (default: 30)
|
|
111
121
|
- `--extraction-window` - Number of samples to retain after detecting an energy peak for further analysis (default: 600)
|
|
112
|
-
- `--sampling-threshold` -
|
|
122
|
+
- `--sampling-threshold` - Controls dashboard visualization rate: a new frame is rendered every N IQ batches (default: 5). Has no effect on IQ delivery from the gNB or on IQ recording to disk. Tunable at runtime via the dashboard when `--show-controls` is active
|
|
113
123
|
|
|
114
124
|
**Adaptive Noise Floor Parameters:**
|
|
125
|
+
|
|
115
126
|
- `--use-adaptive-noise-floor` - Enable adaptive noise floor thresholding using a per-bin median magnitude computed over a rolling history buffer, instead of a fixed noise floor threshold (default: False)
|
|
116
|
-
- `--
|
|
127
|
+
- `--average_over_frames - Number of IQ observation frames to retain in the adaptive noise floor circular buffer. A noise floor estimate will not be produced until this many frames have been collected (default: 32)
|
|
117
128
|
- `--noise-floor-threshold` - When using adaptive noise floor, the minimum SNR in dB that a bin must exceed to be considered a detection. When not using adaptive noise floor, the fixed magnitude threshold in dB above which a bin is considered active (default: 53)
|
|
118
129
|
- `--embargo-timeout-secs` - Minimum time in seconds a PRB remains blocked after its last detection, regardless of the current SNR. Should be set to 1-2x the longest expected detection period to handle infrequent waveforms (default: 9.9)
|
|
119
130
|
|
|
@@ -34,43 +34,54 @@ This dApp implements a spectrum sharing use case discussed in [our paper](https:
|
|
|
34
34
|
**Command-line arguments:**
|
|
35
35
|
|
|
36
36
|
**Connection Configuration:**
|
|
37
|
+
|
|
37
38
|
- `--link` - Specify the link layer to be used. Options: `zmq`, `posix` (default: `zmq`)
|
|
38
39
|
- `--transport` - Specify the transport layer to be used. Options: `ipc`, `tcp` (default: `ipc`)
|
|
39
40
|
|
|
40
41
|
**Data Collection and Control:**
|
|
42
|
+
|
|
41
43
|
- `--save-iqs` - Enable data collection mode. When set, I/Q samples will be saved to disk for later analysis using SigMF
|
|
42
44
|
- `--control` - Enable PRB control. When set, the dApp will actively control spectrum allocation based on detected interference. If not set, the dApp can still receive and implement control action from an external xApp
|
|
43
45
|
|
|
44
46
|
**Spectrum Sensing Configuration:**
|
|
47
|
+
|
|
45
48
|
- `--noise-floor-threshold` - Set the noise floor threshold (in dB) for determining the presence of incumbents and for detecting the PRBs affected. If not specified, will be auto-detected based on environment
|
|
46
49
|
- `--ota` - Specify if the setup is Over-The-Air (OTA) or on Colosseum testbed for using a precalculated noise floor threshold. If `--noise-floor-threshold` is specified, this parameter is ignored
|
|
47
50
|
|
|
48
51
|
**Visualization Options:**
|
|
52
|
+
|
|
49
53
|
- `--energy-gui` - Enable the energy spectrum visualization GUI
|
|
50
54
|
- `--iq-plotter-gui` - Enable the I/Q constellation plotter GUI
|
|
51
55
|
- `--demo-gui` - Enable the demonstration GUI showing real-time spectrum occupancy
|
|
56
|
+
- `--show-controls` - Show tunable controls (sampling threshold) in the dashboard GUI. Only meaningful with `--demo-gui`
|
|
57
|
+
- `--ground-truth LABEL` - Initial ground truth label written into IQ annotations (only used with `--save-iqs`). Updatable at runtime via the dashboard GUI when `--demo-gui` is also set. When no PRBs are detected the label is forced to `no_rfi` regardless of what is set
|
|
52
58
|
|
|
53
59
|
**Radio Configuration:**
|
|
60
|
+
|
|
54
61
|
- `--num-prbs` - Number of Physical Resource Blocks in the channel (default: 106)
|
|
55
62
|
- `--num-subcarrier-spacing` - Subcarrier spacing in kHz. Use 30 for FR1 (sub-6 GHz) (default: 30)
|
|
56
63
|
- `--e` - Enable 3/4 FFT sampling mode. Set this flag if the gNB was started with the `-E` option
|
|
57
64
|
- `--center-freq` - Center frequency in Hz (default: 3.6192e9, which is 3.6192 GHz)
|
|
58
65
|
|
|
59
66
|
**Execution Control:**
|
|
67
|
+
|
|
60
68
|
- `--timed SECONDS` - Run the dApp with a time limit (in seconds). Set to 0 for no limit (default: 0)
|
|
61
69
|
|
|
62
70
|
**AI/ML Model Configuration:**
|
|
71
|
+
|
|
63
72
|
- `--model` - Path to a pre-trained CNN model file for spectrum classification. If not provided, uses rule-based detection
|
|
64
73
|
- `--time-window` - Number of consecutive spectrum vectors to use as input for the CNN model (default: 5)
|
|
65
74
|
|
|
66
75
|
**Signal Processing Parameters:**
|
|
76
|
+
|
|
67
77
|
- `--moving-avg-window` - Window size (in samples) for the moving average filter used to smooth energy measurements and detect peaks in the spectrum (default: 30)
|
|
68
78
|
- `--extraction-window` - Number of samples to retain after detecting an energy peak for further analysis (default: 600)
|
|
69
|
-
- `--sampling-threshold` -
|
|
79
|
+
- `--sampling-threshold` - Controls dashboard visualization rate: a new frame is rendered every N IQ batches (default: 5). Has no effect on IQ delivery from the gNB or on IQ recording to disk. Tunable at runtime via the dashboard when `--show-controls` is active
|
|
70
80
|
|
|
71
81
|
**Adaptive Noise Floor Parameters:**
|
|
82
|
+
|
|
72
83
|
- `--use-adaptive-noise-floor` - Enable adaptive noise floor thresholding using a per-bin median magnitude computed over a rolling history buffer, instead of a fixed noise floor threshold (default: False)
|
|
73
|
-
- `--
|
|
84
|
+
- `--average_over_frames - Number of IQ observation frames to retain in the adaptive noise floor circular buffer. A noise floor estimate will not be produced until this many frames have been collected (default: 32)
|
|
74
85
|
- `--noise-floor-threshold` - When using adaptive noise floor, the minimum SNR in dB that a bin must exceed to be considered a detection. When not using adaptive noise floor, the fixed magnitude threshold in dB above which a bin is considered active (default: 53)
|
|
75
86
|
- `--embargo-timeout-secs` - Minimum time in seconds a PRB remains blocked after its last detection, regardless of the current SNR. Should be set to 1-2x the longest expected detection period to handle infrequent waveforms (default: 9.9)
|
|
76
87
|
|
dapps-0.0.8/VERSION
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.0.8
|
|
@@ -106,6 +106,8 @@ def main(args):
|
|
|
106
106
|
e_sampling=args.e,
|
|
107
107
|
num_subcarrier_spacing=args.num_subcarrier_spacing,
|
|
108
108
|
sampling_threshold=args.sampling_threshold,
|
|
109
|
+
ground_truth=args.ground_truth,
|
|
110
|
+
show_controls=args.show_controls,
|
|
109
111
|
dapp_name="SpectrumSharing",
|
|
110
112
|
dapp_version="1.0.0",
|
|
111
113
|
vendor="WinesLab",
|
|
@@ -202,7 +204,15 @@ if __name__ == "__main__":
|
|
|
202
204
|
parser.add_argument('--extraction-window', type=int, default=600,
|
|
203
205
|
help="Samples to retain after CNN energy peak detection")
|
|
204
206
|
parser.add_argument('--sampling-threshold', type=int, default=5,
|
|
205
|
-
help="
|
|
207
|
+
help="Render a new dashboard frame every N IQ batches (visualization only, does not affect IQ delivery or recording)")
|
|
208
|
+
parser.add_argument('--ground-truth', type=str, default='', metavar='LABEL',
|
|
209
|
+
help="Initial ground truth label written into IQ annotations "
|
|
210
|
+
"(only used with --save-iqs). "
|
|
211
|
+
"Updatable at runtime via the dashboard GUI "
|
|
212
|
+
"when --demo-gui is also set.")
|
|
213
|
+
parser.add_argument('--show-controls', action='store_true', default=False,
|
|
214
|
+
help="Show tunable controls (sampling threshold) in the dashboard GUI. "
|
|
215
|
+
"Only meaningful with --demo-gui.")
|
|
206
216
|
|
|
207
217
|
args = parser.parse_args()
|
|
208
218
|
print("Start dApp")
|
|
@@ -25,8 +25,6 @@ dependencies = [ "asn1tools", "typing_extensions", "numpy", "pyzmq", "sigmf", "j
|
|
|
25
25
|
|
|
26
26
|
authors = [
|
|
27
27
|
{name = "Andrea Lacava", email = "thecave003@gmail.com"},
|
|
28
|
-
{name = "Filippo Olimpieri", email = "filippo.olimpieri@gmail.com"},
|
|
29
|
-
{name = "Chris Wozny", email = "woznych@gmail.com"}
|
|
30
28
|
]
|
|
31
29
|
|
|
32
30
|
|
|
@@ -52,6 +50,10 @@ all = [
|
|
|
52
50
|
]
|
|
53
51
|
|
|
54
52
|
|
|
53
|
+
[tool.hatch.envs.default]
|
|
54
|
+
features = ["gui", "api", "network"]
|
|
55
|
+
dependencies = ["pytest"]
|
|
56
|
+
|
|
55
57
|
[tool.hatch.version]
|
|
56
58
|
path = "VERSION"
|
|
57
59
|
pattern = "(?P<version>\\d+\\.\\d+\\.\\d+)"
|
|
@@ -39,10 +39,11 @@ class IQSaver:
|
|
|
39
39
|
dtype: str = "ci16_le",
|
|
40
40
|
filename: str = None,
|
|
41
41
|
max_samples_per_file: Optional[int] = None,
|
|
42
|
+
rotation_interval: Optional[float] = None,
|
|
42
43
|
**metadata_kwargs):
|
|
43
44
|
"""
|
|
44
45
|
Initialize IQSaver with SigMF-compliant recording configuration.
|
|
45
|
-
|
|
46
|
+
|
|
46
47
|
Args:
|
|
47
48
|
base_path: Directory for recordings (default: current directory)
|
|
48
49
|
center_freq: Center frequency in Hz
|
|
@@ -56,6 +57,8 @@ class IQSaver:
|
|
|
56
57
|
filename: Custom filename (without extension). If None, timestamp-based name is used
|
|
57
58
|
max_samples_per_file: Maximum number of frames (save_samples calls) per file
|
|
58
59
|
before rotating to a new file. None disables rotation (single file).
|
|
60
|
+
rotation_interval: Time in seconds before rotating to a new file. None disables
|
|
61
|
+
time-based rotation. Can be combined with max_samples_per_file.
|
|
59
62
|
**metadata_kwargs: Additional global metadata fields (stored under spear: namespace)
|
|
60
63
|
"""
|
|
61
64
|
self.base_path = Path(base_path) if base_path else Path.cwd()
|
|
@@ -97,6 +100,10 @@ class IQSaver:
|
|
|
97
100
|
self.max_samples_per_file = max_samples_per_file
|
|
98
101
|
if max_samples_per_file is not None and max_samples_per_file <= 0:
|
|
99
102
|
raise ValueError("max_samples_per_file must be positive.")
|
|
103
|
+
self.rotation_interval = rotation_interval
|
|
104
|
+
if rotation_interval is not None and rotation_interval <= 0:
|
|
105
|
+
raise ValueError("rotation_interval must be positive.")
|
|
106
|
+
self._file_start_time: Optional[float] = None
|
|
100
107
|
|
|
101
108
|
self._session_timestamp_ms: Optional[int] = None
|
|
102
109
|
self._file_index: int = 0
|
|
@@ -171,8 +178,9 @@ class IQSaver:
|
|
|
171
178
|
|
|
172
179
|
# Write initial metadata file immediately (SigMF requires .sigmf-meta alongside .sigmf-data)
|
|
173
180
|
# This ensures the file exists even if the program is interrupted
|
|
174
|
-
self._sigmf.tofile(str(self.base_path / base_filename))
|
|
175
|
-
|
|
181
|
+
self._sigmf.tofile(str(self.base_path / base_filename), overwrite=True)
|
|
182
|
+
|
|
183
|
+
self._file_start_time = timestamp
|
|
176
184
|
self._is_initialized = True
|
|
177
185
|
|
|
178
186
|
def _rotate_file(self, timestamp: float) -> None:
|
|
@@ -183,7 +191,7 @@ class IQSaver:
|
|
|
183
191
|
self._file_handle.close()
|
|
184
192
|
self._file_handle = None
|
|
185
193
|
if self._sigmf is not None and self._filename is not None:
|
|
186
|
-
self._sigmf.tofile(str(self.base_path / self._filename))
|
|
194
|
+
self._sigmf.tofile(str(self.base_path / self._filename), overwrite=True)
|
|
187
195
|
self._all_files.append(self._filename)
|
|
188
196
|
|
|
189
197
|
# Reset all state for new file
|
|
@@ -195,6 +203,7 @@ class IQSaver:
|
|
|
195
203
|
self._is_initialized = False
|
|
196
204
|
self._annotation_buffer.clear()
|
|
197
205
|
self._annotation_count = 0
|
|
206
|
+
self._file_start_time = None
|
|
198
207
|
self._file_index += 1
|
|
199
208
|
|
|
200
209
|
def _finalize_file(self) -> None:
|
|
@@ -208,7 +217,7 @@ class IQSaver:
|
|
|
208
217
|
self._flush_annotations()
|
|
209
218
|
|
|
210
219
|
# Write final metadata file
|
|
211
|
-
self._sigmf.tofile(str(self.base_path / self._filename))
|
|
220
|
+
self._sigmf.tofile(str(self.base_path / self._filename), overwrite=True)
|
|
212
221
|
|
|
213
222
|
def _get_iso8601_timestamp(self, timestamp: Optional[float] = None) -> str:
|
|
214
223
|
"""Convert Unix timestamp to ISO 8601 format for SigMF with microsecond precision."""
|
|
@@ -232,9 +241,13 @@ class IQSaver:
|
|
|
232
241
|
if timestamp is None:
|
|
233
242
|
timestamp = time.time()
|
|
234
243
|
|
|
235
|
-
# Check rotation
|
|
244
|
+
# Check rotation conditions (frame count or elapsed time)
|
|
236
245
|
if self.max_samples_per_file is not None and self._sample_count >= self.max_samples_per_file:
|
|
237
246
|
self._rotate_file(timestamp)
|
|
247
|
+
elif (self.rotation_interval is not None
|
|
248
|
+
and self._file_start_time is not None
|
|
249
|
+
and (timestamp - self._file_start_time) >= self.rotation_interval):
|
|
250
|
+
self._rotate_file(timestamp)
|
|
238
251
|
|
|
239
252
|
# Initialize file on first write or after rotation
|
|
240
253
|
if not self._is_initialized:
|
|
@@ -387,7 +400,7 @@ class IQSaver:
|
|
|
387
400
|
|
|
388
401
|
# Write metadata file to disk after flushing annotations
|
|
389
402
|
if self._sigmf is not None and self._filename is not None:
|
|
390
|
-
self._sigmf.tofile(str(self.base_path / self._filename))
|
|
403
|
+
self._sigmf.tofile(str(self.base_path / self._filename), overwrite=True)
|
|
391
404
|
|
|
392
405
|
def update_sample_rate(self, new_sample_rate: float, sampling_threshold: int = None) -> None:
|
|
393
406
|
"""
|
|
@@ -432,6 +445,31 @@ class IQSaver:
|
|
|
432
445
|
# Add new capture segment at current IQ sample position
|
|
433
446
|
self._sigmf.add_capture(self._iq_sample_count, metadata=capture_metadata)
|
|
434
447
|
|
|
448
|
+
def add_waveform_description(self, timestamp: Optional[float] = None, **kwargs) -> bool:
|
|
449
|
+
"""
|
|
450
|
+
Add a post-collection waveform description annotation (e.g. from an LLM or external classifier).
|
|
451
|
+
All kwargs are stored as custom spear: fields at the same sample index as the timestamp.
|
|
452
|
+
"""
|
|
453
|
+
return self.add_annotation(timestamp=timestamp, label="waveform_description", **kwargs)
|
|
454
|
+
|
|
455
|
+
def get_recording_info(self) -> Dict[str, Any]:
|
|
456
|
+
"""Return a snapshot of the current recording session state."""
|
|
457
|
+
all_files = list(self._all_files)
|
|
458
|
+
if self._filename:
|
|
459
|
+
all_files.append(self._filename)
|
|
460
|
+
|
|
461
|
+
file_paths = [str(self.base_path / f"{name}.sigmf-data") for name in all_files]
|
|
462
|
+
file_sizes = {p: Path(p).stat().st_size if Path(p).exists() else 0 for p in file_paths}
|
|
463
|
+
|
|
464
|
+
return {
|
|
465
|
+
"total_samples": self._iq_sample_count,
|
|
466
|
+
"total_files": len(all_files),
|
|
467
|
+
"pending_annotations": len(self._annotation_buffer),
|
|
468
|
+
"duration_seconds": time.time() - self._session_start_time,
|
|
469
|
+
"file_paths": file_paths,
|
|
470
|
+
"file_size_bytes": file_sizes,
|
|
471
|
+
}
|
|
472
|
+
|
|
435
473
|
def close(self) -> None:
|
|
436
474
|
"""
|
|
437
475
|
Finalize recording and write all metadata.
|
|
@@ -163,6 +163,9 @@ class SpectrumSharingDApp(DApp):
|
|
|
163
163
|
self.energyGui = kwargs.get('energyGui', False)
|
|
164
164
|
self.iqPlotterGui = kwargs.get('iqPlotterGui', False)
|
|
165
165
|
self.dashboard = kwargs.get('dashboard', False)
|
|
166
|
+
if self.save_iqs:
|
|
167
|
+
self._ground_truth_label = kwargs.get('ground_truth', "")
|
|
168
|
+
self._ground_truth_lock = threading.Lock()
|
|
166
169
|
|
|
167
170
|
# Thread-safe sample_idx for IQ saver annotation cross-reference
|
|
168
171
|
self._sample_idx_lock = threading.Lock()
|
|
@@ -195,6 +198,9 @@ class SpectrumSharingDApp(DApp):
|
|
|
195
198
|
classifier=classifier,
|
|
196
199
|
adaptiveThreshold=isinstance(self._detector, AdaptiveThresholdDetector),
|
|
197
200
|
control=self.control,
|
|
201
|
+
label_callback=self.set_ground_truth_label if self.save_iqs else None,
|
|
202
|
+
initial_label=self._ground_truth_label if self.save_iqs else "",
|
|
203
|
+
show_controls=kwargs.get('show_controls', False),
|
|
198
204
|
)
|
|
199
205
|
|
|
200
206
|
def _init_spectrum_encoder(self):
|
|
@@ -266,6 +272,13 @@ class SpectrumSharingDApp(DApp):
|
|
|
266
272
|
self._sampling_threshold_control_callback = callback
|
|
267
273
|
dapp_logger.info(f"Custom control logic callback {'set' if callback else 'removed'}")
|
|
268
274
|
|
|
275
|
+
def set_ground_truth_label(self, label: str):
|
|
276
|
+
with self._ground_truth_lock:
|
|
277
|
+
self._ground_truth_label = label
|
|
278
|
+
dapp_logger.info(f"Ground truth label set to: {label!r}")
|
|
279
|
+
if self.dashboard:
|
|
280
|
+
self.demo.emit_label(label)
|
|
281
|
+
|
|
269
282
|
def create_prb_blacklist_control(self, blacklisted_prbs: list[int],
|
|
270
283
|
update_sampling: bool = False,
|
|
271
284
|
validity_period: int = None) -> bytes:
|
|
@@ -487,6 +500,9 @@ class SpectrumSharingDApp(DApp):
|
|
|
487
500
|
dapp_logger.info(f"Sending Control to RAN: blockedPRBs={prb_blk_list}")
|
|
488
501
|
|
|
489
502
|
if self.save_iqs:
|
|
503
|
+
if hasattr(self, '_ground_truth_label'):
|
|
504
|
+
with self._ground_truth_lock:
|
|
505
|
+
ground_truth_label = self._ground_truth_label
|
|
490
506
|
with self._sample_idx_lock:
|
|
491
507
|
if self.sample_idx is not None:
|
|
492
508
|
self.iq_saver.add_annotation(
|
|
@@ -498,6 +514,7 @@ class SpectrumSharingDApp(DApp):
|
|
|
498
514
|
noise_threshold=self._detector.threshold_db,
|
|
499
515
|
control_action="blacklist",
|
|
500
516
|
detector=type(self._detector).__name__,
|
|
517
|
+
**({"ground_truth_label": ground_truth_label} if hasattr(self, '_ground_truth_label') else {}),
|
|
501
518
|
)
|
|
502
519
|
dapp_logger.info("Annotation added")
|
|
503
520
|
|
|
@@ -647,6 +664,10 @@ class SpectrumSharingDApp(DApp):
|
|
|
647
664
|
f"noise_floor_per_prb={ann.get('noise_floor_per_prb')} | "
|
|
648
665
|
f"snr_db_per_prb={ann.get('snr_db_per_prb')}"
|
|
649
666
|
)
|
|
667
|
+
if hasattr(self, '_ground_truth_label'):
|
|
668
|
+
with self._ground_truth_lock:
|
|
669
|
+
gt = self._ground_truth_label if detected_prbs.size > 0 else "no_rfi"
|
|
670
|
+
ann["ground_truth_label"] = gt
|
|
650
671
|
with self._sample_idx_lock:
|
|
651
672
|
if self.sample_idx is not None:
|
|
652
673
|
self.iq_saver.add_annotation(start_sample=self.sample_idx, **ann)
|
|
@@ -3,21 +3,22 @@ try:
|
|
|
3
3
|
from flask import Flask, render_template
|
|
4
4
|
from flask_socketio import SocketIO
|
|
5
5
|
except ModuleNotFoundError:
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
exit(-1)
|
|
6
|
+
print(
|
|
7
|
+
"Optional dependencies for GUI not installed.\n"
|
|
8
|
+
"Fix this by running:\n\n"
|
|
9
|
+
" pip install 'dApps[gui]' # OR\n"
|
|
10
|
+
" pip install 'dApps[all]'\n"
|
|
11
|
+
)
|
|
12
|
+
exit(-1)
|
|
14
13
|
import numpy as np
|
|
15
14
|
|
|
16
15
|
class Dashboard:
|
|
17
16
|
# The default values of the dashboard are based on the default configuration
|
|
18
17
|
def __init__(self, buffer_size: int = 100, ofdm_symbol_size: int = 1272, bw: float = 38.16e6, center_freq: float = 3.6192e9,
|
|
19
18
|
num_prbs: int = 106, first_carrier_offset: int = 900, prb_protected_below: int = 75,
|
|
20
|
-
classifier=None, adaptiveThreshold=False, control: bool = False
|
|
19
|
+
classifier=None, adaptiveThreshold=False, control: bool = False,
|
|
20
|
+
label_callback=None, initial_label: str = "", port: int = 7778,
|
|
21
|
+
show_controls: bool = False):
|
|
21
22
|
# Flask app setup
|
|
22
23
|
self.app = Flask(__name__)
|
|
23
24
|
self.app.config['TEMPLATES_AUTO_RELOAD'] = True
|
|
@@ -40,12 +41,22 @@ class Dashboard:
|
|
|
40
41
|
|
|
41
42
|
self.classifier = classifier if classifier is not None else None
|
|
42
43
|
self.adaptiveThreshold = adaptiveThreshold
|
|
44
|
+
self.label_callback = label_callback
|
|
45
|
+
self.current_label = initial_label
|
|
46
|
+
self.port = port
|
|
47
|
+
self.show_controls = show_controls
|
|
43
48
|
|
|
44
49
|
# SocketIO event handlers
|
|
45
50
|
self.socketio.on_event("connect", self.handle_initial_connection)
|
|
51
|
+
if self.label_callback is not None:
|
|
52
|
+
def _on_set_ground_truth_label(label):
|
|
53
|
+
self.current_label = label
|
|
54
|
+
self.label_callback(label)
|
|
55
|
+
self.socketio.on_event("set_ground_truth_label", _on_set_ground_truth_label)
|
|
56
|
+
self.socketio.on_event("set_sampling_threshold", self._on_set_sampling_threshold)
|
|
46
57
|
self._initialize_plot()
|
|
47
58
|
|
|
48
|
-
#
|
|
59
|
+
# Render a new dashboard frame every N IQ batches — does not affect IQ delivery or recording
|
|
49
60
|
self.sampling_counter = 0
|
|
50
61
|
self.sampling_threshold = 5
|
|
51
62
|
|
|
@@ -53,7 +64,8 @@ class Dashboard:
|
|
|
53
64
|
return render_template("index.html")
|
|
54
65
|
|
|
55
66
|
def run(self):
|
|
56
|
-
self.socketio.run(self.app, host="0.0.0.0", port=
|
|
67
|
+
self.socketio.run(self.app, host="0.0.0.0", port=self.port, debug=False,
|
|
68
|
+
use_reloader=False, allow_unsafe_werkzeug=True)
|
|
57
69
|
|
|
58
70
|
def _initialize_plot(self):
|
|
59
71
|
self.run_thread = threading.Thread(target=self.run, daemon=True)
|
|
@@ -76,9 +88,28 @@ class Dashboard:
|
|
|
76
88
|
"prb_protected_below": self.prb_protected_below if self.control else None,
|
|
77
89
|
"predicted_label": self.classifier is not None,
|
|
78
90
|
"adaptive_noise_floor": self.adaptiveThreshold,
|
|
91
|
+
"show_label_selector": self.label_callback is not None,
|
|
92
|
+
"current_label": self.current_label,
|
|
93
|
+
"show_controls": self.show_controls,
|
|
94
|
+
"sampling_threshold": self.sampling_threshold,
|
|
79
95
|
}
|
|
80
96
|
self.socketio.emit("initialize_plot", data_buffer)
|
|
81
97
|
|
|
98
|
+
def emit_label(self, label: str):
|
|
99
|
+
self.current_label = label
|
|
100
|
+
self.socketio.emit("update_ground_truth_label", label)
|
|
101
|
+
|
|
102
|
+
def _on_set_sampling_threshold(self, value):
|
|
103
|
+
try:
|
|
104
|
+
rate = int(value)
|
|
105
|
+
except (TypeError, ValueError):
|
|
106
|
+
return
|
|
107
|
+
if rate < 1:
|
|
108
|
+
return
|
|
109
|
+
self.sampling_threshold = rate
|
|
110
|
+
self.socketio.emit("update_sampling_threshold", rate)
|
|
111
|
+
self.socketio.emit("reset_waterfall")
|
|
112
|
+
|
|
82
113
|
def _process_iq_data(self, iq_data):
|
|
83
114
|
real_part = iq_data[0::2] # Even indices: real part
|
|
84
115
|
imag_part = iq_data[1::2] # Odd indices: imaginary part
|
|
@@ -96,7 +127,7 @@ class Dashboard:
|
|
|
96
127
|
if plot == "iq_data":
|
|
97
128
|
iq_data = payload
|
|
98
129
|
|
|
99
|
-
self.sampling_counter += 1
|
|
130
|
+
self.sampling_counter += 1
|
|
100
131
|
if self.sampling_counter >= self.sampling_threshold:
|
|
101
132
|
magnitude_dB = self._process_iq_data(iq_data)[::-1].tolist() # visualization processing is delegated to client
|
|
102
133
|
self.socketio.emit("update_plot", {"magnitude": magnitude_dB})
|
|
@@ -123,4 +154,32 @@ class Dashboard:
|
|
|
123
154
|
self.run_thread.join(timeout=1)
|
|
124
155
|
|
|
125
156
|
if __name__ == "__main__":
|
|
126
|
-
|
|
157
|
+
import argparse
|
|
158
|
+
import time
|
|
159
|
+
|
|
160
|
+
parser = argparse.ArgumentParser(description="Dashboard interactive demo")
|
|
161
|
+
parser.add_argument("--initial-label", default="", metavar="LABEL",
|
|
162
|
+
help="Ground truth label to pre-populate in the GUI")
|
|
163
|
+
parser.add_argument("--port", type=int, default=7778)
|
|
164
|
+
args = parser.parse_args()
|
|
165
|
+
|
|
166
|
+
def on_label(label):
|
|
167
|
+
print(f"[demo] ground_truth_label updated: {label!r}")
|
|
168
|
+
|
|
169
|
+
demo = Dashboard(
|
|
170
|
+
label_callback=on_label,
|
|
171
|
+
initial_label=args.initial_label,
|
|
172
|
+
port=args.port,
|
|
173
|
+
)
|
|
174
|
+
print(f"Dashboard running at http://localhost:{args.port}")
|
|
175
|
+
print("Type a label and press Enter to push it to the GUI, or Ctrl-C to quit.")
|
|
176
|
+
try:
|
|
177
|
+
while True:
|
|
178
|
+
label = input("label> ").strip()
|
|
179
|
+
if label:
|
|
180
|
+
demo.emit_label(label)
|
|
181
|
+
print(f" → pushed {label!r} to browser")
|
|
182
|
+
except (KeyboardInterrupt, EOFError):
|
|
183
|
+
pass
|
|
184
|
+
finally:
|
|
185
|
+
demo.stop()
|
|
@@ -11,7 +11,11 @@
|
|
|
11
11
|
<body>
|
|
12
12
|
<div id="prbs"></div>
|
|
13
13
|
<div id="magnitude"></div>
|
|
14
|
-
<div
|
|
14
|
+
<div style="display: flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: 24px;">
|
|
15
|
+
<div id="predicted_label"></div>
|
|
16
|
+
<div id="ground_truth_label"></div>
|
|
17
|
+
<div id="sampling_threshold_control"></div>
|
|
18
|
+
</div>
|
|
15
19
|
<div id="adaptive_noise_floor"></div>
|
|
16
20
|
|
|
17
21
|
<script>
|
|
@@ -32,7 +36,7 @@
|
|
|
32
36
|
|
|
33
37
|
var waterfallLayout = {
|
|
34
38
|
title: {
|
|
35
|
-
text: "Sensed Spectrum",
|
|
39
|
+
text: "dApp Sensed Spectrum",
|
|
36
40
|
font: { size: 16 }
|
|
37
41
|
},
|
|
38
42
|
xaxis: {
|
|
@@ -158,7 +162,7 @@
|
|
|
158
162
|
}
|
|
159
163
|
],
|
|
160
164
|
title: {
|
|
161
|
-
text: "PRB Status Grid",
|
|
165
|
+
text: "dApp PRB Status Grid",
|
|
162
166
|
font: { size: 16 }
|
|
163
167
|
},
|
|
164
168
|
xaxis: {
|
|
@@ -200,7 +204,7 @@
|
|
|
200
204
|
var adaptiveNoiseFloorLayout = {
|
|
201
205
|
title: {
|
|
202
206
|
text: "Adaptive Noise Floor",
|
|
203
|
-
font: {size:
|
|
207
|
+
font: {size: 16}
|
|
204
208
|
},
|
|
205
209
|
xaxis: {
|
|
206
210
|
title: {
|
|
@@ -223,6 +227,15 @@
|
|
|
223
227
|
var start_ignore;
|
|
224
228
|
var end_ignore;
|
|
225
229
|
var initialized = false;
|
|
230
|
+
var samplingThresholdInput;
|
|
231
|
+
var gtInput = null;
|
|
232
|
+
var gtBadge = null;
|
|
233
|
+
function updateBadge(label) { if (gtBadge) gtBadge.textContent = label; }
|
|
234
|
+
function flashBadge() {
|
|
235
|
+
if (!gtBadge) return;
|
|
236
|
+
gtBadge.style.outline = '3px solid #fbbc04';
|
|
237
|
+
setTimeout(function() { gtBadge.style.outline = 'none'; }, 400);
|
|
238
|
+
}
|
|
226
239
|
|
|
227
240
|
var socket = io.connect('http://' + document.domain + ':' + location.port);
|
|
228
241
|
socket.on('initialize_plot', function (data) {
|
|
@@ -260,6 +273,41 @@
|
|
|
260
273
|
Plotly.newPlot('magnitude', waterfall, waterfallLayout);
|
|
261
274
|
Plotly.newPlot('prbs', prbs, prbsLayout);
|
|
262
275
|
|
|
276
|
+
if (data.show_controls) {
|
|
277
|
+
var samplingThresholdContainer = document.getElementById('sampling_threshold_control');
|
|
278
|
+
samplingThresholdContainer.innerHTML = '';
|
|
279
|
+
samplingThresholdContainer.style.cssText = 'padding: 12px; font-family: sans-serif; display: flex; align-items: center; gap: 10px;';
|
|
280
|
+
|
|
281
|
+
var samplingThresholdLabel = document.createElement('label');
|
|
282
|
+
samplingThresholdLabel.textContent = 'Visualization sampling threshold:';
|
|
283
|
+
samplingThresholdLabel.style.cssText = 'font-size: 14px; font-weight: bold; white-space: nowrap;';
|
|
284
|
+
samplingThresholdContainer.appendChild(samplingThresholdLabel);
|
|
285
|
+
|
|
286
|
+
samplingThresholdInput = document.createElement('input');
|
|
287
|
+
samplingThresholdInput.type = 'number';
|
|
288
|
+
samplingThresholdInput.min = '1';
|
|
289
|
+
samplingThresholdInput.step = '1';
|
|
290
|
+
samplingThresholdInput.value = data.sampling_threshold;
|
|
291
|
+
samplingThresholdInput.style.cssText = 'font-size: 13px; padding: 5px 10px; border: 2px solid #ccc; border-radius: 4px; width: 100px; outline: none;';
|
|
292
|
+
samplingThresholdContainer.appendChild(samplingThresholdInput);
|
|
293
|
+
|
|
294
|
+
var samplingThresholdBtn = document.createElement('button');
|
|
295
|
+
samplingThresholdBtn.textContent = 'Set';
|
|
296
|
+
samplingThresholdBtn.style.cssText = 'padding: 5px 14px; font-size: 13px; background: #1a73e8; color: #fff; border: none; border-radius: 4px; cursor: pointer;';
|
|
297
|
+
samplingThresholdContainer.appendChild(samplingThresholdBtn);
|
|
298
|
+
|
|
299
|
+
function emitSamplingThreshold() {
|
|
300
|
+
var val = parseInt(samplingThresholdInput.value, 10);
|
|
301
|
+
if (!isNaN(val) && val >= 1) {
|
|
302
|
+
socket.emit('set_sampling_threshold', val);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
samplingThresholdBtn.addEventListener('click', emitSamplingThreshold);
|
|
306
|
+
samplingThresholdInput.addEventListener('keydown', function(e) {
|
|
307
|
+
if (e.key === 'Enter') emitSamplingThreshold();
|
|
308
|
+
});
|
|
309
|
+
} // end show_controls
|
|
310
|
+
|
|
263
311
|
// PRB zone overlays — only drawn when control is active (PRB blocking enabled).
|
|
264
312
|
// Without control there are no blocked PRBs so zone markers would be misleading.
|
|
265
313
|
if (data.show_prb_zones) {
|
|
@@ -314,6 +362,54 @@
|
|
|
314
362
|
});
|
|
315
363
|
}
|
|
316
364
|
|
|
365
|
+
if (data.show_label_selector) {
|
|
366
|
+
var container = document.getElementById('ground_truth_label');
|
|
367
|
+
container.innerHTML = '';
|
|
368
|
+
container.style.cssText = 'padding: 12px; font-family: sans-serif; display: flex; align-items: center; gap: 10px; flex-wrap: wrap;';
|
|
369
|
+
|
|
370
|
+
var heading = document.createElement('span');
|
|
371
|
+
heading.textContent = 'Ground Truth Label:';
|
|
372
|
+
heading.style.cssText = 'font-size: 14px; font-weight: bold; white-space: nowrap;';
|
|
373
|
+
container.appendChild(heading);
|
|
374
|
+
|
|
375
|
+
// Status badge
|
|
376
|
+
gtBadge = document.createElement('span');
|
|
377
|
+
gtBadge.style.cssText = 'font-size: 13px; font-weight: bold; padding: 4px 14px; border-radius: 12px; white-space: nowrap; transition: outline 0.1s; background: #5f6368; color: #fff;';
|
|
378
|
+
updateBadge(data.current_label);
|
|
379
|
+
container.appendChild(gtBadge);
|
|
380
|
+
|
|
381
|
+
// Free-text input for custom labels
|
|
382
|
+
gtInput = document.createElement('input');
|
|
383
|
+
gtInput.type = 'text';
|
|
384
|
+
gtInput.value = data.current_label;
|
|
385
|
+
gtInput.placeholder = 'custom label...';
|
|
386
|
+
gtInput.style.cssText = 'font-size: 13px; padding: 5px 10px; border: 2px solid #ccc; border-radius: 4px; width: 160px; outline: none;';
|
|
387
|
+
container.appendChild(gtInput);
|
|
388
|
+
|
|
389
|
+
var btn = document.createElement('button');
|
|
390
|
+
btn.textContent = 'Set';
|
|
391
|
+
btn.style.cssText = 'padding: 5px 14px; font-size: 13px; background: #1a73e8; color: #fff; border: none; border-radius: 4px; cursor: pointer;';
|
|
392
|
+
container.appendChild(btn);
|
|
393
|
+
|
|
394
|
+
var labelInitialized = false;
|
|
395
|
+
function emitLabel() {
|
|
396
|
+
var val = gtInput.value.trim();
|
|
397
|
+
if (val) {
|
|
398
|
+
socket.emit('set_ground_truth_label', val);
|
|
399
|
+
updateBadge(val);
|
|
400
|
+
if (labelInitialized) flashBadge();
|
|
401
|
+
labelInitialized = true;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
btn.addEventListener('click', emitLabel);
|
|
406
|
+
gtInput.addEventListener('keydown', function(e) {
|
|
407
|
+
if (e.key === 'Enter') emitLabel();
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
emitLabel();
|
|
411
|
+
}
|
|
412
|
+
|
|
317
413
|
// Dynamic preprocessing based on first_carrier_offset
|
|
318
414
|
// The carrier frequency is in the middle PRB (including DC leak on some radios)
|
|
319
415
|
console.log("Initial values:", {
|
|
@@ -340,6 +436,26 @@
|
|
|
340
436
|
initialized = true;
|
|
341
437
|
});
|
|
342
438
|
|
|
439
|
+
socket.on('update_sampling_threshold', function (rate) {
|
|
440
|
+
samplingThresholdInput.value = rate;
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
socket.on('update_ground_truth_label', function (label) {
|
|
444
|
+
if (gtInput) gtInput.value = label;
|
|
445
|
+
updateBadge(label);
|
|
446
|
+
flashBadge();
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
socket.on('reset_waterfall', function () {
|
|
450
|
+
if (!initialized) return;
|
|
451
|
+
var rows = waterfall_buffer.length;
|
|
452
|
+
var cols = waterfall_buffer[0].length;
|
|
453
|
+
for (var i = 0; i < rows; i++) {
|
|
454
|
+
waterfall_buffer[i] = new Array(cols).fill(0);
|
|
455
|
+
}
|
|
456
|
+
Plotly.restyle('magnitude', { z: [waterfall_buffer] });
|
|
457
|
+
});
|
|
458
|
+
|
|
343
459
|
socket.on('update_plot', function (data) {
|
|
344
460
|
if (!initialized) {
|
|
345
461
|
return;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Tests for Dashboard — label callback, initial_label, and emit_label.
|
|
4
|
+
"""
|
|
5
|
+
import sys
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
|
|
9
|
+
|
|
10
|
+
from visualization.dashboard import Dashboard
|
|
11
|
+
|
|
12
|
+
# port=0 lets the OS assign a free ephemeral port — avoids collisions between tests
|
|
13
|
+
_PORT = 0
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_label_callback_fires():
|
|
17
|
+
received = []
|
|
18
|
+
d = Dashboard(label_callback=received.append, initial_label="no_rfi", port=_PORT)
|
|
19
|
+
assert d.label_callback is not None
|
|
20
|
+
assert d.current_label == "no_rfi"
|
|
21
|
+
d.label_callback("jammer")
|
|
22
|
+
assert received == ["jammer"]
|
|
23
|
+
d.stop()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_emit_label_updates_current_label():
|
|
27
|
+
d = Dashboard(label_callback=lambda _: None, initial_label="no_rfi", port=_PORT)
|
|
28
|
+
d.emit_label("radar")
|
|
29
|
+
assert d.current_label == "radar"
|
|
30
|
+
d.stop()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_no_label_callback_hides_selector():
|
|
34
|
+
d = Dashboard(port=_PORT)
|
|
35
|
+
assert d.label_callback is None
|
|
36
|
+
assert d.current_label == ""
|
|
37
|
+
d.stop()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_initial_label_empty_by_default():
|
|
41
|
+
d = Dashboard(label_callback=lambda _: None, port=_PORT)
|
|
42
|
+
assert d.current_label == ""
|
|
43
|
+
d.stop()
|
|
@@ -10,10 +10,9 @@ from pathlib import Path
|
|
|
10
10
|
import sys
|
|
11
11
|
import os
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
|
13
|
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
|
|
15
14
|
|
|
16
|
-
from
|
|
15
|
+
from iq_saver.iq_saver import IQSaver
|
|
17
16
|
import sigmf
|
|
18
17
|
|
|
19
18
|
|
|
@@ -394,6 +393,61 @@ def test_spectrum_dapp_integration_pattern():
|
|
|
394
393
|
print("✓ Test 7 PASSED\n")
|
|
395
394
|
|
|
396
395
|
|
|
396
|
+
def test_metadata_accumulation_across_flushes():
|
|
397
|
+
"""Verify that repeated tofile calls accumulate all annotations, never losing earlier ones."""
|
|
398
|
+
print("=" * 80)
|
|
399
|
+
print("Test 8: Metadata accumulation across flushes")
|
|
400
|
+
print("=" * 80)
|
|
401
|
+
|
|
402
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
403
|
+
saver = IQSaver(
|
|
404
|
+
base_path=tmpdir,
|
|
405
|
+
center_freq=3.6192e9,
|
|
406
|
+
bandwidth=38.16e6,
|
|
407
|
+
annotation_flush_interval=200, # manual flush only
|
|
408
|
+
num_prbs=106
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
samples = (np.random.randn(1024) + 1j * np.random.randn(1024)) * 1000
|
|
412
|
+
saver.save_samples(samples.astype(np.complex64))
|
|
413
|
+
|
|
414
|
+
# First batch of annotations, flushed to disk
|
|
415
|
+
saver.add_annotation(label='batch_1_a', comment='first flush, annotation A')
|
|
416
|
+
saver.add_annotation(label='batch_1_b', comment='first flush, annotation B')
|
|
417
|
+
saver.finalize_annotations()
|
|
418
|
+
|
|
419
|
+
meta_file = list(Path(tmpdir).glob('*.sigmf-meta'))[0]
|
|
420
|
+
with open(meta_file) as f:
|
|
421
|
+
meta = json.load(f)
|
|
422
|
+
assert len(meta.get('annotations', [])) == 2, "Expected 2 annotations after first flush"
|
|
423
|
+
print("✓ First flush: 2 annotations on disk")
|
|
424
|
+
|
|
425
|
+
# Second batch, flushed on top of the first
|
|
426
|
+
saver.add_annotation(label='batch_2_a', comment='second flush, annotation A')
|
|
427
|
+
saver.finalize_annotations()
|
|
428
|
+
|
|
429
|
+
with open(meta_file) as f:
|
|
430
|
+
meta = json.load(f)
|
|
431
|
+
assert len(meta.get('annotations', [])) == 3, \
|
|
432
|
+
f"Expected 3 annotations after second flush, got {len(meta.get('annotations', []))}"
|
|
433
|
+
print("✓ Second flush: all 3 annotations preserved on disk")
|
|
434
|
+
|
|
435
|
+
# Third batch flushed via close()
|
|
436
|
+
saver.add_annotation(label='batch_3_a', comment='third flush via close')
|
|
437
|
+
saver.close()
|
|
438
|
+
|
|
439
|
+
with open(meta_file) as f:
|
|
440
|
+
meta = json.load(f)
|
|
441
|
+
assert len(meta.get('annotations', [])) == 4, \
|
|
442
|
+
f"Expected 4 annotations after close, got {len(meta.get('annotations', []))}"
|
|
443
|
+
labels = [a.get('core:label') for a in meta['annotations']]
|
|
444
|
+
assert 'batch_1_a' in labels and 'batch_1_b' in labels
|
|
445
|
+
assert 'batch_2_a' in labels
|
|
446
|
+
assert 'batch_3_a' in labels
|
|
447
|
+
print("✓ close(): all 4 annotations preserved, no data lost across flushes")
|
|
448
|
+
print("✓ Test 8 PASSED\n")
|
|
449
|
+
|
|
450
|
+
|
|
397
451
|
if __name__ == "__main__":
|
|
398
452
|
print("\n" + "=" * 80)
|
|
399
453
|
print("IQSaver Test Suite")
|
|
@@ -407,6 +461,7 @@ if __name__ == "__main__":
|
|
|
407
461
|
test_context_manager,
|
|
408
462
|
test_deferred_annotations,
|
|
409
463
|
test_spectrum_dapp_integration_pattern,
|
|
464
|
+
test_metadata_accumulation_across_flushes,
|
|
410
465
|
]
|
|
411
466
|
|
|
412
467
|
passed = 0
|
dapps-0.0.7/VERSION
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.0.7
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|