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.
Files changed (56) hide show
  1. {dapps-0.0.7 → dapps-0.0.8}/.github/workflows/mirror.yml +1 -1
  2. {dapps-0.0.7 → dapps-0.0.8}/.github/workflows/tag-and-release.yml +3 -3
  3. dapps-0.0.8/.github/workflows/test.yml +24 -0
  4. dapps-0.0.8/CONTRIBUTORS +44 -0
  5. {dapps-0.0.7 → dapps-0.0.8}/PKG-INFO +15 -4
  6. {dapps-0.0.7 → dapps-0.0.8}/README.md +13 -2
  7. dapps-0.0.8/VERSION +1 -0
  8. {dapps-0.0.7 → dapps-0.0.8}/examples/spectrum_dapp.py +11 -1
  9. {dapps-0.0.7 → dapps-0.0.8}/pyproject.toml +4 -2
  10. {dapps-0.0.7 → dapps-0.0.8}/src/iq_saver/iq_saver.py +45 -7
  11. {dapps-0.0.7 → dapps-0.0.8}/src/spectrum/spectrum_dapp.py +21 -0
  12. {dapps-0.0.7 → dapps-0.0.8}/src/visualization/dashboard.py +72 -13
  13. {dapps-0.0.7 → dapps-0.0.8}/src/visualization/templates/index.html +120 -4
  14. dapps-0.0.8/tests/test_dashboard.py +43 -0
  15. {dapps-0.0.7 → dapps-0.0.8}/tests/test_iq_saver.py +58 -3
  16. dapps-0.0.7/VERSION +0 -1
  17. {dapps-0.0.7 → dapps-0.0.8}/.gitignore +0 -0
  18. {dapps-0.0.7 → dapps-0.0.8}/CONTRIBUTING.md +0 -0
  19. {dapps-0.0.7 → dapps-0.0.8}/LICENSE +0 -0
  20. {dapps-0.0.7 → dapps-0.0.8}/MANIFEST.in +0 -0
  21. {dapps-0.0.7 → dapps-0.0.8}/docs/dapp_uml.txt +0 -0
  22. {dapps-0.0.7 → dapps-0.0.8}/docs/gui_uml.txt +0 -0
  23. {dapps-0.0.7 → dapps-0.0.8}/examples/nvidia_dapp.py +0 -0
  24. {dapps-0.0.7 → dapps-0.0.8}/examples/simple_dapp.py +0 -0
  25. {dapps-0.0.7 → dapps-0.0.8}/src/dapp/__init__.py +0 -0
  26. {dapps-0.0.7 → dapps-0.0.8}/src/dapp/dapp.py +0 -0
  27. {dapps-0.0.7 → dapps-0.0.8}/src/e3interface/__init__.py +0 -0
  28. {dapps-0.0.7 → dapps-0.0.8}/src/e3interface/defs/e3.asn +0 -0
  29. {dapps-0.0.7 → dapps-0.0.8}/src/e3interface/defs/e3.json +0 -0
  30. {dapps-0.0.7 → dapps-0.0.8}/src/e3interface/e3_connector.py +0 -0
  31. {dapps-0.0.7 → dapps-0.0.8}/src/e3interface/e3_encoder.py +0 -0
  32. {dapps-0.0.7 → dapps-0.0.8}/src/e3interface/e3_interface.py +0 -0
  33. {dapps-0.0.7 → dapps-0.0.8}/src/e3interface/e3_logging.py +0 -0
  34. {dapps-0.0.7 → dapps-0.0.8}/src/iq_saver/__init__.py +0 -0
  35. {dapps-0.0.7 → dapps-0.0.8}/src/nvidia/__init__.py +0 -0
  36. {dapps-0.0.7 → dapps-0.0.8}/src/nvidia/defs/e3sm_nvidia.asn +0 -0
  37. {dapps-0.0.7 → dapps-0.0.8}/src/nvidia/defs/e3sm_nvidia.json +0 -0
  38. {dapps-0.0.7 → dapps-0.0.8}/src/nvidia/nvidia_dapp.py +0 -0
  39. {dapps-0.0.7 → dapps-0.0.8}/src/simple/__init__.py +0 -0
  40. {dapps-0.0.7 → dapps-0.0.8}/src/simple/defs/e3sm_simple.asn +0 -0
  41. {dapps-0.0.7 → dapps-0.0.8}/src/simple/simple_dapp.py +0 -0
  42. {dapps-0.0.7 → dapps-0.0.8}/src/spectrum/__init__.py +0 -0
  43. {dapps-0.0.7 → dapps-0.0.8}/src/spectrum/adaptive_noise_floor.py +0 -0
  44. {dapps-0.0.7 → dapps-0.0.8}/src/spectrum/defs/e3sm_spectrum.asn +0 -0
  45. {dapps-0.0.7 → dapps-0.0.8}/src/spectrum/defs/e3sm_spectrum.json +0 -0
  46. {dapps-0.0.7 → dapps-0.0.8}/src/spectrum/embargo_manager.py +0 -0
  47. {dapps-0.0.7 → dapps-0.0.8}/src/spectrum/threshold_detector.py +0 -0
  48. {dapps-0.0.7 → dapps-0.0.8}/src/visualization/__init__.py +0 -0
  49. {dapps-0.0.7 → dapps-0.0.8}/src/visualization/energy.py +0 -0
  50. {dapps-0.0.7 → dapps-0.0.8}/src/visualization/grafana.py +0 -0
  51. {dapps-0.0.7 → dapps-0.0.8}/src/visualization/influx.py +0 -0
  52. {dapps-0.0.7 → dapps-0.0.8}/src/visualization/iq.py +0 -0
  53. {dapps-0.0.7 → dapps-0.0.8}/tools/README.md +0 -0
  54. {dapps-0.0.7 → dapps-0.0.8}/tools/asntojson.py +0 -0
  55. {dapps-0.0.7 → dapps-0.0.8}/tools/generate_sm.sh +0 -0
  56. {dapps-0.0.7 → dapps-0.0.8}/tools/read_recording.py +0 -0
@@ -12,7 +12,7 @@ jobs:
12
12
 
13
13
  steps:
14
14
  - name: Checkout
15
- uses: actions/checkout@v4
15
+ uses: actions/checkout@v6
16
16
  with:
17
17
  fetch-depth: '0' # Ensures the entire history is fetched
18
18
 
@@ -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@v4
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@v4
57
+ - uses: actions/checkout@v6
58
58
  with:
59
59
  persist-credentials: false
60
60
  - name: Set up Python
61
- uses: actions/setup-python@v5
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
@@ -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.7
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>, Filippo Olimpieri <filippo.olimpieri@gmail.com>, Chris Wozny <woznych@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` - Down-sampling ratio applied to IQ sensing. An IQ vector will be delivered every Nth sensing (default: 5)
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
- - `--adaptive-hist-depth` - 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)
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` - Down-sampling ratio applied to IQ sensing. An IQ vector will be delivered every Nth sensing (default: 5)
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
- - `--adaptive-hist-depth` - 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)
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="Deliver IQ every N sensing cycles (each cycle is 10 ms)")
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 condition (frame count threshold)
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
- 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
- exc_info=True
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
- # We do not show every iqs otherwise the GUI is too slow
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=7778, debug=False, use_reloader=False)
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
- server_app = Dashboard()
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 id="predicted_label"></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: 24}
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
- # Add parent directory to path for imports
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 spear.iq_saver.iq_saver import IQSaver
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