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.
- spectre_core/__init__.py +3 -0
- spectre_core/cfg.py +116 -0
- spectre_core/chunks/__init__.py +206 -0
- spectre_core/chunks/base.py +160 -0
- spectre_core/chunks/chunk_register.py +15 -0
- spectre_core/chunks/factory.py +26 -0
- spectre_core/chunks/library/__init__.py +8 -0
- spectre_core/chunks/library/callisto/__init__.py +0 -0
- spectre_core/chunks/library/callisto/chunk.py +101 -0
- spectre_core/chunks/library/fixed/__init__.py +0 -0
- spectre_core/chunks/library/fixed/chunk.py +185 -0
- spectre_core/chunks/library/sweep/__init__.py +0 -0
- spectre_core/chunks/library/sweep/chunk.py +400 -0
- spectre_core/dynamic_imports.py +22 -0
- spectre_core/exceptions.py +17 -0
- spectre_core/file_handlers/base.py +94 -0
- spectre_core/file_handlers/configs.py +269 -0
- spectre_core/file_handlers/json.py +36 -0
- spectre_core/file_handlers/text.py +21 -0
- spectre_core/logging.py +222 -0
- spectre_core/plotting/__init__.py +5 -0
- spectre_core/plotting/base.py +194 -0
- spectre_core/plotting/factory.py +26 -0
- spectre_core/plotting/format.py +19 -0
- spectre_core/plotting/library/__init__.py +7 -0
- spectre_core/plotting/library/frequency_cuts/panel.py +74 -0
- spectre_core/plotting/library/integral_over_frequency/panel.py +34 -0
- spectre_core/plotting/library/spectrogram/panel.py +92 -0
- spectre_core/plotting/library/time_cuts/panel.py +77 -0
- spectre_core/plotting/panel_register.py +13 -0
- spectre_core/plotting/panel_stack.py +148 -0
- spectre_core/receivers/__init__.py +6 -0
- spectre_core/receivers/base.py +415 -0
- spectre_core/receivers/factory.py +19 -0
- spectre_core/receivers/library/__init__.py +7 -0
- spectre_core/receivers/library/rsp1a/__init__.py +0 -0
- spectre_core/receivers/library/rsp1a/gr/__init__.py +0 -0
- spectre_core/receivers/library/rsp1a/gr/fixed.py +104 -0
- spectre_core/receivers/library/rsp1a/gr/sweep.py +129 -0
- spectre_core/receivers/library/rsp1a/receiver.py +68 -0
- spectre_core/receivers/library/rspduo/__init__.py +0 -0
- spectre_core/receivers/library/rspduo/gr/__init__.py +0 -0
- spectre_core/receivers/library/rspduo/gr/tuner_1_fixed.py +110 -0
- spectre_core/receivers/library/rspduo/gr/tuner_1_sweep.py +135 -0
- spectre_core/receivers/library/rspduo/receiver.py +68 -0
- spectre_core/receivers/library/test/__init__.py +0 -0
- spectre_core/receivers/library/test/gr/__init__.py +0 -0
- spectre_core/receivers/library/test/gr/cosine_signal_1.py +83 -0
- spectre_core/receivers/library/test/gr/tagged_staircase.py +93 -0
- spectre_core/receivers/library/test/receiver.py +174 -0
- spectre_core/receivers/receiver_register.py +22 -0
- spectre_core/receivers/validators.py +205 -0
- spectre_core/spectrograms/__init__.py +3 -0
- spectre_core/spectrograms/analytical.py +205 -0
- spectre_core/spectrograms/array_operations.py +77 -0
- spectre_core/spectrograms/spectrogram.py +461 -0
- spectre_core/spectrograms/transform.py +267 -0
- spectre_core/watchdog/__init__.py +6 -0
- spectre_core/watchdog/base.py +105 -0
- spectre_core/watchdog/event_handler_register.py +15 -0
- spectre_core/watchdog/factory.py +22 -0
- spectre_core/watchdog/library/__init__.py +10 -0
- spectre_core/watchdog/library/fixed/__init__.py +0 -0
- spectre_core/watchdog/library/fixed/event_handler.py +41 -0
- spectre_core/watchdog/library/sweep/event_handler.py +55 -0
- spectre_core/watchdog/watcher.py +50 -0
- spectre_core/web_fetch/callisto.py +101 -0
- spectre_core-0.0.1.dist-info/LICENSE +674 -0
- spectre_core-0.0.1.dist-info/METADATA +40 -0
- spectre_core-0.0.1.dist-info/RECORD +72 -0
- spectre_core-0.0.1.dist-info/WHEEL +5 -0
- 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): ...
|