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,174 @@
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 spectre_core.receivers import validators
6
+ from spectre_core.receivers.base import SPECTREReceiver
7
+ from spectre_core.receivers.receiver_register import register_receiver
8
+ from spectre_core.receivers.library.test.gr import cosine_signal_1
9
+ from spectre_core.receivers.library.test.gr import tagged_staircase
10
+ from spectre_core.file_handlers.configs import CaptureConfig
11
+
12
+
13
+ @register_receiver("test")
14
+ class Receiver(SPECTREReceiver):
15
+ def __init__(self, *args, **kwargs):
16
+ super().__init__(*args, **kwargs)
17
+
18
+
19
+ def _set_capture_methods(self) -> None:
20
+ self._capture_methods = {
21
+ "cosine-signal-1": self.__cosine_signal_1,
22
+ "tagged-staircase": self.__tagged_staircase
23
+ }
24
+
25
+
26
+ def _set_validators(self) -> None:
27
+ self._validators = {
28
+ "cosine-signal-1": self.__cosine_signal_1_validator,
29
+ "tagged-staircase": self.__tagged_staircase_validator
30
+ }
31
+
32
+
33
+ def _set_type_templates(self) -> None:
34
+ self._type_templates = {
35
+ "cosine-signal-1": {
36
+ 'samp_rate': int, # [Hz]
37
+ 'frequency': float, # [Hz]
38
+ 'amplitude': float, # unitless
39
+ 'chunk_size': int, # [s]
40
+ 'joining_time': int, # [s]
41
+ 'time_resolution': float, # [s]
42
+ 'frequency_resolution': float, # [Hz]
43
+ 'window_type': str, # the window type for the STFFT
44
+ 'window_kwargs': dict, # keyword arguments for scipy get window function. Must be in order as in scipy documentation.
45
+ 'window_size': int, # number of samples for the window
46
+ 'STFFT_kwargs': dict, # keyword arguments for scipy STFFT class
47
+ 'chunk_key': str, # tag will map to the chunk with this key
48
+ 'event_handler_key': str # tag will map to event handler with this key during post processing
49
+ },
50
+ "tagged-staircase": {
51
+ 'samp_rate': int, # [Hz]
52
+ 'min_samples_per_step': int, # [samples]
53
+ 'max_samples_per_step': int, # [samples]
54
+ 'freq_step': float, # [Hz]
55
+ 'step_increment': int, # [samples]
56
+ 'chunk_size': int, # [s]
57
+ 'joining_time': int, # [s]
58
+ 'time_resolution': float, # [s]
59
+ 'frequency_resolution': float, # [Hz]
60
+ 'window_type': str, # the window type for the STFFT
61
+ 'window_kwargs': dict, # keyword arguments for scipy get window function. Must be in order as in scipy documentation.
62
+ 'window_size': int, # number of samples for the window
63
+ 'STFFT_kwargs': dict, # keyword arguments for scipy STFFT class
64
+ 'chunk_key': str, # tag will map to the chunk with this key
65
+ 'event_handler_key': str, # tag will map to event handler with this key during post processing
66
+ }
67
+ }
68
+
69
+ def _set_specifications(self) -> None:
70
+ self._specifications = {
71
+ }
72
+
73
+
74
+ def __cosine_signal_1(self, capture_config: CaptureConfig) -> None:
75
+ cosine_signal_1.main(capture_config)
76
+
77
+
78
+ def __tagged_staircase(self, capture_config: CaptureConfig) -> None:
79
+ tagged_staircase.main(capture_config)
80
+
81
+
82
+ def __cosine_signal_1_validator(self, capture_config: CaptureConfig) -> None:
83
+ # unpack the capture config
84
+ samp_rate = capture_config["samp_rate"]
85
+ frequency = capture_config["frequency"]
86
+ amplitude = capture_config["amplitude"]
87
+ chunk_size = capture_config["chunk_size"]
88
+ window_type = capture_config["window_type"]
89
+ window_size = capture_config["window_size"]
90
+ STFFT_kwargs = capture_config["STFFT_kwargs"]
91
+ chunk_key = capture_config["chunk_key"]
92
+ event_handler_key = capture_config["event_handler_key"]
93
+ time_resolution = capture_config["time_resolution"]
94
+ frequency_resolution = capture_config["frequency_resolution"]
95
+
96
+ validators.samp_rate_strictly_positive(samp_rate)
97
+ validators.chunk_size_strictly_positive(chunk_size)
98
+ validators.time_resolution(time_resolution, chunk_size)
99
+ validators.window(window_type,
100
+ {},
101
+ window_size,
102
+ chunk_size,
103
+ samp_rate)
104
+ validators.STFFT_kwargs(STFFT_kwargs)
105
+ validators.chunk_key(chunk_key, "fixed")
106
+ validators.event_handler_key(event_handler_key, "fixed")
107
+
108
+ if time_resolution != 0:
109
+ raise ValueError(f"Time resolution must be zero. Received: {time_resolution}")
110
+
111
+ if frequency_resolution != 0:
112
+ raise ValueError(f"Frequency resolution must be zero. Received {frequency_resolution}")
113
+
114
+ # check that the sample rate is an integer multiple of the underlying signal frequency
115
+ if samp_rate % frequency != 0:
116
+ raise ValueError("The sampling rate must be some integer multiple of frequency")
117
+
118
+ a = samp_rate/frequency
119
+ if a < 2:
120
+ raise ValueError(f"The ratio of sampling rate over frequency must be a natural number greater than two. Received: {a}")
121
+
122
+ # ensuring the window type is rectangular
123
+ if window_type != "boxcar":
124
+ raise ValueError(f"The window type must be \"boxcar\". Received: {window_type}")
125
+
126
+ # analytical requirement
127
+ # if p is the number of sampled cycles, we can find that p = window_size / a
128
+ # the number of sampled cycles must be a positive natural number.
129
+ p = window_size / a
130
+ if window_size % a != 0:
131
+ raise ValueError(f"The number of sampled cycles must be a positive natural number. Computed that p={p}")
132
+
133
+
134
+ if amplitude <= 0:
135
+ raise ValueError(f"The amplitude must be strictly positive. Received: {amplitude}")
136
+
137
+
138
+ def __tagged_staircase_validator(self, capture_config: CaptureConfig) -> None:
139
+ samp_rate = capture_config["samp_rate"]
140
+ min_samples_per_step = capture_config["min_samples_per_step"]
141
+ max_samples_per_step = capture_config["max_samples_per_step"]
142
+ freq_step = capture_config["freq_step"]
143
+ step_increment = capture_config["step_increment"]
144
+ chunk_size = capture_config["chunk_size"]
145
+ window_type = capture_config["window_type"]
146
+ window_kwargs = capture_config["window_kwargs"]
147
+ window_size = capture_config["window_size"]
148
+ STFFT_kwargs = capture_config["STFFT_kwargs"]
149
+ chunk_key = capture_config["chunk_key"]
150
+ event_handler_key = capture_config["event_handler_key"]
151
+ time_resolution = capture_config["time_resolution"]
152
+
153
+ validators.samp_rate_strictly_positive(samp_rate)
154
+ validators.chunk_size_strictly_positive(chunk_size)
155
+ validators.time_resolution(time_resolution, chunk_size)
156
+ validators.window(window_type, window_kwargs, window_size, chunk_size, samp_rate)
157
+ validators.STFFT_kwargs(STFFT_kwargs)
158
+ validators.chunk_key(chunk_key, "sweep")
159
+ validators.event_handler_key(event_handler_key, "sweep")
160
+
161
+ if freq_step != samp_rate:
162
+ raise ValueError(f"The frequency step must be equal to the sampling rate")
163
+
164
+ if min_samples_per_step <= 0:
165
+ raise ValueError(f"Minimum samples per step must be strictly positive. Received: {min_samples_per_step}")
166
+
167
+ if max_samples_per_step <= 0:
168
+ raise ValueError(f"Maximum samples per step must be strictly positive. Received: {max_samples_per_step}")
169
+
170
+ if step_increment <= 0:
171
+ raise ValueError(f"Step increment must be strictly positive. Received: {step_increment}")
172
+
173
+ if min_samples_per_step > max_samples_per_step:
174
+ raise ValueError(f"Minimum samples per step cannot be greater than the maximum samples per step. Received: {min_samples_per_step} > {max_samples_per_step}")
@@ -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
+ # Global dictionaries to hold the mappings
6
+ receivers = {}
7
+
8
+ # classes decorated with @register_receiver("<receiver_name>")
9
+ # will be added to the global map of receivers with key "receiver_name"
10
+ def register_receiver(receiver_name: str):
11
+ def decorator(cls):
12
+ receivers[receiver_name] = cls
13
+ return cls
14
+ return decorator
15
+
16
+ # return a list of all receiver names
17
+ def list_all_receiver_names() -> list[str]:
18
+ return list(receivers.keys())
19
+
20
+
21
+
22
+
@@ -0,0 +1,205 @@
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
+ from math import floor
9
+ import warnings
10
+
11
+ from scipy.signal import get_window
12
+
13
+
14
+ def closed_upper_bound_RF_gain(RF_gain: float,
15
+ RF_gain_upper_bound: float) -> None:
16
+ if not (RF_gain <= RF_gain_upper_bound):
17
+ raise ValueError(f"RF gain must be strictly less than or equal to {RF_gain_upper_bound} [dB]. Got {RF_gain} [dB]")
18
+
19
+
20
+ def closed_upper_bound_IF_gain(IF_gain: float,
21
+ IF_gain_upper_bound: float) -> None:
22
+ if not (IF_gain <= IF_gain_upper_bound):
23
+ raise ValueError(f"IF gain must be strictly less than or equal to {IF_gain_upper_bound} [dB]. Got {IF_gain} [dB]")
24
+
25
+
26
+ def closed_confine_center_freq(center_freq: float,
27
+ center_freq_lower_bound: float,
28
+ center_freq_upper_bound: float) -> None:
29
+ if not (center_freq_lower_bound <= center_freq <= center_freq_upper_bound):
30
+ raise ValueError(f"Center frequency must be between {center_freq_lower_bound*1e-3} [kHz] and {center_freq_upper_bound*1e-9} [GHz]. Received {center_freq*1e-6} [MHz]")
31
+
32
+
33
+ def closed_confine_samp_rate(samp_rate: int,
34
+ samp_rate_lower_bound: int,
35
+ samp_rate_upper_bound: int) -> None:
36
+ if not (samp_rate_lower_bound <= samp_rate <= samp_rate_upper_bound):
37
+ raise ValueError(f"Sampling rate must be between {samp_rate_lower_bound*1e-6} [MHz] and {samp_rate_upper_bound*1e-6} [MHz]. Received {samp_rate*1e-6} [MHz]")
38
+
39
+
40
+ def closed_confine_bandwidth(bandwidth: float,
41
+ bandwidth_lower_bound: float,
42
+ bandwidth_upper_bound: float) -> None:
43
+ if not (bandwidth_lower_bound <= bandwidth <= bandwidth_upper_bound):
44
+ raise ValueError(f"Bandwidth must be between {bandwidth_lower_bound*1e-3} [kHz] and {bandwidth_upper_bound*1e-6} [MHz]. Received {bandwidth*1e-6} [MHz]")
45
+
46
+
47
+ def is_power_of_two(n):
48
+ return (n != 0) and (n & (n-1) == 0)
49
+
50
+
51
+ def window(window_type: str,
52
+ window_kwargs: dict,
53
+ window_size: int,
54
+ chunk_size: int,
55
+ samp_rate: float) -> None:
56
+
57
+ if not is_power_of_two(window_size):
58
+ raise ValueError(f"Window size must be some power of two. Received: {window_size}")
59
+
60
+
61
+ if window_size*(1/samp_rate) > chunk_size:
62
+ raise ValueError("Windowing interval must be strictly less than the chunk size")
63
+
64
+ try:
65
+ window_params = (window_type,
66
+ *window_kwargs.values())
67
+ _ = get_window(window_params, window_size)
68
+ except Exception as e:
69
+ raise Exception(f"An error has occurred while validating the window. Received: {str(e)}")
70
+
71
+
72
+ def STFFT_kwargs(STFFT_kwargs: dict):
73
+ if len(STFFT_kwargs) == 0:
74
+ raise ValueError("STFFT kwargs cannot be empty")
75
+
76
+ STFFT_keys = STFFT_kwargs.keys()
77
+ if "hop" not in STFFT_keys:
78
+ raise KeyError(f"\"hop\" is a required key in STFFT kwargs. Received: {STFFT_keys}")
79
+
80
+ hop_value = STFFT_kwargs.get("hop")
81
+ hop_value_type = type(hop_value)
82
+ if hop_value_type != int:
83
+ raise TypeError(f"\"hop\" must be specified as an integer. Received: {hop_value_type}")
84
+
85
+ if hop_value < 0:
86
+ raise ValueError(f"\"hop\" must be strictly positive. Received: {hop_value}")
87
+
88
+
89
+ def center_freq_strictly_positive(center_freq: float):
90
+ if center_freq <= 0:
91
+ raise ValueError(f"Center frequency must be strictly positive. Received: {center_freq*1e-6} [MHz]")
92
+
93
+
94
+ def bandwidth_strictly_positive(bandwidth: float) -> None:
95
+ if bandwidth < 0:
96
+ raise ValueError(f"Bandwidth must be non-negative. Received: {bandwidth*1e-6} [MHz]")
97
+
98
+
99
+ def nyquist_criterion(samp_rate: int,
100
+ bandwidth: float) -> None:
101
+ if samp_rate < bandwidth:
102
+ raise ValueError("Sample rate must be greater than or equal to the bandwidth")
103
+
104
+
105
+ def samp_rate_strictly_positive(samp_rate: int) -> None:
106
+ if samp_rate < 0:
107
+ raise ValueError(f"Sample rate must be strictly positive. Received: {samp_rate} [Hz]")
108
+
109
+
110
+ def chunk_size_strictly_positive(chunk_size: int) -> None:
111
+ if chunk_size <= 0:
112
+ raise ValueError(f"Chunk size must be strictly positive. Received: {chunk_size} [s]")
113
+
114
+
115
+ def time_resolution(time_resolution: float,
116
+ chunk_size: int) -> None:
117
+ if time_resolution < 0:
118
+ raise ValueError(f"Time resolution must be non-negative. Received: {time_resolution} [s]")
119
+
120
+ if time_resolution > chunk_size:
121
+ raise ValueError("Time resolution must be less than or equal to chunk size")
122
+
123
+
124
+ def frequency_resolution(frequency_resolution: float,
125
+ bandwidth: float = None) -> None:
126
+ if frequency_resolution < 0:
127
+ raise ValueError(f"Frequency resolution must be non-negative. Received {frequency_resolution} [Hz]")
128
+
129
+ if bandwidth is not None and frequency_resolution >= bandwidth:
130
+ raise ValueError(f"Frequency resolution must be less than the bandwidth. Received frequency resolution to be {frequency_resolution} [Hz], with bandwidth {bandwidth} [Hz]")
131
+
132
+
133
+ def chunk_key(chunk_key: str,
134
+ expected_chunk_key: str) -> None:
135
+ if chunk_key != expected_chunk_key:
136
+ raise ValueError(f"Expected \"{expected_chunk_key}\" for the chunk_key, received: {chunk_key}")
137
+
138
+
139
+ def event_handler_key(event_handler_key: str,
140
+ expected_event_handler_key: str) -> None:
141
+ if event_handler_key != expected_event_handler_key:
142
+ raise ValueError(f"Expected \"{expected_event_handler_key}\" for the event_handler_key, received: {event_handler_key}")
143
+
144
+
145
+ def gain_is_negative(gain: float) -> None:
146
+ if gain > 0:
147
+ raise ValueError(f"Gain must be non-positive. Received {gain} [dB]")
148
+
149
+
150
+ def _compute_num_steps_per_sweep(min_freq: float,
151
+ max_freq: float,
152
+ samp_rate: int,
153
+ freq_step: float) -> int:
154
+ return floor((max_freq - min_freq + samp_rate/2) / freq_step)
155
+
156
+
157
+ def num_steps_per_sweep(min_freq: float,
158
+ max_freq: float,
159
+ samp_rate: int,
160
+ freq_step: float) -> None:
161
+ num_steps_per_sweep = _compute_num_steps_per_sweep(min_freq,
162
+ max_freq,
163
+ samp_rate,
164
+ freq_step)
165
+ if num_steps_per_sweep <= 1:
166
+ raise ValueError(f"We need strictly greater than one sample per step. Computed: {num_steps_per_sweep}")
167
+
168
+
169
+ def sweep_interval(min_freq: float,
170
+ max_freq: float,
171
+ samp_rate: int,
172
+ freq_step: float,
173
+ samples_per_step: int,
174
+ chunk_size: float) -> None:
175
+ num_steps_per_sweep = _compute_num_steps_per_sweep(min_freq,
176
+ max_freq,
177
+ samp_rate,
178
+ freq_step)
179
+ num_samples_per_sweep = num_steps_per_sweep * samples_per_step
180
+ sweep_interval = num_samples_per_sweep * 1/samp_rate
181
+ if sweep_interval > chunk_size:
182
+ raise ValueError(f"Sweep interval must be less than the chunk size. Computed sweep interval: {sweep_interval} [s] is greater than the given chunk size {chunk_size} [s]")
183
+
184
+
185
+ def num_samples_per_step(samples_per_step: int,
186
+ window_size: int) -> None:
187
+ if window_size >= samples_per_step:
188
+ raise ValueError(f"Window size must be strictly less than the number of samples per step. Received window size {window_size} [samples], which is more than or equal to the number of samples per step {samples_per_step}")
189
+
190
+
191
+ def non_overlapping_steps(freq_step: float,
192
+ samp_rate: int) -> None:
193
+ if freq_step < samp_rate:
194
+ raise NotImplementedError(f"SPECTRE does not yet support spectral steps overlapping in frequency. Received frequency step {freq_step/1e6} [MHz] which is less than the sample rate {samp_rate/1e6} [MHz]")
195
+
196
+
197
+ def step_interval(samples_per_step: int,
198
+ samp_rate: int,
199
+ api_latency: float) -> None:
200
+ step_interval = samples_per_step * 1/samp_rate # [s]
201
+ if step_interval < api_latency:
202
+ warning_message = f"The computed step interval of {step_interval} [s] is of the order of empirically derived api latency {api_latency} [s]; you may experience undefined behaviour!"
203
+ warnings.warn(warning_message)
204
+ _LOGGER.warning(warning_message)
205
+
@@ -0,0 +1,3 @@
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
@@ -0,0 +1,205 @@
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 Callable
6
+ from dataclasses import dataclass
7
+
8
+ import numpy as np
9
+
10
+ from spectre_core.file_handlers.configs import CaptureConfig
11
+ from spectre_core.spectrograms.spectrogram import Spectrogram
12
+ from spectre_core.spectrograms.array_operations import is_close
13
+ from spectre_core.exceptions import ModeNotFoundError
14
+
15
+
16
+
17
+ @dataclass
18
+ class TestResults:
19
+ # Whether the times array matches analytically
20
+ times_validated: bool = False
21
+ # Whether the frequencies array matches analytically
22
+ frequencies_validated: bool = False
23
+ # Maps each time to whether the corresponding spectrum matched analytically
24
+ spectrum_validated: dict[float, bool] = None
25
+
26
+ @property
27
+ def num_validated_spectrums(self) -> int:
28
+ """Counts the number of validated spectrums."""
29
+ return sum(is_validated for is_validated in self.spectrum_validated.values())
30
+
31
+ @property
32
+ def num_invalid_spectrums(self) -> int:
33
+ """Counts the number of spectrums that are not validated."""
34
+ return len(self.spectrum_validated) - self.num_validated_spectrums
35
+
36
+
37
+ class _AnalyticalFactory:
38
+ def __init__(self):
39
+ self._builders: dict[str, Callable] = {
40
+ "cosine-signal-1": self.cosine_signal_1,
41
+ "tagged-staircase": self.tagged_staircase
42
+ }
43
+ self._test_modes = list(self.builders.keys())
44
+
45
+
46
+ @property
47
+ def builders(self) -> dict[str, Callable]:
48
+ return self._builders
49
+
50
+
51
+ @property
52
+ def test_modes(self) -> list[str]:
53
+ return self._test_modes
54
+
55
+
56
+ def get_spectrogram(self,
57
+ num_spectrums: int,
58
+ capture_config: CaptureConfig) -> Spectrogram:
59
+ """Get an analytical spectrogram based on a test receiver capture config.
60
+
61
+ The anaytically derived spectrogram should be able to be fully determined
62
+ by parameters in the corresponding capture config and the number of spectrums
63
+ in the output spectrogram.
64
+ """
65
+ receiver_name, test_mode = capture_config['receiver'], capture_config['mode']
66
+
67
+ if receiver_name != "test":
68
+ raise ValueError(f"Input capture config must correspond to the test receiver")
69
+
70
+ builder_method = self.builders.get(test_mode)
71
+ if builder_method is None:
72
+ raise ModeNotFoundError(f"Test mode not found. Expected one of {self.test_modes}, but received {test_mode}")
73
+ return builder_method(num_spectrums,
74
+ capture_config)
75
+
76
+
77
+ def cosine_signal_1(self,
78
+ num_spectrums: int,
79
+ capture_config: CaptureConfig) -> Spectrogram:
80
+ # Extract necessary parameters from the capture configuration.
81
+ window_size = capture_config['window_size']
82
+ samp_rate = capture_config['samp_rate']
83
+ amplitude = capture_config['amplitude']
84
+ frequency = capture_config['frequency']
85
+ hop = capture_config['STFFT_kwargs']['hop']
86
+
87
+ # Calculate derived parameters a (sampling rate ratio) and p (sampled periods).
88
+ a = int(samp_rate / frequency)
89
+ p = int(window_size / a)
90
+
91
+ # Create the analytical spectrum, which is constant in time.
92
+ spectrum = np.zeros(window_size)
93
+ spectral_amplitude = amplitude * window_size / 2
94
+ spectrum[p] = spectral_amplitude
95
+ spectrum[window_size - p] = spectral_amplitude
96
+
97
+ # Align spectrum to naturally ordered frequency array.
98
+ spectrum = np.fft.fftshift(spectrum)
99
+
100
+ # Populate the spectrogram with identical spectra.
101
+ analytical_dynamic_spectra = np.ones((window_size, num_spectrums)) * spectrum[:, np.newaxis]
102
+
103
+ # Compute time array.
104
+ sampling_interval = 1 / samp_rate
105
+ times = np.arange(num_spectrums) * hop * sampling_interval
106
+
107
+ # compute the frequency array.
108
+ frequencies = np.fft.fftshift(np.fft.fftfreq(window_size, sampling_interval))
109
+
110
+ # Return the spectrogram.
111
+ return Spectrogram(analytical_dynamic_spectra,
112
+ times,
113
+ frequencies,
114
+ 'analytically-derived-spectrogram',
115
+ spectrum_type="amplitude")
116
+
117
+
118
+ def tagged_staircase(self,
119
+ num_spectrums: int,
120
+ capture_config: CaptureConfig) -> Spectrogram:
121
+ # Extract necessary parameters from the capture configuration.
122
+ window_size = capture_config['window_size']
123
+ min_samples_per_step = capture_config['min_samples_per_step']
124
+ max_samples_per_step = capture_config['max_samples_per_step']
125
+ step_increment = capture_config['step_increment']
126
+ samp_rate = capture_config['samp_rate']
127
+
128
+ # Calculate step sizes and derived parameters.
129
+ num_samples_per_step = np.arange(min_samples_per_step, max_samples_per_step + 1, step_increment)
130
+ num_steps = len(num_samples_per_step)
131
+
132
+ # Create the analytical spectrum, constant in time.
133
+ spectrum = np.zeros(window_size * num_steps)
134
+ step_count = 0
135
+ for i in range(num_steps):
136
+ step_count += 1
137
+ spectral_amplitude = window_size * step_count
138
+ spectrum[int(window_size/2) + i*window_size] = spectral_amplitude
139
+
140
+ # Populate the spectrogram with identical spectra.
141
+ analytical_dynamic_spectra = np.ones((window_size * num_steps, num_spectrums)) * spectrum[:, np.newaxis]
142
+
143
+ # Compute time array
144
+ num_samples_per_sweep = sum(num_samples_per_step)
145
+ midpoint_sample = sum(num_samples_per_step) // 2
146
+ sampling_interval = 1 / samp_rate
147
+ # compute the sample index we are "assigning" to each spectrum
148
+ # and multiply by the sampling interval to get the equivalent physical time
149
+ times = np.array([ midpoint_sample + (i * num_samples_per_sweep) for i in range(num_spectrums) ]) * sampling_interval
150
+
151
+ # Compute the frequency array
152
+ baseband_frequencies = np.fft.fftshift(np.fft.fftfreq(window_size, sampling_interval))
153
+ frequencies = np.empty((window_size * num_steps))
154
+ for i in range(num_steps):
155
+ lower_bound = i * window_size
156
+ upper_bound = (i + 1) * window_size
157
+ frequencies[lower_bound:upper_bound] = baseband_frequencies + (samp_rate / 2) + (samp_rate * i)
158
+
159
+ # Return the spectrogram.
160
+ return Spectrogram(analytical_dynamic_spectra,
161
+ times,
162
+ frequencies,
163
+ 'analytically-derived-spectrogram',
164
+ spectrum_type="amplitude")
165
+
166
+
167
+ def get_analytical_spectrogram(num_spectrums: int,
168
+ capture_config: CaptureConfig) -> Spectrogram:
169
+
170
+ factory = _AnalyticalFactory()
171
+ return factory.get_spectrogram(num_spectrums,
172
+ capture_config)
173
+
174
+
175
+ def validate_analytically(spectrogram: Spectrogram,
176
+ capture_config: CaptureConfig,
177
+ absolute_tolerance: float) -> TestResults:
178
+
179
+ analytical_spectrogram = get_analytical_spectrogram(spectrogram.num_times,
180
+ capture_config)
181
+
182
+
183
+ test_results = TestResults()
184
+
185
+ if is_close(analytical_spectrogram.times,
186
+ spectrogram.times,
187
+ absolute_tolerance):
188
+ test_results.times_validated = True
189
+
190
+
191
+ if is_close(analytical_spectrogram.frequencies,
192
+ spectrogram.frequencies,
193
+ absolute_tolerance):
194
+ test_results.frequencies_validated = True
195
+
196
+ test_results.spectrum_validated = {}
197
+ for i in range(spectrogram.num_times):
198
+ time = spectrogram.times[i]
199
+ analytical_spectrum = analytical_spectrogram.dynamic_spectra[:, i]
200
+ spectrum = spectrogram.dynamic_spectra[:, i]
201
+ test_results.spectrum_validated[time] = is_close(analytical_spectrum,
202
+ spectrum,
203
+ absolute_tolerance)
204
+
205
+ return test_results
@@ -0,0 +1,77 @@
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
6
+
7
+ import numpy as np
8
+
9
+
10
+ def is_close(ar: np.ndarray,
11
+ ar_comparison: np.ndarray,
12
+ absolute_tolerance: float) -> bool:
13
+ """Close enough accounts for wiggle-room equating floats."""
14
+ return np.all(np.isclose(ar,
15
+ ar_comparison,
16
+ atol=absolute_tolerance))
17
+
18
+
19
+ def find_closest_index(
20
+ target_value: float | datetime,
21
+ array: np.ndarray,
22
+ enforce_strict_bounds: bool = False
23
+ ) -> int:
24
+ # Ensure input array is a numpy array
25
+ array = np.asarray(array)
26
+
27
+ # Convert to datetime64 if necessary
28
+ if isinstance(target_value, datetime) or np.issubdtype(array.dtype, np.datetime64):
29
+ target_value = np.datetime64(target_value)
30
+ array = array.astype('datetime64[ns]')
31
+ else:
32
+ target_value = float(target_value)
33
+ array = array.astype(float)
34
+
35
+ # Check bounds if strict enforcement is required
36
+ if enforce_strict_bounds:
37
+ max_value, min_value = np.nanmax(array), np.nanmin(array)
38
+ if target_value > max_value:
39
+ raise ValueError(f"Target value {target_value} exceeds max array value {max_value}")
40
+ if target_value < min_value:
41
+ raise ValueError(f"Target value {target_value} is less than min array value {min_value}")
42
+
43
+ # Find the index of the closest value
44
+ return np.argmin(np.abs(array - target_value))
45
+
46
+
47
+ def normalise_peak_intensity(array: np.ndarray) -> np.ndarray:
48
+ return array/np.nanmax(array)
49
+
50
+
51
+ def compute_resolution(array: np.ndarray) -> float:
52
+ # Check that the array is one-dimensional
53
+ if array.ndim != 1:
54
+ raise ValueError("Input array must be one-dimensional")
55
+
56
+ if len(array) < 2:
57
+ raise ValueError("Input array must contain at least two elements")
58
+
59
+ # Calculate differences between consecutive elements.
60
+ resolutions = np.diff(array)
61
+
62
+ return np.nanmedian(resolutions)
63
+
64
+
65
+ def compute_range(array: np.ndarray) -> float:
66
+ # Check that the array is one-dimensional
67
+ if array.ndim != 1:
68
+ raise ValueError("Input array must be one-dimensional")
69
+
70
+ if len(array) < 2:
71
+ raise ValueError("Input array must contain at least two elements")
72
+ return array[-1] - array[0]
73
+
74
+
75
+ def subtract_background(array: np.ndarray, start_index: int, end_index: int) -> np.ndarray:
76
+ array -= np.nanmean(array[start_index:end_index+1])
77
+ return array