spectre-core 0.0.1__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 (72) hide show
  1. spectre_core/__init__.py +3 -0
  2. spectre_core/cfg.py +116 -0
  3. spectre_core/chunks/__init__.py +206 -0
  4. spectre_core/chunks/base.py +160 -0
  5. spectre_core/chunks/chunk_register.py +15 -0
  6. spectre_core/chunks/factory.py +26 -0
  7. spectre_core/chunks/library/__init__.py +8 -0
  8. spectre_core/chunks/library/callisto/__init__.py +0 -0
  9. spectre_core/chunks/library/callisto/chunk.py +101 -0
  10. spectre_core/chunks/library/fixed/__init__.py +0 -0
  11. spectre_core/chunks/library/fixed/chunk.py +185 -0
  12. spectre_core/chunks/library/sweep/__init__.py +0 -0
  13. spectre_core/chunks/library/sweep/chunk.py +400 -0
  14. spectre_core/dynamic_imports.py +22 -0
  15. spectre_core/exceptions.py +17 -0
  16. spectre_core/file_handlers/base.py +94 -0
  17. spectre_core/file_handlers/configs.py +269 -0
  18. spectre_core/file_handlers/json.py +36 -0
  19. spectre_core/file_handlers/text.py +21 -0
  20. spectre_core/logging.py +222 -0
  21. spectre_core/plotting/__init__.py +5 -0
  22. spectre_core/plotting/base.py +194 -0
  23. spectre_core/plotting/factory.py +26 -0
  24. spectre_core/plotting/format.py +19 -0
  25. spectre_core/plotting/library/__init__.py +7 -0
  26. spectre_core/plotting/library/frequency_cuts/panel.py +74 -0
  27. spectre_core/plotting/library/integral_over_frequency/panel.py +34 -0
  28. spectre_core/plotting/library/spectrogram/panel.py +92 -0
  29. spectre_core/plotting/library/time_cuts/panel.py +77 -0
  30. spectre_core/plotting/panel_register.py +13 -0
  31. spectre_core/plotting/panel_stack.py +148 -0
  32. spectre_core/receivers/__init__.py +6 -0
  33. spectre_core/receivers/base.py +415 -0
  34. spectre_core/receivers/factory.py +19 -0
  35. spectre_core/receivers/library/__init__.py +7 -0
  36. spectre_core/receivers/library/rsp1a/__init__.py +0 -0
  37. spectre_core/receivers/library/rsp1a/gr/__init__.py +0 -0
  38. spectre_core/receivers/library/rsp1a/gr/fixed.py +104 -0
  39. spectre_core/receivers/library/rsp1a/gr/sweep.py +129 -0
  40. spectre_core/receivers/library/rsp1a/receiver.py +68 -0
  41. spectre_core/receivers/library/rspduo/__init__.py +0 -0
  42. spectre_core/receivers/library/rspduo/gr/__init__.py +0 -0
  43. spectre_core/receivers/library/rspduo/gr/tuner_1_fixed.py +110 -0
  44. spectre_core/receivers/library/rspduo/gr/tuner_1_sweep.py +135 -0
  45. spectre_core/receivers/library/rspduo/receiver.py +68 -0
  46. spectre_core/receivers/library/test/__init__.py +0 -0
  47. spectre_core/receivers/library/test/gr/__init__.py +0 -0
  48. spectre_core/receivers/library/test/gr/cosine_signal_1.py +83 -0
  49. spectre_core/receivers/library/test/gr/tagged_staircase.py +93 -0
  50. spectre_core/receivers/library/test/receiver.py +174 -0
  51. spectre_core/receivers/receiver_register.py +22 -0
  52. spectre_core/receivers/validators.py +205 -0
  53. spectre_core/spectrograms/__init__.py +3 -0
  54. spectre_core/spectrograms/analytical.py +205 -0
  55. spectre_core/spectrograms/array_operations.py +77 -0
  56. spectre_core/spectrograms/spectrogram.py +461 -0
  57. spectre_core/spectrograms/transform.py +267 -0
  58. spectre_core/watchdog/__init__.py +6 -0
  59. spectre_core/watchdog/base.py +105 -0
  60. spectre_core/watchdog/event_handler_register.py +15 -0
  61. spectre_core/watchdog/factory.py +22 -0
  62. spectre_core/watchdog/library/__init__.py +10 -0
  63. spectre_core/watchdog/library/fixed/__init__.py +0 -0
  64. spectre_core/watchdog/library/fixed/event_handler.py +41 -0
  65. spectre_core/watchdog/library/sweep/event_handler.py +55 -0
  66. spectre_core/watchdog/watcher.py +50 -0
  67. spectre_core/web_fetch/callisto.py +101 -0
  68. spectre_core-0.0.1.dist-info/LICENSE +674 -0
  69. spectre_core-0.0.1.dist-info/METADATA +40 -0
  70. spectre_core-0.0.1.dist-info/RECORD +72 -0
  71. spectre_core-0.0.1.dist-info/WHEEL +5 -0
  72. spectre_core-0.0.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,185 @@
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 datetime import datetime, timedelta
6
+ from typing import Tuple
7
+ import numpy as np
8
+
9
+ from astropy.io import fits
10
+ from astropy.io.fits.hdu.image import PrimaryHDU
11
+ from astropy.io.fits.hdu.table import BinTableHDU
12
+ from astropy.io.fits.hdu.hdulist import HDUList
13
+
14
+ from spectre_core.chunks.chunk_register import register_chunk
15
+ from spectre_core.spectrograms.spectrogram import Spectrogram
16
+ from spectre_core.chunks.base import (
17
+ SPECTREChunk,
18
+ ChunkFile
19
+ )
20
+
21
+ @register_chunk("fixed")
22
+ class Chunk(SPECTREChunk):
23
+ def __init__(self, *args, **kwargs):
24
+ super().__init__(*args, **kwargs)
25
+
26
+ self.add_file(BinChunk(self.chunk_parent_path, self.chunk_name))
27
+ self.add_file(FitsChunk(self.chunk_parent_path, self.chunk_name))
28
+ self.add_file(HdrChunk(self.chunk_parent_path, self.chunk_name))
29
+
30
+
31
+ def build_spectrogram(self) -> Spectrogram:
32
+ """Create a spectrogram by performing a Short Time FFT on the IQ samples for this chunk."""
33
+ iq_data = self.read_file("bin")
34
+ millisecond_correction = self.read_file("hdr")
35
+
36
+ # units conversion
37
+ microsecond_correction = millisecond_correction * 1e3
38
+
39
+ times, frequencies, dynamic_spectra = self.__do_STFFT(iq_data)
40
+
41
+ # explicitly type cast data arrays to 32-bit floats
42
+ times = np.array(times, dtype = 'float32')
43
+ frequencies = np.array(frequencies, dtype = 'float32')
44
+ dynamic_spectra = np.array(dynamic_spectra, dtype = 'float32')
45
+
46
+ return Spectrogram(dynamic_spectra,
47
+ times,
48
+ frequencies,
49
+ self.tag,
50
+ chunk_start_time = self.chunk_start_time,
51
+ microsecond_correction = microsecond_correction,
52
+ spectrum_type = "amplitude")
53
+
54
+
55
+ def __do_STFFT(self,
56
+ iq_data: np.array) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
57
+ """For reference: https://docs.scipy.org/doc/scipy/reference/generated/scipy.signal.ShortTimeFFT.html"""
58
+
59
+ # set p0=0, since by convention in the STFFT docs, p=0 corresponds to the slice centred at t=0
60
+ p0=0
61
+
62
+ # set p1 to the index of the first slice where the "midpoint" of the window is still inside the signal
63
+ num_samples = len(iq_data)
64
+ p1 = self.SFT.upper_border_begin(num_samples)[1]
65
+
66
+ # compute a ShortTimeFFT on the IQ samples
67
+ complex_spectra = self.SFT.stft(iq_data,
68
+ p0 = p0,
69
+ p1 = p1)
70
+
71
+ # compute the magnitude of each spectral component
72
+ dynamic_spectra = np.abs(complex_spectra)
73
+
74
+
75
+ # assign a physical time to each spectrum
76
+ times = self.SFT.t(num_samples,
77
+ p0 = 0,
78
+ p1 = p1)
79
+
80
+ # assign physical frequencies to each spectral component
81
+ frequencies = self.SFT.f + self.capture_config.get('center_freq', 0.0)
82
+
83
+ return times, frequencies, dynamic_spectra
84
+
85
+
86
+ class BinChunk(ChunkFile):
87
+ def __init__(self, chunk_parent_path: str, chunk_name: str):
88
+ super().__init__(chunk_parent_path, chunk_name, "bin")
89
+
90
+ def read(self) -> np.ndarray:
91
+ with open(self.file_path, "rb") as fh:
92
+ return np.fromfile(fh, dtype=np.complex64)
93
+
94
+
95
+
96
+ class HdrChunk(ChunkFile):
97
+ def __init__(self, chunk_parent_path: str, chunk_name: str):
98
+ super().__init__(chunk_parent_path, chunk_name, "hdr")
99
+
100
+
101
+ def read(self) -> int:
102
+ hdr_contents = self._extract_contents()
103
+ return self._get_millisecond_correction(hdr_contents)
104
+
105
+
106
+ def _extract_contents(self) -> np.ndarray:
107
+ with open(self.file_path, "rb") as fh:
108
+ return np.fromfile(fh, dtype=np.float32)
109
+
110
+
111
+ def _get_millisecond_correction(self, hdr_contents: np.ndarray) -> int:
112
+ if len(hdr_contents) != 1:
113
+ raise ValueError(f"Expected exactly one integer in the header, but received header contents: {hdr_contents}")
114
+
115
+ millisecond_correction_as_float = float(hdr_contents[0])
116
+
117
+ if not millisecond_correction_as_float.is_integer():
118
+ raise TypeError(f"Expected integer value for millisecond correction, but got {millisecond_correction_as_float}")
119
+
120
+ return int(millisecond_correction_as_float)
121
+
122
+
123
+
124
+
125
+ class FitsChunk(ChunkFile):
126
+ def __init__(self, chunk_parent_path: str, chunk_name: str):
127
+ super().__init__(chunk_parent_path, chunk_name, "fits")
128
+
129
+
130
+ @property
131
+ def datetimes(self) -> np.ndarray:
132
+ with fits.open(self.file_path, mode='readonly') as hdulist:
133
+ bintable_data = hdulist[1].data
134
+ times = bintable_data['TIME'][0]
135
+ return [self.chunk_start_datetime + timedelta(seconds=t) for t in times]
136
+
137
+
138
+ def read(self) -> Spectrogram:
139
+ with fits.open(self.file_path, mode='readonly') as hdulist:
140
+ primary_hdu = self._get_primary_hdu(hdulist)
141
+ dynamic_spectra = self._get_dynamic_spectra(primary_hdu)
142
+ spectrum_type = self._get_spectrum_type(primary_hdu)
143
+ microsecond_correction = self._get_microsecond_correction(primary_hdu)
144
+ bintable_hdu = self._get_bintable_hdu(hdulist)
145
+ times, frequencies = self._get_time_and_frequency(bintable_hdu)
146
+
147
+ return Spectrogram(dynamic_spectra,
148
+ times,
149
+ frequencies,
150
+ self.tag,
151
+ chunk_start_time=self.chunk_start_time,
152
+ microsecond_correction=microsecond_correction,
153
+ spectrum_type = spectrum_type)
154
+
155
+
156
+ def _get_primary_hdu(self, hdulist: HDUList) -> PrimaryHDU:
157
+ return hdulist['PRIMARY']
158
+
159
+
160
+ def _get_dynamic_spectra(self, primary_hdu: PrimaryHDU) -> np.ndarray:
161
+ return primary_hdu.data
162
+
163
+
164
+ def _get_spectrum_type(self, primary_hdu: PrimaryHDU) -> str:
165
+ return primary_hdu.header.get('BUNIT', None)
166
+
167
+
168
+ def _get_microsecond_correction(self, primary_hdu: PrimaryHDU) -> int:
169
+ date_obs = primary_hdu.header.get('DATE-OBS', None)
170
+ time_obs = primary_hdu.header.get('TIME-OBS', None)
171
+ datetime_obs = datetime.strptime(f"{date_obs}T{time_obs}", "%Y-%m-%dT%H:%M:%S.%f")
172
+ return datetime_obs.microsecond
173
+
174
+
175
+ def _get_bintable_hdu(self, hdulist: HDUList) -> BinTableHDU:
176
+ return hdulist[1]
177
+
178
+
179
+ def _get_time_and_frequency(self, bintable_hdu: BinTableHDU) -> Tuple[np.ndarray, np.ndarray]:
180
+ data = bintable_hdu.data
181
+ times = data['TIME'][0]
182
+ frequencies_MHz = data['FREQUENCY'][0]
183
+ frequencies = frequencies_MHz * 1e6 # convert to Hz
184
+ return times, frequencies
185
+
File without changes
@@ -0,0 +1,400 @@
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 typing import Tuple
6
+ from typing import Optional
7
+ from datetime import timedelta
8
+ from dataclasses import dataclass
9
+
10
+ import numpy as np
11
+
12
+ from spectre_core.chunks.chunk_register import register_chunk
13
+ from spectre_core.spectrograms.spectrogram import Spectrogram
14
+ from spectre_core.chunks.library.fixed.chunk import BinChunk, FitsChunk
15
+ from spectre_core.cfg import DEFAULT_DATETIME_FORMAT
16
+ from spectre_core.chunks.base import SPECTREChunk, ChunkFile
17
+ from spectre_core.exceptions import InvalidSweepMetadataError
18
+
19
+
20
+ @dataclass
21
+ class SweepMetadata:
22
+ """Wrapper for metadata required to assign center frequencies to each IQ sample in the chunk.
23
+
24
+ center_frequencies is an ordered list containing all the center frequencies that the IQ samples
25
+ were collected at. Typically, these will be ordered in "steps", where each step corresponds to
26
+ IQ samples collected at a constant center frequency:
27
+
28
+ (freq_0, freq_1, ..., freq_M, freq_0, freq_1, ..., freq_M, ...), freq_0 < freq_1 < ... < freq_M
29
+
30
+ The n'th element of the num_samples list, tells us how many samples were collected at the n'th
31
+ element of center_frequencies:
32
+
33
+ Number of samples: (num_samples_at_freq_0, num_samples_at_freq_1, ...)
34
+
35
+ Both these lists together allow us to map for each IQ sample, the center frequency it was collected at.
36
+ """
37
+ center_frequencies: np.ndarray
38
+ num_samples: np.ndarray
39
+
40
+
41
+ @register_chunk('sweep')
42
+ class Chunk(SPECTREChunk):
43
+ def __init__(self, chunk_start_time, tag):
44
+ super().__init__(chunk_start_time, tag)
45
+
46
+ self.add_file(BinChunk(self.chunk_parent_path, self.chunk_name))
47
+ self.add_file(FitsChunk(self.chunk_parent_path, self.chunk_name))
48
+ self.add_file(HdrChunk(self.chunk_parent_path, self.chunk_name))
49
+
50
+
51
+ def build_spectrogram(self,
52
+ previous_chunk: Optional[SPECTREChunk] = None) -> Spectrogram:
53
+ """Create a spectrogram by performing a Short Time FFT on the (swept) IQ samples for this chunk."""
54
+ iq_data = self.read_file("bin")
55
+ millisecond_correction, sweep_metadata = self.read_file("hdr")
56
+
57
+ if previous_chunk:
58
+ iq_data, sweep_metadata, num_samples_prepended = self.__reconstruct_initial_sweep(previous_chunk,
59
+ iq_data,
60
+ sweep_metadata)
61
+
62
+ chunk_start_time, millisecond_correction = self.__correct_timing(millisecond_correction,
63
+ num_samples_prepended)
64
+ else:
65
+ chunk_start_time = self.chunk_start_time
66
+
67
+ microsecond_correction = millisecond_correction * 1e3
68
+
69
+ times, frequencies, dynamic_spectra = self.__do_STFFT(iq_data,
70
+ sweep_metadata)
71
+
72
+ return Spectrogram(dynamic_spectra,
73
+ times,
74
+ frequencies,
75
+ self.tag,
76
+ chunk_start_time,
77
+ microsecond_correction,
78
+ spectrum_type = "amplitude")
79
+
80
+ def __get_final_sweep(self,
81
+ previous_chunk: SPECTREChunk) -> Tuple[np.ndarray, SweepMetadata]:
82
+ """Get data from the final sweep of the previous chunk."""
83
+ # unpack the data from the previous chunk
84
+ previous_iq_data = previous_chunk.read_file("bin")
85
+ _, previous_sweep_metadata = previous_chunk.read_file("hdr")
86
+ # find the step index from the last sweep
87
+ # [0] since the return of np.where is a 1 element Tuple,
88
+ # containing a list of step indices corresponding to the smallest center frequencies
89
+ # [-1] since we want the final step index, where the center frequency is minimised
90
+ final_sweep_start_step_index = np.where(previous_sweep_metadata.center_frequencies == np.min(previous_sweep_metadata.center_frequencies))[0][-1]
91
+ # isolate the data from the final sweep
92
+ final_center_frequencies = previous_sweep_metadata.center_frequencies[final_sweep_start_step_index:]
93
+ final_num_samples = previous_sweep_metadata.num_samples[final_sweep_start_step_index:]
94
+ final_sweep_iq_data = previous_iq_data[-np.sum(final_num_samples):]
95
+
96
+ # sanity check on the number of samples in the final sweep
97
+ if len(final_sweep_iq_data) != np.sum(final_num_samples):
98
+ raise ValueError((f"Unexpected error! Mismatch in sample count for the final sweep data."
99
+ f"Expected {np.sum(final_num_samples)} based on sweep metadata, but found "
100
+ f" {len(final_sweep_iq_data)} IQ samples in the final sweep"))
101
+
102
+ return final_sweep_iq_data, SweepMetadata(final_center_frequencies, final_num_samples)
103
+
104
+
105
+ def __prepend_iq_data(self,
106
+ carryover_iq_data: np.ndarray,
107
+ iq_data: np.ndarray) -> np.ndarray:
108
+ """Prepend the IQ samples from the final sweep of the previous chunk."""
109
+ return np.concatenate((carryover_iq_data, iq_data))
110
+
111
+
112
+ def __prepend_center_frequencies(self,
113
+ carryover_center_frequencies: np.ndarray,
114
+ center_frequencies: np.ndarray,
115
+ final_sweep_spans_two_chunks: bool)-> np.ndarray:
116
+ """Prepend the center frequencies from the final sweep of the previous chunk."""
117
+ # in the case that the sweep has bled across chunks,
118
+ # do not permit identical neighbours in the center frequency array
119
+ if final_sweep_spans_two_chunks:
120
+ # truncate the final frequency to prepend (as it already exists in the array we are appending to in this case)
121
+ carryover_center_frequencies = carryover_center_frequencies[:-1]
122
+ return np.concatenate((carryover_center_frequencies, center_frequencies))
123
+
124
+
125
+ def __prepend_num_samples(self,
126
+ carryover_num_samples: np.ndarray,
127
+ num_samples: np.ndarray,
128
+ final_sweep_spans_two_chunks: bool) -> np.ndarray:
129
+ """Prepend the number of samples from the final sweep of the previous chunk."""
130
+ if final_sweep_spans_two_chunks:
131
+ # ensure the number of samples from the final step in the previous chunk are accounted for
132
+ num_samples[0] += carryover_num_samples[-1]
133
+ # and truncate as required
134
+ carryover_num_samples = carryover_num_samples[:-1]
135
+ return np.concatenate((carryover_num_samples, num_samples))
136
+
137
+
138
+ def __reconstruct_initial_sweep(self,
139
+ previous_chunk: SPECTREChunk,
140
+ iq_data: np.ndarray,
141
+ sweep_metadata: SweepMetadata) -> Tuple[np.ndarray, SweepMetadata, int]:
142
+ """Reconstruct the initial sweep of the current chunk, using data from the previous chunk."""
143
+
144
+ # carryover the final sweep of the previous chunk, and prepend that data to the current chunk data
145
+ carryover_iq_data, carryover_sweep_metadata = self.__get_final_sweep(previous_chunk)
146
+ iq_data = self.__prepend_iq_data(carryover_iq_data,
147
+ iq_data)
148
+
149
+ # if the final sweep of the previous sweep has bled through to the current chunk
150
+ final_sweep_spans_two_chunks = carryover_sweep_metadata.center_frequencies[-1] == sweep_metadata.center_frequencies[0]
151
+
152
+ center_frequencies = self.__prepend_center_frequencies(carryover_sweep_metadata.center_frequencies,
153
+ sweep_metadata.center_frequencies,
154
+ final_sweep_spans_two_chunks)
155
+ num_samples = self.__prepend_num_samples(carryover_sweep_metadata.num_samples,
156
+ sweep_metadata.num_samples,
157
+ final_sweep_spans_two_chunks)
158
+
159
+ num_samples_prepended = np.sum(carryover_sweep_metadata.num_samples)
160
+
161
+ return iq_data, SweepMetadata(center_frequencies, num_samples), num_samples_prepended
162
+
163
+
164
+ def __correct_timing(self,
165
+ millisecond_correction: int,
166
+ num_samples_prepended: int):
167
+ """Correct the start time for this chunk based on the number of samples we prepended reconstructing the initial sweep."""
168
+ elapsed_time = num_samples_prepended * (1 / self.capture_config.get("samp_rate"))
169
+
170
+ corrected_datetime = self.chunk_start_datetime + timedelta(milliseconds = millisecond_correction) - timedelta(seconds = float(elapsed_time))
171
+ return corrected_datetime.strftime(DEFAULT_DATETIME_FORMAT), corrected_datetime.microsecond * 1e-3
172
+
173
+
174
+ def __validate_center_frequencies_ordering(self,
175
+ center_frequencies) -> None:
176
+ """Check that the center frequencies are well-ordered in the detached header."""
177
+ min_frequency = np.min(center_frequencies)
178
+ diffs = np.diff(center_frequencies)
179
+ # Extract the expected difference between each step within a sweep.
180
+ freq_step = self.capture_config.get("freq_step")
181
+ # Validate frequency steps
182
+ for i, diff in enumerate(diffs):
183
+ # steps should either increase by freq_step or drop to the minimum
184
+ if (diff != freq_step) and (center_frequencies[i + 1] != min_frequency):
185
+ raise InvalidSweepMetadataError(f"Unordered center frequencies detected")
186
+
187
+
188
+ def __compute_num_steps_per_sweep(self,
189
+ center_frequencies: np.ndarray) -> int:
190
+ """Compute the (ensured constant) number of steps in each sweep."""
191
+ # find the (step) indices corresponding to the minimum frequencies
192
+ min_freq_indices = np.where(center_frequencies == np.min(center_frequencies))[0]
193
+ # then, we evaluate the number of steps that has occured between them via np.diff over the indices
194
+ unique_num_steps_per_sweep = np.unique(np.diff(min_freq_indices))
195
+ # we expect that the difference is always the same, so that the result of np.unique has a single element
196
+ if len(unique_num_steps_per_sweep) != 1:
197
+ raise InvalidSweepMetadataError(("Irregular step count per sweep, "
198
+ "expected a consistent number of steps per sweep"))
199
+ return int(unique_num_steps_per_sweep[0])
200
+
201
+
202
+ def __compute_num_full_sweeps(self,
203
+ center_frequencies: np.ndarray) -> int:
204
+ """Compute the total number of full sweeps over the chunk.
205
+
206
+ Since the number of each samples in each step is variable, we only know a sweep is complete
207
+ when there is a sweep after it. So we can define the total number of *full* sweeps as the number of
208
+ (freq_max, freq_min) pairs in center_frequencies. It is only at an instance of (freq_max, freq_min) pair
209
+ in center frequencies that the frequency decreases, so, we can compute the number of full sweeps by
210
+ counting the numbers of negative values in np.diff(center_frequencies)
211
+ """
212
+ return len(np.where(np.diff(center_frequencies) < 0)[0])
213
+
214
+
215
+ def __compute_num_max_slices_in_step(self, num_samples: np.ndarray) -> int:
216
+ """Compute the maximum number of slices over all steps, in all sweeps over the chunk."""
217
+ return self.SFT.upper_border_begin(np.max(num_samples))[1]
218
+
219
+
220
+ def __fill_stepped_dynamic_spectra(self,
221
+ stepped_dynamic_spectra: np.ndarray,
222
+ iq_data: np.ndarray,
223
+ num_samples: np.ndarray,
224
+ num_full_sweeps: int,
225
+ num_steps_per_sweep: int) -> None:
226
+ """Compute the dynamic spectra for the input IQ samples for each step.
227
+
228
+ All IQ samples per step were collected at the same center frequency.
229
+ """
230
+ # global_step_index will hold the step index over all sweeps (doesn't reset each sweep)
231
+ # start_sample_index will hold the index of the first sample in the step
232
+ global_step_index, start_sample_index = 0, 0
233
+ for sweep_index in range(num_full_sweeps):
234
+ for step_index in range(num_steps_per_sweep):
235
+ # extract how many samples are in the current step from the metadata
236
+ end_sample_index = start_sample_index + num_samples[global_step_index]
237
+ # compute the number of slices in the current step based on the window we defined on the capture config
238
+ num_slices = self.SFT.upper_border_begin(num_samples[global_step_index])[1]
239
+ # perform a short time fast fourier transform on the step
240
+ complex_spectra = self.SFT.stft(iq_data[start_sample_index:end_sample_index],
241
+ p0=0,
242
+ p1=num_slices)
243
+ # and pack the absolute values into the stepped spectrogram where the step slot is padded to the maximum size for ease of processing later)
244
+ stepped_dynamic_spectra[sweep_index, step_index, :, :num_slices] = np.abs(complex_spectra)
245
+ # reassign the start_sample_index for the next step
246
+ start_sample_index = end_sample_index
247
+ # and increment the global step index
248
+ global_step_index += 1
249
+
250
+
251
+ def __fill_frequencies(self,
252
+ frequencies: np.ndarray,
253
+ center_frequencies: np.ndarray,
254
+ baseband_frequencies: np.ndarray,
255
+ window_size: int) -> None:
256
+ """Assign physical frequencies to each of the swept spectral components."""
257
+ for i, center_frequency in enumerate(np.unique(center_frequencies)):
258
+ lower_bound = i * window_size
259
+ upper_bound = (i + 1) * window_size
260
+ frequencies[lower_bound:upper_bound] = (baseband_frequencies + center_frequency)
261
+
262
+
263
+ def __fill_times(self,
264
+ times: np.ndarray,
265
+ num_samples: np.ndarray,
266
+ num_full_sweeps: int,
267
+ num_steps_per_sweep: int) -> None:
268
+ """Assign physical times to each swept spectrum. We use (by convention) the time of the midpoint sample in each sweep."""
269
+
270
+ sampling_interval = 1 / self.capture_config.get("samp_rate")
271
+ cumulative_samples = 0
272
+ for sweep_index in range(num_full_sweeps):
273
+ # find the total number of samples across the sweep
274
+ start_step = sweep_index * num_steps_per_sweep
275
+ end_step = (sweep_index + 1) * num_steps_per_sweep
276
+ num_samples_in_sweep = np.sum(num_samples[start_step:end_step])
277
+
278
+ # compute the midpoint sample in the sweep
279
+ midpoint_sample = cumulative_samples + num_samples_in_sweep // 2
280
+
281
+ # update cumulative samples
282
+ cumulative_samples += num_samples_in_sweep
283
+
284
+ # assign a physical time to the spectrum for this sweep
285
+ times[sweep_index] = midpoint_sample * sampling_interval
286
+
287
+
288
+ def __average_over_steps(self,
289
+ stepped_dynamic_spectra: np.ndarray) -> None:
290
+ """Average the spectrums in each step totally in time."""
291
+ return np.nanmean(stepped_dynamic_spectra[..., 1:], axis=-1)
292
+
293
+
294
+ def __stitch_steps(self,
295
+ stepped_dynamic_spectra: np.ndarray,
296
+ num_full_sweeps: int) -> np.ndarray:
297
+ """For each full sweep, create a swept spectrum by stitching together the spectrum at each of the steps."""
298
+ return stepped_dynamic_spectra.reshape((num_full_sweeps, -1)).T
299
+
300
+
301
+ def __do_STFFT(self,
302
+ iq_data: np.ndarray,
303
+ sweep_metadata: SweepMetadata):
304
+ """Perform a Short Time FFT on the input swept IQ samples."""
305
+ self.__validate_center_frequencies_ordering(sweep_metadata.center_frequencies)
306
+
307
+ window_size = len(self.SFT.win)
308
+
309
+ num_steps_per_sweep = self.__compute_num_steps_per_sweep(sweep_metadata.center_frequencies)
310
+ num_full_sweeps = self.__compute_num_full_sweeps(sweep_metadata.center_frequencies)
311
+ num_max_slices_in_step = self.__compute_num_max_slices_in_step(sweep_metadata.num_samples)
312
+
313
+ stepped_dynamic_spectra_shape = (num_full_sweeps,
314
+ num_steps_per_sweep,
315
+ window_size,
316
+ num_max_slices_in_step)
317
+ stepped_dynamic_spectra = np.full(stepped_dynamic_spectra_shape, np.nan)
318
+
319
+
320
+ frequencies = np.empty(num_steps_per_sweep * window_size)
321
+ times = np.empty(num_full_sweeps)
322
+
323
+ self.__fill_stepped_dynamic_spectra(stepped_dynamic_spectra,
324
+ iq_data,
325
+ sweep_metadata.num_samples,
326
+ num_full_sweeps,
327
+ num_steps_per_sweep)
328
+
329
+ self.__fill_frequencies(frequencies,
330
+ sweep_metadata.center_frequencies,
331
+ self.SFT.f,
332
+ window_size)
333
+
334
+ self.__fill_times(times,
335
+ sweep_metadata.num_samples,
336
+ num_full_sweeps,
337
+ num_steps_per_sweep)
338
+
339
+ averaged_spectra = self.__average_over_steps(stepped_dynamic_spectra)
340
+ dynamic_spectra = self.__stitch_steps(averaged_spectra,
341
+ num_full_sweeps)
342
+
343
+ return times, frequencies, dynamic_spectra
344
+
345
+
346
+ class HdrChunk(ChunkFile):
347
+ def __init__(self, chunk_parent_path: str, chunk_name: str):
348
+ super().__init__(chunk_parent_path, chunk_name, "hdr")
349
+
350
+ def read(self) -> Tuple[int, SweepMetadata]:
351
+ hdr_contents = self._read_file_contents()
352
+ millisecond_correction = self._get_millisecond_correction(hdr_contents)
353
+ center_frequencies = self._get_center_frequencies(hdr_contents)
354
+ num_samples = self._get_num_samples(hdr_contents)
355
+ self._validate_frequencies_and_samples(center_frequencies,
356
+ num_samples)
357
+
358
+ return millisecond_correction, SweepMetadata(center_frequencies, num_samples)
359
+
360
+
361
+ def _read_file_contents(self) -> np.ndarray:
362
+ with open(self.file_path, "rb") as fh:
363
+ return np.fromfile(fh, dtype=np.float32)
364
+
365
+
366
+ def _get_millisecond_correction(self, hdr_contents: np.ndarray) -> int:
367
+ ''' Millisecond correction is an integral quantity, but stored in the detached header as a 32-bit float.'''
368
+ millisecond_correction_as_float = float(hdr_contents[0])
369
+
370
+ if not millisecond_correction_as_float.is_integer():
371
+ raise TypeError(f"Expected integer value for millisecond correction, but got {millisecond_correction_as_float}")
372
+
373
+ return int(millisecond_correction_as_float)
374
+
375
+
376
+ def _get_center_frequencies(self, hdr_contents: np.ndarray) -> np.ndarray:
377
+ '''
378
+ Detached header contents are stored in (center_freq_i, num_samples_at_center_freq_i) pairs
379
+ Return only a list of center frequencies, by skipping over file contents in twos.
380
+ '''
381
+ return hdr_contents[1::2]
382
+
383
+
384
+ def _get_num_samples(self, hdr_contents: np.ndarray) -> np.ndarray:
385
+ '''
386
+ Detached header contents are stored in (center_freq_i, num_samples_at_center_freq_i) pairs
387
+ Return only the number of samples at each center frequency, by skipping over file contents in twos.
388
+ Number of samples is an integral quantity, but stored in the detached header as a 32-bit float.
389
+ Types are checked before return.
390
+ '''
391
+ num_samples_as_float = hdr_contents[2::2]
392
+ if not all(num_samples_as_float == num_samples_as_float.astype(int)):
393
+ raise InvalidSweepMetadataError("Number of samples per frequency is expected to describe an integer")
394
+ return num_samples_as_float.astype(int)
395
+
396
+
397
+ def _validate_frequencies_and_samples(self, center_frequencies: np.ndarray, num_samples: np.ndarray) -> None:
398
+ """Validates that the center frequencies and the number of samples arrays have the same length."""
399
+ if len(center_frequencies) != len(num_samples):
400
+ raise InvalidSweepMetadataError("Center frequencies and number of samples arrays are not the same length")
@@ -0,0 +1,22 @@
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
+ import os
6
+ from importlib import import_module
7
+
8
+ def import_target_modules(caller_file: str, # __file__ in the calling context for the library
9
+ caller_name: str, # __name__ in the calling context for the library
10
+ target_module: str # the module we are looking to dynamically import
11
+ ) -> None:
12
+ # fetch the directory path for the __init__.py in the library directory
13
+ library_dir_path = os.path.dirname(caller_file)
14
+ # list all subdirectories in the library directory
15
+ subdirs = [x.name for x in os.scandir(library_dir_path) if x.is_dir() and (x.name != "__pycache__")]
16
+ # for each subdirectory, try and import the target module
17
+ for subdir in subdirs:
18
+ full_module_name = f"{caller_name}.{subdir}.{target_module}"
19
+ import_module(full_module_name)
20
+
21
+
22
+
@@ -0,0 +1,17 @@
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
+ class ChunkNotFoundError(FileNotFoundError): ...
6
+ class ChunkFileNotFoundError(FileNotFoundError): ...
7
+ class SpectrogramNotFoundError(FileNotFoundError): ...
8
+
9
+ class ModeNotFoundError(KeyError): ...
10
+ class EventHandlerNotFoundError(KeyError): ...
11
+ class ReceiverNotFoundError(KeyError): ...
12
+ class TemplateNotFoundError(KeyError): ...
13
+ class SpecificationNotFoundError(KeyError): ...
14
+ class PanelNotFoundError(KeyError): ...
15
+
16
+ class InvalidTagError(ValueError): ...
17
+ class InvalidSweepMetadataError(ValueError): ...