spectre-core 0.0.11__py3-none-any.whl → 0.0.13__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. spectre_core/_file_io/__init__.py +1 -3
  2. spectre_core/_file_io/file_handlers.py +170 -65
  3. spectre_core/batches/__init__.py +21 -0
  4. spectre_core/batches/_base.py +238 -0
  5. spectre_core/batches/_batches.py +247 -0
  6. spectre_core/batches/_factory.py +69 -0
  7. spectre_core/batches/_register.py +30 -0
  8. spectre_core/batches/plugins/_batch_keys.py +16 -0
  9. spectre_core/batches/plugins/_callisto.py +183 -0
  10. spectre_core/batches/plugins/_iq_stream.py +354 -0
  11. spectre_core/capture_configs/__init__.py +17 -13
  12. spectre_core/capture_configs/_capture_config.py +93 -34
  13. spectre_core/capture_configs/_capture_modes.py +22 -0
  14. spectre_core/capture_configs/_capture_templates.py +207 -122
  15. spectre_core/capture_configs/_parameters.py +116 -46
  16. spectre_core/capture_configs/_pconstraints.py +86 -35
  17. spectre_core/capture_configs/_pnames.py +49 -0
  18. spectre_core/capture_configs/_ptemplates.py +389 -346
  19. spectre_core/capture_configs/_pvalidators.py +121 -77
  20. spectre_core/config/__init__.py +7 -9
  21. spectre_core/config/_paths.py +66 -26
  22. spectre_core/config/_time_formats.py +15 -8
  23. spectre_core/exceptions.py +2 -4
  24. spectre_core/jobs/__init__.py +14 -0
  25. spectre_core/jobs/_jobs.py +111 -0
  26. spectre_core/jobs/_workers.py +171 -0
  27. spectre_core/logs/__init__.py +17 -0
  28. spectre_core/logs/_configure.py +67 -0
  29. spectre_core/logs/_decorators.py +33 -0
  30. spectre_core/logs/_logs.py +228 -0
  31. spectre_core/logs/_process_types.py +14 -0
  32. spectre_core/plotting/__init__.py +4 -2
  33. spectre_core/plotting/_base.py +204 -102
  34. spectre_core/plotting/_format.py +17 -4
  35. spectre_core/plotting/_panel_names.py +18 -0
  36. spectre_core/plotting/_panel_stack.py +167 -53
  37. spectre_core/plotting/_panels.py +341 -141
  38. spectre_core/post_processing/__init__.py +8 -6
  39. spectre_core/post_processing/_base.py +71 -45
  40. spectre_core/post_processing/_factory.py +42 -12
  41. spectre_core/post_processing/_post_processor.py +27 -29
  42. spectre_core/post_processing/_register.py +22 -6
  43. spectre_core/post_processing/plugins/_event_handler_keys.py +16 -0
  44. spectre_core/post_processing/plugins/_fixed_center_frequency.py +129 -0
  45. spectre_core/post_processing/plugins/_swept_center_frequency.py +439 -0
  46. spectre_core/py.typed +0 -0
  47. spectre_core/receivers/__init__.py +10 -7
  48. spectre_core/receivers/_base.py +220 -69
  49. spectre_core/receivers/_factory.py +53 -7
  50. spectre_core/receivers/_register.py +30 -9
  51. spectre_core/receivers/_spec_names.py +26 -15
  52. spectre_core/receivers/plugins/__init__.py +0 -0
  53. spectre_core/receivers/plugins/_receiver_names.py +16 -0
  54. spectre_core/receivers/plugins/_rsp1a.py +59 -0
  55. spectre_core/receivers/plugins/_rspduo.py +67 -0
  56. spectre_core/receivers/plugins/_sdrplay_receiver.py +190 -0
  57. spectre_core/receivers/plugins/_test.py +218 -0
  58. spectre_core/receivers/plugins/gr/_base.py +80 -0
  59. spectre_core/receivers/{gr → plugins/gr}/_rsp1a.py +45 -55
  60. spectre_core/receivers/{gr → plugins/gr}/_rspduo.py +65 -78
  61. spectre_core/receivers/{gr → plugins/gr}/_test.py +36 -34
  62. spectre_core/spectrograms/__init__.py +5 -3
  63. spectre_core/spectrograms/_analytical.py +121 -72
  64. spectre_core/spectrograms/_array_operations.py +103 -36
  65. spectre_core/spectrograms/_spectrogram.py +410 -203
  66. spectre_core/spectrograms/_transform.py +199 -188
  67. spectre_core/wgetting/__init__.py +4 -2
  68. spectre_core/wgetting/_callisto.py +178 -127
  69. {spectre_core-0.0.11.dist-info → spectre_core-0.0.13.dist-info}/METADATA +14 -7
  70. spectre_core-0.0.13.dist-info/RECORD +75 -0
  71. {spectre_core-0.0.11.dist-info → spectre_core-0.0.13.dist-info}/WHEEL +1 -1
  72. spectre_core/chunks/__init__.py +0 -22
  73. spectre_core/chunks/_base.py +0 -116
  74. spectre_core/chunks/_chunks.py +0 -200
  75. spectre_core/chunks/_factory.py +0 -25
  76. spectre_core/chunks/_register.py +0 -15
  77. spectre_core/chunks/library/_callisto.py +0 -98
  78. spectre_core/chunks/library/_fixed_center_frequency.py +0 -128
  79. spectre_core/chunks/library/_swept_center_frequency.py +0 -103
  80. spectre_core/logging/__init__.py +0 -11
  81. spectre_core/logging/_configure.py +0 -35
  82. spectre_core/logging/_decorators.py +0 -19
  83. spectre_core/logging/_log_handlers.py +0 -176
  84. spectre_core/post_processing/library/_fixed_center_frequency.py +0 -115
  85. spectre_core/post_processing/library/_swept_center_frequency.py +0 -382
  86. spectre_core/receivers/gr/_base.py +0 -33
  87. spectre_core/receivers/library/_rsp1a.py +0 -61
  88. spectre_core/receivers/library/_rspduo.py +0 -69
  89. spectre_core/receivers/library/_sdrplay_receiver.py +0 -185
  90. spectre_core/receivers/library/_test.py +0 -221
  91. spectre_core-0.0.11.dist-info/RECORD +0 -64
  92. /spectre_core/receivers/{gr → plugins/gr}/__init__.py +0 -0
  93. {spectre_core-0.0.11.dist-info → spectre_core-0.0.13.dist-info}/LICENSE +0 -0
  94. {spectre_core-0.0.11.dist-info → spectre_core-0.0.13.dist-info}/top_level.txt +0 -0
@@ -1,176 +0,0 @@
1
- # SPDX-FileCopyrightText: © 2024 Jimmy Fitzpatrick <jcfitzpatrick12@gmail.com>
2
- # This file is part of SPECTRE
3
- # SPDX-License-Identifier: GPL-3.0-or-later
4
-
5
-
6
- from logging import getLogger
7
- _LOGGER = getLogger(__name__)
8
-
9
- import os
10
- import logging
11
- from dataclasses import dataclass
12
- from typing import Optional
13
- import warnings
14
- from collections import OrderedDict
15
- from datetime import datetime
16
-
17
- from spectre_core._file_io import TextHandler
18
- from spectre_core.config import get_logs_dir_path, TimeFormats
19
-
20
- PROCESS_TYPES = [
21
- "user",
22
- "worker"
23
- ]
24
-
25
- def _validate_process_type(process_type: str
26
- ) -> None:
27
- if process_type not in PROCESS_TYPES:
28
- raise ValueError(f"Invalid process type: {process_type}. Expected one of {PROCESS_TYPES}")
29
-
30
-
31
- class LogHandler(TextHandler):
32
- def __init__(self,
33
- datetime_stamp: str,
34
- pid: str,
35
- process_type: str):
36
- self._datetime_stamp = datetime_stamp
37
- self._pid = pid
38
- _validate_process_type(process_type)
39
- self._process_type = process_type
40
-
41
- dt = datetime.strptime(datetime_stamp, TimeFormats.DATETIME)
42
- parent_path = get_logs_dir_path(dt.year, dt.month, dt.day)
43
- base_file_name = f"{datetime_stamp}_{pid}_{process_type}"
44
-
45
- super().__init__(parent_path, base_file_name, extension = "log")
46
-
47
-
48
- @property
49
- def datetime_stamp(self) -> str:
50
- return self._datetime_stamp
51
-
52
-
53
- @property
54
- def pid(self) -> str:
55
- return self._pid
56
-
57
-
58
- @property
59
- def process_type(self) -> str:
60
- return self._process_type
61
-
62
-
63
- class LogHandlers:
64
- def __init__(self,
65
- process_type: Optional[str] = None,
66
- year: Optional[int] = None,
67
- month: Optional[int] = None,
68
- day: Optional[int] = None):
69
- self._log_handler_map: dict[str, LogHandler] = OrderedDict()
70
- self._process_type = process_type
71
- self.set_date(year, month, day)
72
-
73
-
74
- @property
75
- def process_type(self) -> str:
76
- return self._process_type
77
-
78
-
79
- @property
80
- def year(self) -> Optional[int]:
81
- return self._year
82
-
83
-
84
- @property
85
- def month(self) -> Optional[int]:
86
- return self._month
87
-
88
-
89
- @property
90
- def day(self) -> Optional[int]:
91
- return self._day
92
-
93
-
94
- @property
95
- def logs_dir_path(self) -> str:
96
- return get_logs_dir_path(self.year, self.month, self.day)
97
-
98
-
99
- @property
100
- def log_handler_map(self) -> dict[str, LogHandler]:
101
- if not self._log_handler_map: # check for empty dictionary
102
- self._update_log_handler_map()
103
- return self._log_handler_map
104
-
105
-
106
- @property
107
- def log_handler_list(self) -> list[LogHandler]:
108
- return list(self.log_handler_map.values())
109
-
110
-
111
- @property
112
- def num_logs(self) -> int:
113
- return len(self.log_handler_list)
114
-
115
-
116
- @property
117
- def file_names(self) -> list[str]:
118
- return list(self.log_handler_map.keys())
119
-
120
-
121
- def set_date(self,
122
- year: Optional[int],
123
- month: Optional[int],
124
- day: Optional[int]) -> None:
125
- self._year = year
126
- self._month = month
127
- self._day = day
128
- self._update_log_handler_map()
129
-
130
-
131
- def _update_log_handler_map(self) -> None:
132
- log_files = [f for (_, _, files) in os.walk(self.logs_dir_path) for f in files]
133
-
134
- if not log_files:
135
- warning_message = "No logs found, setting log map to an empty dictionary"
136
- _LOGGER.warning(warning_message)
137
- warnings.warn(warning_message)
138
- return
139
-
140
- for log_file in log_files:
141
- file_name, _ = os.path.splitext(log_file)
142
- log_start_time, pid, process_type = file_name.split("_")
143
-
144
- if self.process_type and process_type != self.process_type:
145
- continue
146
-
147
- self._log_handler_map[file_name] = LogHandler(log_start_time, pid, process_type)
148
-
149
- self._log_handler_map = OrderedDict(sorted(self._log_handler_map.items()))
150
-
151
-
152
- def update(self) -> None:
153
- """Public alias for setting log handler map"""
154
- self._update_log_handler_map()
155
-
156
-
157
- def __iter__(self):
158
- yield from self.log_handler_list
159
-
160
-
161
- def get_from_file_name(self,
162
- file_name: str) -> LogHandler:
163
- # auto strip the extension if present
164
- file_name, _ = os.path.splitext(file_name)
165
- try:
166
- return self.log_handler_map[file_name]
167
- except KeyError:
168
- raise FileNotFoundError(f"Log handler for file name '{file_name}' not found in log map")
169
-
170
-
171
- def get_from_pid(self,
172
- pid: str) -> LogHandler:
173
- for log_handler in self.log_handler_list:
174
- if log_handler.pid == pid:
175
- return log_handler
176
- raise FileNotFoundError(f"Log handler for PID '{pid}' not found in log map")
@@ -1,115 +0,0 @@
1
- # SPDX-FileCopyrightText: © 2024 Jimmy Fitzpatrick <jcfitzpatrick12@gmail.com>
2
- # This file is part of SPECTRE
3
- # SPDX-License-Identifier: GPL-3.0-or-later
4
-
5
- from logging import getLogger
6
- _LOGGER = getLogger(__name__)
7
-
8
- import numpy as np
9
- from typing import Tuple
10
-
11
- import os
12
-
13
- from spectre_core.capture_configs import CaptureConfig, PNames, CaptureModes
14
- from spectre_core.chunks import BaseChunk
15
- from spectre_core.spectrograms import Spectrogram, time_average, frequency_average
16
- from .._base import BaseEventHandler, make_sft_instance
17
- from .._register import register_event_handler
18
-
19
-
20
- def _do_stfft(iq_data: np.array,
21
- capture_config: CaptureConfig,
22
- ) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
23
- """For reference: https://docs.scipy.org/doc/scipy/reference/generated/scipy.signal.ShortTimeFFT.html"""
24
-
25
- sft = make_sft_instance(capture_config)
26
-
27
- # set p0=0, since by convention in the STFFT docs, p=0 corresponds to the slice centred at t=0
28
- p0=0
29
-
30
- # set p1 to the index of the first slice where the "midpoint" of the window is still inside the signal
31
- num_samples = len(iq_data)
32
- p1 = sft.upper_border_begin(num_samples)[1]
33
-
34
- # compute a ShortTimeFFT on the IQ samples
35
- complex_spectra = sft.stft(iq_data,
36
- p0 = p0,
37
- p1 = p1)
38
-
39
- # compute the magnitude of each spectral component
40
- dynamic_spectra = np.abs(complex_spectra)
41
-
42
-
43
- # assign a physical time to each spectrum
44
- # p0 is defined to correspond with the first sample, at t=0 [s]
45
- times = sft.t(num_samples,
46
- p0 = p0,
47
- p1 = p1)
48
- # assign physical frequencies to each spectral component
49
- frequencies = sft.f + capture_config.get_parameter_value(PNames.CENTER_FREQUENCY)
50
-
51
- return times, frequencies, dynamic_spectra
52
-
53
-
54
- def _build_spectrogram(chunk: BaseChunk,
55
- capture_config: CaptureConfig) -> Spectrogram:
56
- """Create a spectrogram by performing a Short Time FFT on the IQ samples for this chunk."""
57
-
58
- # read the data from the chunk
59
- millisecond_correction = chunk.read_file("hdr")
60
- iq_data = chunk.read_file("bin")
61
-
62
- # units conversion
63
- microsecond_correction = millisecond_correction * 1e3
64
-
65
- times, frequencies, dynamic_spectra = _do_stfft(iq_data,
66
- capture_config)
67
-
68
- # explicitly type cast data arrays to 32-bit floats
69
- times = np.array(times, dtype = 'float32')
70
- frequencies = np.array(frequencies, dtype = 'float32')
71
- dynamic_spectra = np.array(dynamic_spectra, dtype = 'float32')
72
-
73
- return Spectrogram(dynamic_spectra,
74
- times,
75
- frequencies,
76
- chunk.tag,
77
- chunk_start_time = chunk.chunk_start_time,
78
- microsecond_correction = microsecond_correction,
79
- spectrum_type = "amplitude")
80
-
81
-
82
- @register_event_handler(CaptureModes.FIXED_CENTER_FREQUENCY)
83
- class _EventHandler(BaseEventHandler):
84
- def __init__(self, *args, **kwargs):
85
- super().__init__(*args, **kwargs)
86
-
87
- def process(self,
88
- absolute_file_path: str):
89
- _LOGGER.info(f"Processing: {absolute_file_path}")
90
- file_name = os.path.basename(absolute_file_path)
91
- base_file_name, _ = os.path.splitext(file_name)
92
- chunk_start_time, tag = base_file_name.split('_')
93
-
94
- # create an instance of the current chunk being processed
95
- chunk = self._Chunk(chunk_start_time, tag)
96
-
97
- _LOGGER.info("Creating spectrogram")
98
- spectrogram = _build_spectrogram(chunk,
99
- self._capture_config)
100
-
101
- spectrogram = time_average(spectrogram,
102
- resolution = self._capture_config.get_parameter_value(PNames.TIME_RESOLUTION))
103
-
104
- spectrogram = frequency_average(spectrogram,
105
- resolution = self._capture_config.get_parameter_value(PNames.FREQUENCY_RESOLUTION))
106
-
107
- self._cache_spectrogram(spectrogram)
108
-
109
- bin_chunk = chunk.get_file('bin')
110
- _LOGGER.info(f"Deleting {bin_chunk.file_path}")
111
- bin_chunk.delete()
112
-
113
- hdr_chunk = chunk.get_file('hdr')
114
- _LOGGER.info(f"Deleting {hdr_chunk.file_path}")
115
- hdr_chunk.delete()
@@ -1,382 +0,0 @@
1
- # SPDX-FileCopyrightText: © 2024 Jimmy Fitzpatrick <jcfitzpatrick12@gmail.com>
2
- # This file is part of SPECTRE
3
- # SPDX-License-Identifier: GPL-3.0-or-later
4
-
5
- from logging import getLogger
6
- _LOGGER = getLogger(__name__)
7
-
8
- import os
9
- from typing import Optional
10
- from typing import Tuple
11
- from datetime import timedelta, datetime
12
- import numpy as np
13
-
14
- from scipy.signal import ShortTimeFFT
15
-
16
- from spectre_core.spectrograms import Spectrogram, time_average, frequency_average
17
- from spectre_core.config import TimeFormats
18
- from spectre_core.capture_configs import CaptureConfig, PNames, CaptureModes
19
- from spectre_core.chunks import BaseChunk
20
- from spectre_core.chunks import SweepMetadata
21
- from spectre_core.exceptions import InvalidSweepMetadataError
22
- from .._base import BaseEventHandler, make_sft_instance
23
- from .._register import register_event_handler
24
-
25
-
26
- def _stitch_steps(stepped_dynamic_spectra: np.ndarray,
27
- num_full_sweeps: int) -> np.ndarray:
28
- """For each full sweep, create a swept spectrum by stitching together the spectrum at each of the steps."""
29
- return stepped_dynamic_spectra.reshape((num_full_sweeps, -1)).T
30
-
31
-
32
- def _average_over_steps(stepped_dynamic_spectra: np.ndarray) -> None:
33
- """Average the spectrums in each step totally in time."""
34
- return np.nanmean(stepped_dynamic_spectra[..., 1:], axis=-1)
35
-
36
-
37
- def _fill_times(times: np.ndarray,
38
- num_samples: np.ndarray,
39
- sample_rate: int,
40
- num_full_sweeps: int,
41
- num_steps_per_sweep: int) -> None:
42
- """Assign physical times to each swept spectrum. We use (by convention) the time of the first sample in eahc sweep"""
43
-
44
- sampling_interval = 1 / sample_rate
45
- cumulative_samples = 0
46
- for sweep_index in range(num_full_sweeps):
47
- # assign a physical time to the spectrum for this sweep
48
- times[sweep_index] = cumulative_samples * sampling_interval
49
-
50
- # find the total number of samples across the sweep
51
- start_step = sweep_index * num_steps_per_sweep
52
- end_step = (sweep_index + 1) * num_steps_per_sweep
53
-
54
- # update cumulative samples
55
- cumulative_samples += np.sum(num_samples[start_step:end_step])
56
-
57
-
58
- def _fill_frequencies(frequencies: np.ndarray,
59
- center_frequencies: np.ndarray,
60
- baseband_frequencies: np.ndarray,
61
- window_size: int) -> None:
62
- """Assign physical frequencies to each of the swept spectral components."""
63
- for i, center_frequency in enumerate(np.unique(center_frequencies)):
64
- lower_bound = i * window_size
65
- upper_bound = (i + 1) * window_size
66
- frequencies[lower_bound:upper_bound] = (baseband_frequencies + center_frequency)
67
-
68
-
69
- def _fill_stepped_dynamic_spectra(stepped_dynamic_spectra: np.ndarray,
70
- sft: ShortTimeFFT,
71
- iq_data: np.ndarray,
72
- num_samples: np.ndarray,
73
- num_full_sweeps: int,
74
- num_steps_per_sweep: int) -> None:
75
- """Compute the dynamic spectra for the input IQ samples for each step.
76
-
77
- All IQ samples per step were collected at the same center frequency.
78
- """
79
- # global_step_index will hold the step index over all sweeps (doesn't reset each sweep)
80
- # start_sample_index will hold the index of the first sample in the step
81
- global_step_index, start_sample_index = 0, 0
82
- for sweep_index in range(num_full_sweeps):
83
- for step_index in range(num_steps_per_sweep):
84
- # extract how many samples are in the current step from the metadata
85
- end_sample_index = start_sample_index + num_samples[global_step_index]
86
- # compute the number of slices in the current step based on the window we defined on the capture config
87
- num_slices = sft.upper_border_begin(num_samples[global_step_index])[1]
88
- # perform a short time fast fourier transform on the step
89
- complex_spectra = sft.stft(iq_data[start_sample_index:end_sample_index],
90
- p0=0,
91
- p1=num_slices)
92
- # and pack the absolute values into the stepped spectrogram where the step slot is padded to the maximum size for ease of processing later)
93
- stepped_dynamic_spectra[sweep_index, step_index, :, :num_slices] = np.abs(complex_spectra)
94
- # reassign the start_sample_index for the next step
95
- start_sample_index = end_sample_index
96
- # and increment the global step index
97
- global_step_index += 1
98
-
99
-
100
- def _compute_num_max_slices_in_step(sft: ShortTimeFFT,
101
- num_samples: np.ndarray) -> int:
102
- """Compute the maximum number of slices over all steps, in all sweeps over the chunk."""
103
- return sft.upper_border_begin(np.max(num_samples))[1]
104
-
105
-
106
- def _compute_num_full_sweeps(center_frequencies: np.ndarray) -> int:
107
- """Compute the total number of full sweeps over the chunk.
108
-
109
- Since the number of each samples in each step is variable, we only know a sweep is complete
110
- when there is a sweep after it. So we can define the total number of *full* sweeps as the number of
111
- (freq_max, freq_min) pairs in center_frequencies. It is only at an instance of (freq_max, freq_min) pair
112
- in center frequencies that the frequency decreases, so, we can compute the number of full sweeps by
113
- counting the numbers of negative values in np.diff(center_frequencies)
114
- """
115
- return len(np.where(np.diff(center_frequencies) < 0)[0])
116
-
117
-
118
- def _compute_num_steps_per_sweep(center_frequencies: np.ndarray) -> int:
119
- """Compute the (ensured constant) number of steps in each sweep."""
120
- # find the (step) indices corresponding to the minimum frequencies
121
- min_freq_indices = np.where(center_frequencies == np.min(center_frequencies))[0]
122
- # then, we evaluate the number of steps that has occured between them via np.diff over the indices
123
- unique_num_steps_per_sweep = np.unique(np.diff(min_freq_indices))
124
- # we expect that the difference is always the same, so that the result of np.unique has a single element
125
- if len(unique_num_steps_per_sweep) != 1:
126
- raise InvalidSweepMetadataError(("Irregular step count per sweep, "
127
- "expected a consistent number of steps per sweep"))
128
- return int(unique_num_steps_per_sweep[0])
129
-
130
-
131
- def _validate_center_frequencies_ordering(center_frequencies: np.ndarray,
132
- freq_step: float) -> None:
133
- """Check that the center frequencies are well-ordered in the detached header"""
134
- min_frequency = np.min(center_frequencies)
135
- # Extract the expected difference between each step within a sweep.
136
- for i, diff in enumerate(np.diff(center_frequencies)):
137
- # steps should either increase by freq_step or drop to the minimum
138
- if (diff != freq_step) and (center_frequencies[i + 1] != min_frequency):
139
- raise InvalidSweepMetadataError(f"Unordered center frequencies detected")
140
-
141
-
142
- def _do_stfft(iq_data: np.ndarray,
143
- sweep_metadata: SweepMetadata,
144
- capture_config: CaptureConfig):
145
- """Perform a Short Time FFT on the input swept IQ samples."""
146
-
147
- sft = make_sft_instance(capture_config)
148
-
149
- frequency_step = capture_config.get_parameter_value(PNames.FREQUENCY_STEP)
150
- _validate_center_frequencies_ordering(sweep_metadata.center_frequencies,
151
- frequency_step)
152
-
153
- window_size = capture_config.get_parameter_value(PNames.WINDOW_SIZE)
154
-
155
- num_steps_per_sweep = _compute_num_steps_per_sweep(sweep_metadata.center_frequencies)
156
- num_full_sweeps = _compute_num_full_sweeps(sweep_metadata.center_frequencies)
157
- num_max_slices_in_step = _compute_num_max_slices_in_step(sft,
158
- sweep_metadata.num_samples)
159
-
160
- stepped_dynamic_spectra_shape = (num_full_sweeps,
161
- num_steps_per_sweep,
162
- window_size,
163
- num_max_slices_in_step)
164
- stepped_dynamic_spectra = np.full(stepped_dynamic_spectra_shape, np.nan)
165
-
166
- frequencies = np.empty(num_steps_per_sweep * window_size)
167
- times = np.empty(num_full_sweeps)
168
-
169
- _fill_stepped_dynamic_spectra(stepped_dynamic_spectra,
170
- sft,
171
- iq_data,
172
- sweep_metadata.num_samples,
173
- num_full_sweeps,
174
- num_steps_per_sweep)
175
-
176
- _fill_frequencies(frequencies,
177
- sweep_metadata.center_frequencies,
178
- sft.f,
179
- window_size)
180
-
181
- sample_rate = capture_config.get_parameter_value(PNames.SAMPLE_RATE)
182
- _fill_times(times,
183
- sweep_metadata.num_samples,
184
- sample_rate,
185
- num_full_sweeps,
186
- num_steps_per_sweep)
187
-
188
- averaged_spectra = _average_over_steps(stepped_dynamic_spectra)
189
- dynamic_spectra = _stitch_steps(averaged_spectra,
190
- num_full_sweeps)
191
-
192
- return times, frequencies, dynamic_spectra
193
-
194
-
195
- def _correct_timing(chunk_start_datetime: datetime,
196
- millisecond_correction: int,
197
- num_samples_prepended: int,
198
- sample_rate: int):
199
- """Correct the start time for this chunk based on the number of samples we prepended reconstructing the initial sweep."""
200
- sample_interval = (1 / sample_rate)
201
- elapsed_time = num_samples_prepended * sample_interval
202
- corrected_datetime = chunk_start_datetime + timedelta(milliseconds = millisecond_correction) - timedelta(seconds = float(elapsed_time))
203
- return corrected_datetime.strftime(TimeFormats.DATETIME), corrected_datetime.microsecond * 1e-3
204
-
205
-
206
- def _prepend_num_samples(carryover_num_samples: np.ndarray,
207
- num_samples: np.ndarray,
208
- final_step_spans_two_chunks: bool) -> np.ndarray:
209
- """Prepend the number of samples from the final sweep of the previous chunk."""
210
- if final_step_spans_two_chunks:
211
- # ensure the number of samples from the final step in the previous chunk are accounted for
212
- num_samples[0] += carryover_num_samples[-1]
213
- # and truncate as required
214
- carryover_num_samples = carryover_num_samples[:-1]
215
- return np.concatenate((carryover_num_samples, num_samples))
216
-
217
-
218
- def _prepend_center_frequencies(carryover_center_frequencies: np.ndarray,
219
- center_frequencies: np.ndarray,
220
- final_step_spans_two_chunks: bool)-> np.ndarray:
221
- """Prepend the center frequencies from the final sweep of the previous chunk."""
222
- # in the case that the sweep has bled across chunks,
223
- # do not permit identical neighbours in the center frequency array
224
- if final_step_spans_two_chunks:
225
- # truncate the final frequency to prepend (as it already exists in the array we are appending to in this case)
226
- carryover_center_frequencies = carryover_center_frequencies[:-1]
227
- return np.concatenate((carryover_center_frequencies, center_frequencies))
228
-
229
-
230
- def _prepend_iq_data(carryover_iq_data: np.ndarray,
231
- iq_data: np.ndarray) -> np.ndarray:
232
- """Prepend the IQ samples from the final sweep of the previous chunk."""
233
- return np.concatenate((carryover_iq_data, iq_data))
234
-
235
-
236
- def _get_final_sweep(previous_chunk: BaseChunk
237
- ) -> Tuple[np.ndarray, SweepMetadata]:
238
- """Get data from the final sweep of the previous chunk."""
239
- # unpack the data from the previous chunk
240
- previous_iq_data = previous_chunk.read_file("bin")
241
- _, previous_sweep_metadata = previous_chunk.read_file("hdr")
242
- # find the step index from the last sweep
243
- # [0] since the return of np.where is a 1 element Tuple,
244
- # containing a list of step indices corresponding to the smallest center frequencies
245
- # [-1] since we want the final step index, where the center frequency is minimised
246
- final_sweep_start_step_index = np.where(previous_sweep_metadata.center_frequencies == np.min(previous_sweep_metadata.center_frequencies))[0][-1]
247
- # isolate the data from the final sweep
248
- final_center_frequencies = previous_sweep_metadata.center_frequencies[final_sweep_start_step_index:]
249
- final_num_samples = previous_sweep_metadata.num_samples[final_sweep_start_step_index:]
250
- final_sweep_iq_data = previous_iq_data[-np.sum(final_num_samples):]
251
-
252
- # sanity check on the number of samples in the final sweep
253
- if len(final_sweep_iq_data) != np.sum(final_num_samples):
254
- raise ValueError((f"Unexpected error! Mismatch in sample count for the final sweep data."
255
- f"Expected {np.sum(final_num_samples)} based on sweep metadata, but found "
256
- f" {len(final_sweep_iq_data)} IQ samples in the final sweep"))
257
-
258
- return final_sweep_iq_data, SweepMetadata(final_center_frequencies, final_num_samples)
259
-
260
-
261
- def _reconstruct_initial_sweep(previous_chunk: BaseChunk,
262
- iq_data: np.ndarray,
263
- sweep_metadata: SweepMetadata) -> Tuple[np.ndarray, SweepMetadata, int]:
264
- """Reconstruct the initial sweep of the current chunk, using data from the previous chunk."""
265
-
266
- # carryover the final sweep of the previous chunk, and prepend that data to the current chunk data
267
- carryover_iq_data, carryover_sweep_metadata = _get_final_sweep(previous_chunk)
268
-
269
- # prepend the iq data that was carried over from the previous chunk
270
- iq_data = _prepend_iq_data(carryover_iq_data,
271
- iq_data)
272
-
273
- # prepend the sweep metadata from the previous chunk
274
- final_step_spans_two_chunks = carryover_sweep_metadata.center_frequencies[-1] == sweep_metadata.center_frequencies[0]
275
- center_frequencies = _prepend_center_frequencies(carryover_sweep_metadata.center_frequencies,
276
- sweep_metadata.center_frequencies,
277
- final_step_spans_two_chunks)
278
- num_samples = _prepend_num_samples(carryover_sweep_metadata.num_samples,
279
- sweep_metadata.num_samples,
280
- final_step_spans_two_chunks)
281
-
282
- # keep track of how many samples we prepended (required to adjust timing later)
283
- num_samples_prepended = np.sum(carryover_sweep_metadata.num_samples)
284
- return iq_data, SweepMetadata(center_frequencies, num_samples), num_samples_prepended
285
-
286
-
287
- def _build_spectrogram(chunk: BaseChunk,
288
- capture_config: CaptureConfig,
289
- previous_chunk: Optional[BaseChunk] = None) -> Spectrogram:
290
- """Create a spectrogram by performing a Short Time FFT on the (swept) IQ samples for this chunk."""
291
- iq_data = chunk.read_file("bin")
292
- millisecond_correction, sweep_metadata = chunk.read_file("hdr")
293
-
294
- # if a previous chunk has been specified, this indicates that the initial sweep spans
295
- # between two adjacent batched files.
296
- if previous_chunk:
297
- # If this is the case, first reconstruct the initial sweep of the current chunk
298
- # by prepending the final sweep of the previous chunk
299
- iq_data, sweep_metadata, num_samples_prepended = _reconstruct_initial_sweep(previous_chunk,
300
- iq_data,
301
- sweep_metadata)
302
- # since we have prepended extra samples, we need to correct the chunk start time
303
- # appropriately
304
- chunk_start_time, millisecond_correction = _correct_timing(chunk.chunk_start_datetime,
305
- millisecond_correction,
306
- num_samples_prepended,
307
- capture_config.get_parameter_value(PNames.SAMPLE_RATE))
308
- # otherwise, no action is required
309
- else:
310
- chunk_start_time = chunk.chunk_start_time
311
-
312
- microsecond_correction = millisecond_correction * 1e3
313
-
314
- times, frequencies, dynamic_spectra = _do_stfft(iq_data,
315
- sweep_metadata,
316
- capture_config)
317
-
318
- return Spectrogram(dynamic_spectra,
319
- times,
320
- frequencies,
321
- chunk.tag,
322
- chunk_start_time,
323
- microsecond_correction,
324
- spectrum_type = "amplitude")
325
-
326
-
327
- @register_event_handler(CaptureModes.SWEPT_CENTER_FREQUENCY)
328
- class _EventHandler(BaseEventHandler):
329
- def __init__(self, *args, **kwargs):
330
- super().__init__(*args, **kwargs)
331
-
332
- # the previous chunk is stored in order to fetch the
333
- # data from the "final sweep" which was ignored during
334
- # processing.
335
- self._previous_chunk: BaseChunk = None
336
-
337
-
338
- def process(self,
339
- absolute_file_path: str):
340
- _LOGGER.info(f"Processing: {absolute_file_path}")
341
- file_name = os.path.basename(absolute_file_path)
342
- # discard the extension
343
- base_file_name, _ = os.path.splitext(file_name)
344
- chunk_start_time, tag = base_file_name.split('_')
345
- chunk = self._Chunk(chunk_start_time, tag)
346
-
347
- # ensure that the file which has been created has the expected tag
348
- if tag != self._tag:
349
- raise RuntimeError(f"Received an unexpected tag! Expected '{self._tag}', "
350
- f"but a file has been created with tag '{tag}'")
351
-
352
- _LOGGER.info("Creating spectrogram")
353
- spectrogram = _build_spectrogram(chunk,
354
- self._capture_config,
355
- previous_chunk = self._previous_chunk)
356
-
357
- spectrogram = time_average(spectrogram,
358
- resolution = self._capture_config.get_parameter_value(PNames.TIME_RESOLUTION))
359
-
360
- spectrogram = frequency_average(spectrogram,
361
- resolution = self._capture_config.get_parameter_value(PNames.FREQUENCY_RESOLUTION))
362
-
363
- self._cache_spectrogram(spectrogram)
364
-
365
- # if the previous chunk has not yet been set, it means we are processing the first chunk
366
- # so we don't need to handle the previous chunk
367
- if self._previous_chunk is None:
368
- # instead, only set it for the next time this method is called
369
- self._previous_chunk = chunk
370
-
371
- # otherwise the previous chunk is defined (and by this point has already been processed)
372
- else:
373
- bin_chunk = self._previous_chunk.get_file('bin')
374
- _LOGGER.info(f"Deleting {bin_chunk.file_path}")
375
- bin_chunk.delete()
376
-
377
- hdr_chunk = self._previous_chunk.get_file('hdr')
378
- _LOGGER.info(f"Deleting {hdr_chunk.file_path}")
379
- hdr_chunk.delete()
380
-
381
- # and reassign the current chunk to be used as the previous chunk at the next call of this method
382
- self._previous_chunk = chunk