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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. spectre_core/_file_io/__init__.py +1 -3
  2. spectre_core/_file_io/file_handlers.py +163 -58
  3. spectre_core/batches/__init__.py +10 -11
  4. spectre_core/batches/_base.py +170 -78
  5. spectre_core/batches/_batches.py +149 -99
  6. spectre_core/batches/_factory.py +56 -14
  7. spectre_core/batches/_register.py +23 -8
  8. spectre_core/batches/plugins/_batch_keys.py +16 -0
  9. spectre_core/batches/plugins/_callisto.py +183 -0
  10. spectre_core/batches/plugins/_iq_stream.py +354 -0
  11. spectre_core/capture_configs/__init__.py +17 -13
  12. spectre_core/capture_configs/_capture_config.py +93 -34
  13. spectre_core/capture_configs/_capture_modes.py +22 -0
  14. spectre_core/capture_configs/_capture_templates.py +207 -122
  15. spectre_core/capture_configs/_parameters.py +115 -42
  16. spectre_core/capture_configs/_pconstraints.py +86 -35
  17. spectre_core/capture_configs/_pnames.py +49 -0
  18. spectre_core/capture_configs/_ptemplates.py +389 -346
  19. spectre_core/capture_configs/_pvalidators.py +117 -73
  20. spectre_core/config/__init__.py +6 -8
  21. spectre_core/config/_paths.py +65 -25
  22. spectre_core/config/_time_formats.py +15 -10
  23. spectre_core/exceptions.py +2 -4
  24. spectre_core/jobs/__init__.py +14 -0
  25. spectre_core/jobs/_jobs.py +111 -0
  26. spectre_core/jobs/_workers.py +171 -0
  27. spectre_core/logs/__init__.py +17 -0
  28. spectre_core/logs/_configure.py +67 -0
  29. spectre_core/logs/_decorators.py +33 -0
  30. spectre_core/logs/_logs.py +228 -0
  31. spectre_core/logs/_process_types.py +14 -0
  32. spectre_core/plotting/__init__.py +4 -2
  33. spectre_core/plotting/_base.py +204 -102
  34. spectre_core/plotting/_format.py +17 -4
  35. spectre_core/plotting/_panel_names.py +18 -0
  36. spectre_core/plotting/_panel_stack.py +167 -53
  37. spectre_core/plotting/_panels.py +341 -141
  38. spectre_core/post_processing/__init__.py +8 -6
  39. spectre_core/post_processing/_base.py +70 -44
  40. spectre_core/post_processing/_factory.py +42 -12
  41. spectre_core/post_processing/_post_processor.py +24 -26
  42. spectre_core/post_processing/_register.py +22 -6
  43. spectre_core/post_processing/plugins/_event_handler_keys.py +16 -0
  44. spectre_core/post_processing/plugins/_fixed_center_frequency.py +129 -0
  45. spectre_core/post_processing/{library → plugins}/_swept_center_frequency.py +215 -143
  46. spectre_core/py.typed +0 -0
  47. spectre_core/receivers/__init__.py +10 -7
  48. spectre_core/receivers/_base.py +220 -69
  49. spectre_core/receivers/_factory.py +53 -7
  50. spectre_core/receivers/_register.py +30 -9
  51. spectre_core/receivers/_spec_names.py +26 -15
  52. spectre_core/receivers/plugins/__init__.py +0 -0
  53. spectre_core/receivers/plugins/_receiver_names.py +16 -0
  54. spectre_core/receivers/plugins/_rsp1a.py +59 -0
  55. spectre_core/receivers/plugins/_rspduo.py +67 -0
  56. spectre_core/receivers/plugins/_sdrplay_receiver.py +190 -0
  57. spectre_core/receivers/plugins/_test.py +218 -0
  58. spectre_core/receivers/plugins/gr/_base.py +80 -0
  59. spectre_core/receivers/{gr → plugins/gr}/_rsp1a.py +42 -52
  60. spectre_core/receivers/{gr → plugins/gr}/_rspduo.py +61 -74
  61. spectre_core/receivers/{gr → plugins/gr}/_test.py +33 -31
  62. spectre_core/spectrograms/__init__.py +5 -3
  63. spectre_core/spectrograms/_analytical.py +121 -66
  64. spectre_core/spectrograms/_array_operations.py +103 -36
  65. spectre_core/spectrograms/_spectrogram.py +380 -207
  66. spectre_core/spectrograms/_transform.py +197 -169
  67. spectre_core/wgetting/__init__.py +4 -2
  68. spectre_core/wgetting/_callisto.py +173 -118
  69. {spectre_core-0.0.12.dist-info → spectre_core-0.0.13.dist-info}/METADATA +14 -7
  70. spectre_core-0.0.13.dist-info/RECORD +75 -0
  71. {spectre_core-0.0.12.dist-info → spectre_core-0.0.13.dist-info}/WHEEL +1 -1
  72. spectre_core/batches/library/_callisto.py +0 -96
  73. spectre_core/batches/library/_fixed_center_frequency.py +0 -133
  74. spectre_core/batches/library/_swept_center_frequency.py +0 -105
  75. spectre_core/logging/__init__.py +0 -11
  76. spectre_core/logging/_configure.py +0 -35
  77. spectre_core/logging/_decorators.py +0 -19
  78. spectre_core/logging/_log_handlers.py +0 -176
  79. spectre_core/post_processing/library/_fixed_center_frequency.py +0 -114
  80. spectre_core/receivers/gr/_base.py +0 -33
  81. spectre_core/receivers/library/_rsp1a.py +0 -61
  82. spectre_core/receivers/library/_rspduo.py +0 -69
  83. spectre_core/receivers/library/_sdrplay_receiver.py +0 -185
  84. spectre_core/receivers/library/_test.py +0 -221
  85. spectre_core-0.0.12.dist-info/RECORD +0 -64
  86. /spectre_core/receivers/{gr → plugins/gr}/__init__.py +0 -0
  87. {spectre_core-0.0.12.dist-info → spectre_core-0.0.13.dist-info}/LICENSE +0 -0
  88. {spectre_core-0.0.12.dist-info → spectre_core-0.0.13.dist-info}/top_level.txt +0 -0
@@ -2,17 +2,19 @@
2
2
  # This file is part of SPECTRE
3
3
  # SPDX-License-Identifier: GPL-3.0-or-later
4
4
 
5
- from typing import Optional
5
+ from typing import Optional, cast
6
6
  from warnings import warn
7
- from datetime import datetime, timedelta
7
+ from datetime import datetime
8
8
  from dataclasses import dataclass
9
+ from enum import Enum
9
10
  import os
10
11
 
11
12
  import numpy as np
13
+ import numpy.typing as npt
12
14
  from astropy.io import fits
13
15
 
14
- from spectre_core.capture_configs import CaptureConfig, PNames
15
- from spectre_core.config import get_batches_dir_path, TimeFormats
16
+ from spectre_core.capture_configs import CaptureConfig, PName
17
+ from spectre_core.config import get_batches_dir_path, TimeFormat
16
18
  from ._array_operations import (
17
19
  find_closest_index,
18
20
  normalise_peak_intensity,
@@ -22,250 +24,369 @@ from ._array_operations import (
22
24
  )
23
25
 
24
26
 
27
+ class SpectrumUnit(Enum):
28
+ """A defined unit for dynamic spectra values.
29
+
30
+ :ivar AMPLITUDE: Formal definition TBC.
31
+ :ivar POWER: Formal definition TBC.
32
+ :ivar DIGITS: Formal definition TBC.
33
+ """
34
+ AMPLITUDE = "amplitude"
35
+ POWER = "power"
36
+ DIGITS = "digits"
37
+
38
+
25
39
  @dataclass
26
40
  class FrequencyCut:
27
- """A container to hold a cut of a dynamic spectra at a particular instant of time."""
41
+ """A cut of a dynamic spectra, at a particular instant of time. Equivalently, some spectrum in
42
+ the spectrogram.
43
+
44
+ :ivar time: The time of the frequency cut, either in relative time (if time is a float)
45
+ or as a datetime.
46
+ :ivar frequencies: The physical frequencies assigned to each spectral component, in Hz.
47
+ :ivar cut: The spectrum values.
48
+ :ivar spectrum_unit: The unit of each spectrum value.
49
+ """
28
50
  time: float | datetime
29
- frequencies: np.ndarray
30
- cut: np.ndarray
31
- spectrum_type: str
51
+ frequencies: npt.NDArray[np.float32]
52
+ cut: npt.NDArray[np.float32]
53
+ spectrum_unit: SpectrumUnit
32
54
 
33
55
 
34
56
  @dataclass
35
57
  class TimeCut:
36
- """A container to hold a cut of a dynamic spectra at a particular frequency."""
58
+ """A cut of a dynamic spectra, at some fixed frequency. Equivalently, a time series of
59
+ some spectral component in the spectrogram.
60
+
61
+ :ivar frequency: The physical frequency assigned to the spectral component, in Hz.
62
+ :ivar times: The time for each time series value, either as a relative time (if
63
+ the elements are floats) or as a datetimes.
64
+ :ivar cut: The time series values of the spectral component.
65
+ :ivar spectrum_unit: The unit of each time series value.
66
+ """
37
67
  frequency: float
38
- times: np.ndarray
39
- cut: np.ndarray
40
- spectrum_type: str
68
+ times: npt.NDArray[np.float32 | np.datetime64]
69
+ cut: npt.NDArray[np.float32]
70
+ spectrum_unit: SpectrumUnit
41
71
 
42
72
 
43
- @dataclass(frozen=True)
44
- class TimeTypes:
45
- """Container to hold the different types of time we can assign to each spectrum in the dynamic spectra.
73
+ class TimeType(Enum):
74
+ """The type of time we can assign to each spectrum in the dynamic spectra.
46
75
 
47
- 'SECONDS' is equivalent to 'seconds elapsed since the first spectrum'.
48
- 'DATETIMES' is equivalent to 'the datetime associated with each spectrum'.
76
+ :ivar RELATIVE: The elapsed time from the first spectrum, in seconds.
77
+ :ivar DATETIMES: The datetime associated with each spectrum.
49
78
  """
50
- SECONDS : str = "seconds"
51
- DATETIMES: str = "datetimes"
52
-
53
-
54
- @dataclass(frozen=True)
55
- class SpectrumTypes:
56
- """A container for defined units of dynamic spectra."""
57
- AMPLITUDE: str = "amplitude"
58
- POWER : str = "power"
59
- DIGITS : str = "digits"
79
+ RELATIVE = "relative"
80
+ DATETIMES = "datetimes"
60
81
 
61
82
 
62
83
  class Spectrogram:
63
- """A convenient, standardised wrapper for spectrogram data."""
64
- def __init__(self,
65
- dynamic_spectra: np.ndarray,
66
- times: np.ndarray,
67
- frequencies: np.ndarray,
68
- tag: str,
69
- start_datetime: Optional[datetime] = None,
70
- spectrum_type: Optional[str] = None):
84
+ """Standardised wrapper for spectrogram data, providing a consistent
85
+ interface for storing, accessing, and manipulating spectrogram data,
86
+ along with associated metadata.
87
+ """
88
+ def __init__(
89
+ self,
90
+ dynamic_spectra: npt.NDArray[np.float32],
91
+ times : npt.NDArray[np.float32],
92
+ frequencies : npt.NDArray[np.float32],
93
+ tag : str,
94
+ spectrum_unit : SpectrumUnit,
95
+ start_datetime : Optional[datetime | np.datetime64] = None
96
+ ) -> None:
97
+ """Initialise a Spectrogram instance.
98
+
99
+ :param dynamic_spectra: A 2D array of spectrogram data.
100
+ :param times: A 1D array representing the elapsed time of each spectrum, in seconds, relative to the first
101
+ in the spectrogram.
102
+ :param frequencies: A 1D array representing the physical frequencies assigned to each spectral component, in Hz.
103
+ :param tag: A string identifier for the spectrogram.
104
+ :param spectrum_unit: The unit of the dynamic_spectra values.
105
+ :param start_datetime: The datetime corresponding to the first spectrum, defaults to None.
106
+ :raises ValueError: If times does not start at 0 or array shapes are inconsistent.
107
+ """
71
108
 
72
- # dynamic spectra
73
109
  self._dynamic_spectra = dynamic_spectra
74
- self._dynamic_spectra_dBb: Optional[np.ndarray] = None # cache
75
110
 
76
- # assigned times and frequencies
77
111
  if times[0] != 0:
78
112
  raise ValueError(f"The first spectrum must correspond to t=0")
79
-
80
113
  self._times = times
114
+
81
115
  self._frequencies = frequencies
82
-
83
- # general metadata
84
116
  self._tag = tag
85
- self._spectrum_type = spectrum_type
86
-
87
- # datetime information
88
- self._start_datetime = start_datetime
89
- self._datetimes: Optional[list[datetime]] = None # cache
117
+ self._spectrum_unit = spectrum_unit
118
+ self._start_datetime = np.datetime64(start_datetime)
90
119
 
91
- # background metadata
92
- self._background_spectrum: Optional[np.ndarray] = None # cache
120
+ # by default, the background is evaluated over the whole spectrogram
93
121
  self._start_background_index = 0
94
122
  self._end_background_index = self.num_times
95
- # background interval can be set after instanitation.
96
- self._start_background = None
97
- self._end_background = None
123
+ # the background interval can be set after instantiation
124
+ self._start_background: Optional[str] = None
125
+ self._end_background : Optional[str] = None
98
126
 
99
127
  # finally check that the spectrogram arrays are matching in shape
100
128
  self._check_shapes()
101
129
 
102
130
 
103
131
  @property
104
- def dynamic_spectra(self) -> np.ndarray:
105
- """The dynamic spectra."""
132
+ def dynamic_spectra(
133
+ self
134
+ ) -> npt.NDArray[np.float32]:
135
+ """The dynamic spectra array.
136
+
137
+ Returns the 2D array of spectrogram data with shape (num_frequencies, num_times),
138
+ where values are in the units specified by `spectrum_unit`.
139
+ """
106
140
  return self._dynamic_spectra
107
141
 
108
142
 
109
143
  @property
110
- def times(self) -> np.ndarray:
111
- """The physical time assigned to each spectrum.
112
-
113
- Equivalent to the 'seconds elapsed since the first spectrum'. So, by convention, the
114
- first spectrum is at t=0.
115
- """
144
+ def times(
145
+ self
146
+ ) -> npt.NDArray[np.float32]:
147
+ """A 1D array representing the elapsed time of each spectrum, in seconds, relative to the first
148
+ in the spectrogram."""
116
149
  return self._times
117
150
 
118
151
 
119
152
  @property
120
- def num_times(self) -> int:
153
+ def num_times(
154
+ self
155
+ ) -> int:
121
156
  """The size of the times array. Equivalent to the number of spectrums in the spectrogram."""
122
157
  return len(self._times)
123
158
 
124
159
 
125
160
  @property
126
- def time_resolution(self) -> float:
127
- """The time resolution of the dynamic spectra."""
161
+ def time_resolution(
162
+ self
163
+ ) -> float:
164
+ """The time resolution of the dynamic spectra.
165
+
166
+ Represents the spacing between consecutive time values in the times array,
167
+ calculated as the median difference between adjacent elements.
168
+ """
128
169
  return compute_resolution(self._times)
129
170
 
130
171
 
131
172
  @property
132
- def time_range(self) -> float:
133
- """The time range of the dynamic spectra."""
173
+ def time_range(
174
+ self
175
+ ) -> float:
176
+ """The time range of the dynamic spectra.
177
+
178
+ Represents the difference between the first and last time values
179
+ in the times array.
180
+ """
134
181
  return compute_range(self._times)
135
182
 
136
183
 
137
184
  @property
138
- def frequencies(self) -> np.ndarray:
139
- """The physical frequency assigned to each spectral component."""
185
+ def frequencies(
186
+ self
187
+ ) -> npt.NDArray[np.float32]:
188
+ """A 1D array representing the physical frequencies assigned to each spectral component, in Hz."""
140
189
  return self._frequencies
141
190
 
142
191
 
143
192
  @property
144
- def num_frequencies(self) -> int:
145
- """The number of spectral components."""
193
+ def num_frequencies(
194
+ self
195
+ ) -> int:
196
+ """The number of spectral components in the spectrogram.
197
+
198
+ Equivalent to the size of the frequencies array.
199
+ """
146
200
  return len(self._frequencies)
147
201
 
148
202
 
149
203
  @property
150
- def frequency_resolution(self) -> float:
151
- """The frequency resolution of the dynamic spectra."""
204
+ def frequency_resolution(
205
+ self
206
+ ) -> float:
207
+ """The frequency resolution of the dynamic spectra.
208
+
209
+ Represents the spacing between consecutive frequency values in the frequencies array,
210
+ calculated as the median difference between adjacent elements.
211
+ """
152
212
  return compute_resolution(self._frequencies)
153
213
 
154
214
 
155
215
  @property
156
- def frequency_range(self) -> float:
157
- """The frequency range covered by the dynamic spectra."""
216
+ def frequency_range(
217
+ self
218
+ ) -> float:
219
+ """The frequency range covered by the dynamic spectra.
220
+
221
+ Represents the difference between the minimum and maximum frequency
222
+ values in the frequencies array.
223
+ """
158
224
  return compute_range(self._frequencies)
159
225
 
160
226
 
161
227
  @property
162
- def tag(self) -> str:
163
- """The tag identifier corresponding to the dynamic spectra."""
228
+ def tag(
229
+ self
230
+ ) -> str:
231
+ """The tag identifier for the spectrogram"""
164
232
  return self._tag
165
233
 
166
234
 
167
235
  @property
168
- def start_datetime_is_set(self) -> bool:
169
- """Returns true if the start datetime for the spectrogram has been set."""
236
+ def start_datetime_is_set(
237
+ self
238
+ ) -> bool:
239
+ """Indicates whether the start datetime for the spectrogram has been set."""
170
240
  return (self._start_datetime is not None)
171
241
 
172
242
 
173
243
  @property
174
- def start_datetime(self) -> datetime:
175
- """The datetime assigned to the first spectrum in the dynamic spectra."""
244
+ def start_datetime(
245
+ self
246
+ ) -> np.datetime64:
247
+ """The datetime assigned to the first spectrum in `dynamic_spectra`.
248
+
249
+ :raises AttributeError: If the start_datetime has not been set.
250
+ """
176
251
  if self._start_datetime is None:
177
252
  raise AttributeError(f"A start time has not been set.")
178
253
  return self._start_datetime
179
254
 
180
255
 
181
- def format_start_time(self,
182
- precise: bool = False) -> str:
183
- """The datetime assigned to the first spectrum in the dynamic spectra, formatted as a string."""
184
- if precise:
185
- return datetime.strftime(self.start_datetime, TimeFormats.PRECISE_DATETIME)
186
- return datetime.strftime(self.start_datetime, TimeFormats.DATETIME)
187
-
188
-
189
256
  @property
190
- def datetimes(self) -> list[datetime]:
191
- """The datetimes associated with each spectrum in the dynamic spectra."""
192
- if self._datetimes is None:
193
- self._datetimes = [self.start_datetime + timedelta( seconds=(float(t)) ) for t in self._times]
194
- return self._datetimes
257
+ def datetimes(
258
+ self
259
+ ) -> npt.NDArray[np.datetime64]:
260
+ """The datetimes associated with each spectrum in `dynamic_spectra`.
261
+
262
+ Returns a list of datetime objects, calculated by adding the elapsed
263
+ times in the times array to the start_datetime.
264
+ """
265
+ return self.start_datetime + (1e6*self._times).astype('timedelta64[us]')
195
266
 
196
267
 
197
268
  @property
198
- def spectrum_type(self) -> Optional[str]:
199
- """The units of the dynamic spectra."""
200
- return self._spectrum_type
269
+ def spectrum_unit(
270
+ self
271
+ ) -> SpectrumUnit:
272
+ """The units associated with the `dynamic_spectra` array."""
273
+ return self._spectrum_unit
201
274
 
202
275
 
203
276
  @property
204
- def start_background(self) -> Optional[str]:
205
- """The start of the background interval, as a datetime string up to seconds precision."""
277
+ def start_background(
278
+ self
279
+ ) -> Optional[str]:
280
+ """The start of the background interval.
281
+
282
+ Returns a string-formatted datetime up to seconds precision, or None
283
+ if the background interval has not been set.
284
+ """
206
285
  return self._start_background
207
286
 
208
287
 
209
288
  @property
210
- def end_background(self) -> Optional[str]:
211
- """The end of the background interval, as a datetime string up to seconds precision."""
289
+ def end_background(
290
+ self
291
+ ) -> Optional[str]:
292
+ """The end of the background interval.
293
+
294
+ Returns a string-formatted datetime up to seconds precision, or None
295
+ if the background interval has not been set.
296
+ """
212
297
  return self._end_background
213
298
 
214
-
215
- @property
216
- def background_spectrum(self) -> np.ndarray:
217
- """The background spectrum, computed by averaging the dynamic spectra according to the specified background interval.
218
-
219
- By default, the entire dynamic spectra is averaged over.
299
+
300
+ def compute_background_spectrum(
301
+ self
302
+ ) -> npt.NDArray[np.float32]:
303
+ """Compute the background spectrum by averaging the dynamic spectra in time.
304
+
305
+ By default, the entire dynamic spectra is averaged. Use set_background to
306
+ specify a custom background interval.
307
+
308
+ :return: A 1D array representing the time-averaged dynamic spectra over the
309
+ specified background interval.
220
310
  """
221
- if self._background_spectrum is None:
222
- self._background_spectrum = np.nanmean(self._dynamic_spectra[:, self._start_background_index:self._end_background_index+1],
311
+ return np.nanmean(self._dynamic_spectra[:, self._start_background_index:self._end_background_index+1],
223
312
  axis=-1)
224
- return self._background_spectrum
225
313
 
226
314
 
227
- @property
228
- def dynamic_spectra_dBb(self) -> np.ndarray:
229
- """The dynamic spectra in units of decibels above the background spectrum."""
230
- if self._dynamic_spectra_dBb is None:
231
- # Create an artificial spectrogram where each spectrum is identically the background spectrum
232
- background_spectra = self.background_spectrum[:, np.newaxis]
233
- # Suppress divide by zero and invalid value warnings for this block of code
234
- with np.errstate(divide='ignore'):
235
- # Depending on the spectrum type, compute the dBb values differently
236
- if self._spectrum_type == SpectrumTypes.AMPLITUDE or self._spectrum_type == SpectrumTypes.DIGITS:
237
- self._dynamic_spectra_dBb = 10 * np.log10(self._dynamic_spectra / background_spectra)
238
- elif self._spectrum_type == SpectrumTypes.POWER:
239
- self._dynamic_spectra_dBb = 20 * np.log10(self._dynamic_spectra / background_spectra)
240
- else:
241
- raise NotImplementedError(f"{self.spectrum_type} unrecognised, uncertain decibel conversion!")
242
- return self._dynamic_spectra_dBb
243
-
244
-
245
- def set_background(self,
246
- start_background: str,
247
- end_background: str) -> None:
248
- """Public setter for start and end of the background"""
249
- self._dynamic_spectra_dBb = None # reset cache
250
- self._background_spectrum = None # reset cache
315
+ def compute_dynamic_spectra_dBb(
316
+ self
317
+ ) -> npt.NDArray[np.float32]:
318
+ """Compute the dynamic spectra in units of decibels above the background spectrum.
319
+
320
+ The computation applies logarithmic scaling based on the `spectrum_unit`.
321
+
322
+ :raises NotImplementedError: If the spectrum_unit is unrecognised.
323
+ :return: A 2D array with the same shape as `dynamic_spectra`, representing
324
+ the values in decibels above the background.
325
+ """
326
+ # Create a 'background' spectrogram where each spectrum is identically the background spectrum
327
+ background_spectrum = self.compute_background_spectrum()
328
+ background_spectra = background_spectrum[:, np.newaxis]
329
+ # Suppress divide by zero and invalid value warnings for this block of code
330
+ with np.errstate(divide='ignore', invalid='ignore'):
331
+ if self._spectrum_unit == SpectrumUnit.AMPLITUDE or self._spectrum_unit == SpectrumUnit.DIGITS:
332
+ dynamic_spectra_dBb = 10 * np.log10(self._dynamic_spectra / background_spectra)
333
+ elif self._spectrum_unit == SpectrumUnit.POWER:
334
+ dynamic_spectra_dBb = 20 * np.log10(self._dynamic_spectra / background_spectra)
335
+ else:
336
+ raise NotImplementedError(f"{self._spectrum_unit} is unrecognised; decibel conversion is uncertain!")
337
+ return dynamic_spectra_dBb.astype(np.float32)
338
+
339
+
340
+ def format_start_time(
341
+ self
342
+ ) -> str:
343
+ """Format the datetime assigned to the first spectrum in the dynamic spectra.
344
+
345
+ :return: A string representation of the `start_datetime`, up to seconds precision.
346
+ """
347
+ dt = self.start_datetime.astype(datetime)
348
+ return datetime.strftime(dt, TimeFormat.DATETIME)
349
+
350
+
351
+ def set_background(
352
+ self,
353
+ start_background: str,
354
+ end_background: str
355
+ ) -> None:
356
+ """Set the background interval for computing the background spectrum, and doing
357
+ background subtractions.
358
+
359
+ :param start_background: The start time of the background interval, formatted as
360
+ a string in the format `TimeFormat.DATETIME` (up to seconds precision).
361
+ :param end_background: The end time of the background interval, formatted as
362
+ a string in the format `TimeFormat.DATETIME` (up to seconds precision).
363
+ """
251
364
  self._start_background = start_background
252
- self._end_background = end_background
253
- self._update_background_indices_from_interval()
365
+ self._end_background = end_background
366
+ self._update_background_indices_from_interval(self._start_background,
367
+ self._end_background)
254
368
 
255
369
 
256
370
 
257
- def _update_background_indices_from_interval(self) -> None:
258
- start_background = datetime.strptime(self._start_background, TimeFormats.DATETIME)
259
- end_background = datetime.strptime(self._end_background, TimeFormats.DATETIME)
260
- self._start_background_index = find_closest_index(start_background,
371
+ def _update_background_indices_from_interval(
372
+ self,
373
+ start_background: str,
374
+ end_background: str
375
+ ) -> None:
376
+ start_background_datetime = np.datetime64( datetime.strptime(start_background, TimeFormat.DATETIME) )
377
+ end_background_datetime = np.datetime64( datetime.strptime(end_background, TimeFormat.DATETIME) )
378
+ self._start_background_index = find_closest_index(start_background_datetime,
261
379
  self.datetimes,
262
380
  enforce_strict_bounds=True)
263
- self._end_background_index = find_closest_index(end_background,
381
+ self._end_background_index = find_closest_index(end_background_datetime,
264
382
  self.datetimes,
265
383
  enforce_strict_bounds=True)
266
384
 
267
385
 
268
- def _check_shapes(self) -> None:
386
+ def _check_shapes(
387
+ self
388
+ ) -> None:
389
+ """Check that the data array shapes are consistent with one another."""
269
390
  num_spectrogram_dims = np.ndim(self._dynamic_spectra)
270
391
  # Check if 'dynamic_spectra' is a 2D array
271
392
  if num_spectrogram_dims != 2:
@@ -279,16 +400,28 @@ class Spectrogram:
279
400
  raise ValueError(f"Mismatch in number of time bins: Expected {self.num_times}, but got {dynamic_spectra_shape[1]}")
280
401
 
281
402
 
282
- def save(self) -> None:
283
- """Save the spectrogram as a fits file."""
403
+ def save(
404
+ self
405
+ ) -> None:
406
+ """Write the spectrogram and its associated metadata to a batch file in the FITS format."""
284
407
  _save_spectrogram(self)
285
408
 
286
409
 
287
- def integrate_over_frequency(self,
288
- correct_background: bool = False,
289
- peak_normalise: bool = False) -> np.ndarray[np.float32]:
290
- """Return the dynamic spectra, numerically integrated over frequency."""
291
- # integrate over frequency
410
+ def integrate_over_frequency(
411
+ self,
412
+ correct_background: bool = False,
413
+ peak_normalise: bool = False
414
+ ) -> npt.NDArray[np.float32]:
415
+ """Numerically integrate the spectrogram over the frequency axis.
416
+
417
+ :param correct_background: Indicates whether to subtract the background after
418
+ computing the integral, defaults to False
419
+ :param peak_normalise: Indicates whether to normalise the integral such that
420
+ the peak value is equal to unity, defaults to False
421
+ :return: A 1D array containing each spectrum numerically integrated over the
422
+ frequency axis.
423
+ """
424
+ # numerically integrate over frequency
292
425
  I = np.trapz(self._dynamic_spectra, self._frequencies, axis=0)
293
426
 
294
427
  if correct_background:
@@ -300,34 +433,46 @@ class Spectrogram:
300
433
  return I
301
434
 
302
435
 
303
- def get_frequency_cut(self,
304
- at_time: float | str,
305
- dBb: bool = False,
306
- peak_normalise: bool = False) -> FrequencyCut:
307
- """Get a cut of the dynamic spectra at a particular instant of time.
308
-
309
- It is important to note that the 'at_time' as specified at input may not correspond exactly
310
- to one of the times assigned to each spectrogram.
436
+ def get_frequency_cut(
437
+ self,
438
+ at_time: float | str,
439
+ dBb: bool = False,
440
+ peak_normalise: bool = False
441
+ ) -> FrequencyCut:
442
+ """Retrieve a cut of the dynamic spectra at a specific time.
443
+
444
+ If `at_time` does not match exactly with a time in `times`, the closest match
445
+ is selected. The cut represents one of the spectrums in the spectrogram.
446
+
447
+ :param at_time: The requested time for the cut. If a string, it is parsed
448
+ as a datetime. If a float, it is treated as elapsed time since the first spectrum.
449
+ :param dBb: If True, returns the cut in decibels above the background,
450
+ defaults to False.
451
+ :param peak_normalise: If True, normalises the cut such that its peak value
452
+ is equal to 1. Ignored if dBb is True, defaults to False.
453
+ :raises ValueError: If at_time is not a recognised type.
454
+ :return: A FrequencyCut object containing the spectral values and associated metadata.
311
455
  """
312
456
 
313
457
  if isinstance(at_time, str):
314
- at_time = datetime.strptime(at_time, TimeFormats.DATETIME)
315
- index_of_cut = find_closest_index(at_time,
316
- self.datetimes,
458
+ _at_datetime = np.datetime64( datetime.strptime(at_time, TimeFormat.DATETIME) )
459
+ index_of_cut = find_closest_index(_at_datetime,
460
+ self.datetimes,
317
461
  enforce_strict_bounds = True)
318
462
  time_of_cut = self.datetimes[index_of_cut]
319
463
 
320
- elif isinstance(at_time, (float, int)):
321
- index_of_cut = find_closest_index(at_time,
464
+ elif isinstance(at_time, float):
465
+ _at_time = np.float32(at_time)
466
+ index_of_cut = find_closest_index(_at_time,
322
467
  self._times,
323
468
  enforce_strict_bounds = True)
324
469
  time_of_cut = self.times[index_of_cut]
325
470
 
326
471
  else:
327
- raise ValueError(f"Type of at_time is unsupported: {type(at_time)}")
472
+ raise ValueError(f"'at_time' type '{type(at_time)}' is unsupported.")
328
473
 
329
474
  if dBb:
330
- ds = self.dynamic_spectra_dBb
475
+ ds = self.compute_dynamic_spectra_dBb()
331
476
  else:
332
477
  ds = self._dynamic_spectra
333
478
 
@@ -343,35 +488,44 @@ class Spectrogram:
343
488
  return FrequencyCut(time_of_cut,
344
489
  self._frequencies,
345
490
  cut,
346
- self._spectrum_type)
491
+ self._spectrum_unit)
347
492
 
348
493
 
349
- def get_time_cut(self,
350
- at_frequency: float,
351
- dBb: bool = False,
352
- peak_normalise = False,
353
- correct_background = False,
354
- return_time_type: str = TimeTypes.SECONDS) -> TimeCut:
355
- """Get a cut of the dynamic spectra at a particular frequency.
356
-
357
- It is important to note that the 'at_frequency' as specified at input may not correspond exactly
358
- to one of the times assigned to each spectrogram.
494
+ def get_time_cut(
495
+ self,
496
+ at_frequency: float,
497
+ dBb: bool = False,
498
+ peak_normalise = False,
499
+ correct_background = False,
500
+ return_time_type: TimeType = TimeType.RELATIVE
501
+ ) -> TimeCut:
502
+ """Retrieve a cut of the dynamic spectra at a specific frequency.
503
+
504
+ If `at_frequency` does not exactly match a frequency in `frequencies`, the
505
+ closest match is selected. The cut represents the time series of some spectral
506
+ component.
507
+
508
+ :param at_frequency: The requested frequency for the cut, in Hz.
509
+ :param dBb: If True, returns the cut in decibels above the background.
510
+ Defaults to False.
511
+ :param peak_normalise: If True, normalises the cut so its peak value is 1.
512
+ Ignored if dBb is True. Defaults to False.
513
+ :param correct_background: If True, subtracts the background from the cut.
514
+ Ignored if dBb is True. Defaults to False.
515
+ :param return_time_type: Specifies the type of time values in the cut
516
+ (TimeType.RELATIVE or TimeType.DATETIMES). Defaults to TimeType.RELATIVE.
517
+ :raises ValueError: If return_time_type is not recognised.
518
+ :return: A TimeCut object containing the temporal values and associated metadata.
359
519
  """
360
- index_of_cut = find_closest_index(at_frequency,
520
+ _at_frequency = np.float32(at_frequency)
521
+ index_of_cut = find_closest_index(_at_frequency,
361
522
  self._frequencies,
362
523
  enforce_strict_bounds = True)
363
- frequency_of_cut = self.frequencies[index_of_cut]
364
-
365
- if return_time_type == TimeTypes.DATETIMES:
366
- times = self.datetimes
367
- elif return_time_type == TimeTypes.SECONDS:
368
- times = self.times
369
- else:
370
- raise ValueError(f"Invalid return_time_type. Got {return_time_type}, expected one of 'datetimes' or 'seconds'")
524
+ frequency_of_cut = float( self.frequencies[index_of_cut] )
371
525
 
372
526
  # dependent on the requested cut type, we return the dynamic spectra in the preferred units
373
527
  if dBb:
374
- ds = self.dynamic_spectra_dBb
528
+ ds = self.compute_dynamic_spectra_dBb()
375
529
  else:
376
530
  ds = self.dynamic_spectra
377
531
 
@@ -392,24 +546,43 @@ class Spectrogram:
392
546
  if peak_normalise:
393
547
  cut = normalise_peak_intensity(cut)
394
548
 
395
- return TimeCut(frequency_of_cut,
396
- times,
397
- cut,
398
- self.spectrum_type)
549
+ if return_time_type == TimeType.DATETIMES:
550
+ return TimeCut(frequency_of_cut,
551
+ self.datetimes,
552
+ cut,
553
+ self.spectrum_unit)
554
+ elif return_time_type == TimeType.RELATIVE:
555
+ return TimeCut(frequency_of_cut,
556
+ self.times,
557
+ cut,
558
+ self.spectrum_unit)
559
+ else:
560
+ raise ValueError(f"Invalid return_time_type. Got {return_time_type}, "
561
+ f"expected one of 'datetimes' or 'seconds'")
562
+
399
563
 
400
564
 
401
- def _seconds_of_day(dt: datetime) -> float:
565
+ def _seconds_of_day(
566
+ dt: datetime
567
+ ) -> float:
402
568
  start_of_day = datetime(dt.year, dt.month, dt.day)
403
569
  return (dt - start_of_day).total_seconds()
404
570
 
405
571
 
406
572
  # Function to create a FITS file with the specified structure
407
- def _save_spectrogram(spectrogram: Spectrogram) -> None:
573
+ def _save_spectrogram(
574
+ spectrogram: Spectrogram
575
+ ) -> None:
576
+ """Save the input spectrogram and associated metadata to a fits file. Some metadata
577
+ will be fetched from the corresponding capture config.
408
578
 
579
+ :param spectrogram: The spectrogram containing the data to be saved.
580
+ """
581
+ dt = spectrogram.start_datetime.astype(datetime)
409
582
  # making the write path
410
- batch_parent_path = get_batches_dir_path(year = spectrogram.start_datetime.year,
411
- month = spectrogram.start_datetime.month,
412
- day = spectrogram.start_datetime.day)
583
+ batch_parent_path = get_batches_dir_path(year = dt.year,
584
+ month = dt.month,
585
+ day = dt.day)
413
586
  # file name formatted as a batch file
414
587
  file_name = f"{spectrogram.format_start_time()}_{spectrogram.tag}.fits"
415
588
  write_path = os.path.join(batch_parent_path,
@@ -417,13 +590,13 @@ def _save_spectrogram(spectrogram: Spectrogram) -> None:
417
590
 
418
591
  # get optional metadata from the capture config
419
592
  capture_config = CaptureConfig(spectrogram.tag)
420
- ORIGIN = capture_config.get_parameter_value(PNames.ORIGIN)
421
- INSTRUME = capture_config.get_parameter_value(PNames.INSTRUMENT)
422
- TELESCOP = capture_config.get_parameter_value(PNames.TELESCOPE)
423
- OBJECT = capture_config.get_parameter_value(PNames.OBJECT)
424
- OBS_ALT = capture_config.get_parameter_value(PNames.OBS_ALT)
425
- OBS_LAT = capture_config.get_parameter_value(PNames.OBS_LAT)
426
- OBS_LON = capture_config.get_parameter_value(PNames.OBS_LON)
593
+ ORIGIN = cast(str, capture_config.get_parameter_value(PName.ORIGIN))
594
+ INSTRUME = cast(str, capture_config.get_parameter_value(PName.INSTRUMENT))
595
+ TELESCOP = cast(str, capture_config.get_parameter_value(PName.TELESCOPE))
596
+ OBJECT = cast(str, capture_config.get_parameter_value(PName.OBJECT))
597
+ OBS_ALT = cast(float, capture_config.get_parameter_value(PName.OBS_ALT))
598
+ OBS_LAT = cast(float, capture_config.get_parameter_value(PName.OBS_LAT))
599
+ OBS_LON = cast(float, capture_config.get_parameter_value(PName.OBS_LON))
427
600
 
428
601
  # Primary HDU with data
429
602
  primary_data = spectrogram.dynamic_spectra.astype(dtype=np.float32)
@@ -448,13 +621,13 @@ def _save_spectrogram(spectrogram: Spectrogram) -> None:
448
621
  for comment in comments:
449
622
  primary_hdu.header.add_comment(comment)
450
623
 
451
- start_datetime = spectrogram.datetimes[0]
452
- start_date = start_datetime.strftime("%Y-%m-%d")
453
- start_time = start_datetime.strftime("%H:%M:%S.%f")
624
+ start_datetime = cast(datetime, spectrogram.datetimes[0].astype(datetime))
625
+ start_date = start_datetime.strftime("%Y-%m-%d")
626
+ start_time = start_datetime.strftime("%H:%M:%S.%f")
454
627
 
455
- end_datetime = spectrogram.datetimes[-1]
456
- end_date = end_datetime.strftime("%Y-%m-%d")
457
- end_time = end_datetime.strftime("%H:%M:%S.%f")
628
+ end_datetime = cast(datetime, spectrogram.datetimes[-1].astype(datetime))
629
+ end_date = end_datetime.strftime("%Y-%m-%d")
630
+ end_time = end_datetime.strftime("%H:%M:%S.%f")
458
631
 
459
632
  primary_hdu.header.set('DATE', start_date, 'time of observation')
460
633
  primary_hdu.header.set('CONTENT', f'{start_date} dynamic spectrogram', 'title of image')
@@ -470,12 +643,12 @@ def _save_spectrogram(spectrogram: Spectrogram) -> None:
470
643
 
471
644
  primary_hdu.header.set('BZERO', 0, 'scaling offset')
472
645
  primary_hdu.header.set('BSCALE', 1, 'scaling factor')
473
- primary_hdu.header.set('BUNIT', f"{spectrogram.spectrum_type}", 'z-axis title')
646
+ primary_hdu.header.set('BUNIT', f"{spectrogram.spectrum_unit.value}", 'z-axis title')
474
647
 
475
648
  primary_hdu.header.set('DATAMIN', np.nanmin(spectrogram.dynamic_spectra), 'minimum element in image')
476
649
  primary_hdu.header.set('DATAMAX', np.nanmax(spectrogram.dynamic_spectra), 'maximum element in image')
477
650
 
478
- primary_hdu.header.set('CRVAL1', f'{_seconds_of_day(start_datetime)}', 'value on axis 1 at reference pixel [sec of day]')
651
+ primary_hdu.header.set('CRVAL1', f'{_seconds_of_day( start_datetime)}', 'value on axis 1 at reference pixel [sec of day]')
479
652
  primary_hdu.header.set('CRPIX1', 0, 'reference pixel of axis 1')
480
653
  primary_hdu.header.set('CTYPE1', 'TIME [UT]', 'title of axis 1')
481
654
  primary_hdu.header.set('CDELT1', spectrogram.time_resolution, 'step between first and second element in x-axis')
@@ -519,4 +692,4 @@ def _save_spectrogram(spectrogram: Spectrogram) -> None:
519
692
 
520
693
  # Create HDU list and write to file
521
694
  hdul = fits.HDUList([primary_hdu, bin_table_hdu])
522
- hdul.writeto(write_path, overwrite=True)
695
+ hdul.writeto(write_path, overwrite=True)