spectre-core 0.0.22__py3-none-any.whl → 0.0.24__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 (78) hide show
  1. spectre_core/__init__.py +5 -0
  2. spectre_core/_file_io/__init__.py +4 -4
  3. spectre_core/_file_io/file_handlers.py +60 -106
  4. spectre_core/batches/__init__.py +20 -3
  5. spectre_core/batches/_base.py +85 -134
  6. spectre_core/batches/_batches.py +55 -99
  7. spectre_core/batches/_factory.py +21 -20
  8. spectre_core/batches/_register.py +8 -8
  9. spectre_core/batches/plugins/_batch_keys.py +7 -6
  10. spectre_core/batches/plugins/_callisto.py +65 -97
  11. spectre_core/batches/plugins/_iq_stream.py +105 -169
  12. spectre_core/capture_configs/__init__.py +46 -17
  13. spectre_core/capture_configs/_capture_config.py +25 -52
  14. spectre_core/capture_configs/_capture_modes.py +8 -6
  15. spectre_core/capture_configs/_capture_templates.py +50 -110
  16. spectre_core/capture_configs/_parameters.py +37 -74
  17. spectre_core/capture_configs/_pconstraints.py +40 -40
  18. spectre_core/capture_configs/_pnames.py +36 -34
  19. spectre_core/capture_configs/_ptemplates.py +260 -347
  20. spectre_core/capture_configs/_pvalidators.py +99 -102
  21. spectre_core/config/__init__.py +19 -8
  22. spectre_core/config/_paths.py +25 -47
  23. spectre_core/config/_time_formats.py +6 -5
  24. spectre_core/exceptions.py +38 -0
  25. spectre_core/jobs/__init__.py +3 -6
  26. spectre_core/jobs/_duration.py +12 -0
  27. spectre_core/jobs/_jobs.py +72 -43
  28. spectre_core/jobs/_workers.py +55 -105
  29. spectre_core/logs/__init__.py +7 -2
  30. spectre_core/logs/_configure.py +13 -17
  31. spectre_core/logs/_decorators.py +6 -4
  32. spectre_core/logs/_logs.py +37 -89
  33. spectre_core/logs/_process_types.py +5 -3
  34. spectre_core/plotting/__init__.py +19 -3
  35. spectre_core/plotting/_base.py +112 -177
  36. spectre_core/plotting/_format.py +10 -8
  37. spectre_core/plotting/_panel_names.py +7 -5
  38. spectre_core/plotting/_panel_stack.py +138 -130
  39. spectre_core/plotting/_panels.py +152 -162
  40. spectre_core/post_processing/__init__.py +6 -3
  41. spectre_core/post_processing/_base.py +41 -55
  42. spectre_core/post_processing/_factory.py +14 -11
  43. spectre_core/post_processing/_post_processor.py +16 -12
  44. spectre_core/post_processing/_register.py +10 -7
  45. spectre_core/post_processing/plugins/_event_handler_keys.py +4 -3
  46. spectre_core/post_processing/plugins/_fixed_center_frequency.py +54 -47
  47. spectre_core/post_processing/plugins/_swept_center_frequency.py +199 -174
  48. spectre_core/receivers/__init__.py +9 -2
  49. spectre_core/receivers/_base.py +82 -148
  50. spectre_core/receivers/_factory.py +20 -30
  51. spectre_core/receivers/_register.py +7 -10
  52. spectre_core/receivers/_spec_names.py +17 -15
  53. spectre_core/receivers/plugins/_b200mini.py +47 -60
  54. spectre_core/receivers/plugins/_receiver_names.py +8 -6
  55. spectre_core/receivers/plugins/_rsp1a.py +44 -40
  56. spectre_core/receivers/plugins/_rspduo.py +59 -44
  57. spectre_core/receivers/plugins/_sdrplay_receiver.py +67 -83
  58. spectre_core/receivers/plugins/_test.py +136 -129
  59. spectre_core/receivers/plugins/_usrp.py +93 -85
  60. spectre_core/receivers/plugins/gr/__init__.py +1 -1
  61. spectre_core/receivers/plugins/gr/_base.py +14 -22
  62. spectre_core/receivers/plugins/gr/_rsp1a.py +53 -60
  63. spectre_core/receivers/plugins/gr/_rspduo.py +77 -89
  64. spectre_core/receivers/plugins/gr/_test.py +49 -57
  65. spectre_core/receivers/plugins/gr/_usrp.py +61 -59
  66. spectre_core/spectrograms/__init__.py +21 -13
  67. spectre_core/spectrograms/_analytical.py +108 -99
  68. spectre_core/spectrograms/_array_operations.py +39 -46
  69. spectre_core/spectrograms/_spectrogram.py +293 -324
  70. spectre_core/spectrograms/_transform.py +106 -73
  71. spectre_core/wgetting/__init__.py +1 -3
  72. spectre_core/wgetting/_callisto.py +87 -93
  73. {spectre_core-0.0.22.dist-info → spectre_core-0.0.24.dist-info}/METADATA +9 -23
  74. spectre_core-0.0.24.dist-info/RECORD +79 -0
  75. {spectre_core-0.0.22.dist-info → spectre_core-0.0.24.dist-info}/WHEEL +1 -1
  76. spectre_core-0.0.22.dist-info/RECORD +0 -78
  77. {spectre_core-0.0.22.dist-info → spectre_core-0.0.24.dist-info}/licenses/LICENSE +0 -0
  78. {spectre_core-0.0.22.dist-info → spectre_core-0.0.24.dist-info}/top_level.txt +0 -0
@@ -26,27 +26,29 @@ from ._array_operations import (
26
26
 
27
27
  class SpectrumUnit(Enum):
28
28
  """A defined unit for dynamic spectra values.
29
-
29
+
30
30
  :ivar AMPLITUDE: Formal definition TBC.
31
31
  :ivar POWER: Formal definition TBC.
32
32
  :ivar DIGITS: Formal definition TBC.
33
33
  """
34
+
34
35
  AMPLITUDE = "amplitude"
35
- POWER = "power"
36
- DIGITS = "digits"
37
-
36
+ POWER = "power"
37
+ DIGITS = "digits"
38
+
38
39
 
39
40
  @dataclass
40
41
  class FrequencyCut:
41
- """A cut of a dynamic spectra, at a particular instant of time. Equivalently, some spectrum in
42
+ """A cut of a dynamic spectra, at a particular instant of time. Equivalently, some spectrum in
42
43
  the spectrogram.
43
-
44
+
44
45
  :ivar time: The time of the frequency cut, either in relative time (if time is a float)
45
46
  or as a datetime.
46
47
  :ivar frequencies: The physical frequencies assigned to each spectral component, in Hz.
47
48
  :ivar cut: The spectrum values.
48
49
  :ivar spectrum_unit: The unit of each spectrum value.
49
50
  """
51
+
50
52
  time: float | datetime
51
53
  frequencies: npt.NDArray[np.float32]
52
54
  cut: npt.NDArray[np.float32]
@@ -57,13 +59,14 @@ class FrequencyCut:
57
59
  class TimeCut:
58
60
  """A cut of a dynamic spectra, at some fixed frequency. Equivalently, a time series of
59
61
  some spectral component in the spectrogram.
60
-
62
+
61
63
  :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
64
+ :ivar times: The time for each time series value, either as a relative time (if
63
65
  the elements are floats) or as a datetimes.
64
66
  :ivar cut: The time series values of the spectral component.
65
67
  :ivar spectrum_unit: The unit of each time series value.
66
68
  """
69
+
67
70
  frequency: float
68
71
  times: npt.NDArray[np.float32 | np.datetime64]
69
72
  cut: npt.NDArray[np.float32]
@@ -72,27 +75,29 @@ class TimeCut:
72
75
 
73
76
  class TimeType(Enum):
74
77
  """The type of time we can assign to each spectrum in the dynamic spectra.
75
-
78
+
76
79
  :ivar RELATIVE: The elapsed time from the first spectrum, in seconds.
77
80
  :ivar DATETIMES: The datetime associated with each spectrum.
78
81
  """
79
- RELATIVE = "relative"
80
- DATETIMES = "datetimes"
82
+
83
+ RELATIVE = "relative"
84
+ DATETIMES = "datetimes"
81
85
 
82
86
 
83
87
  class Spectrogram:
84
88
  """Standardised wrapper for spectrogram data, providing a consistent
85
- interface for storing, accessing, and manipulating spectrogram data,
89
+ interface for storing, accessing, and manipulating spectrogram data,
86
90
  along with associated metadata.
87
91
  """
92
+
88
93
  def __init__(
89
- self,
94
+ self,
90
95
  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
+ times: npt.NDArray[np.float32],
97
+ frequencies: npt.NDArray[np.float32],
98
+ tag: str,
99
+ spectrum_unit: SpectrumUnit,
100
+ start_datetime: Optional[datetime | np.datetime64] = None,
96
101
  ) -> None:
97
102
  """Initialise a Spectrogram instance.
98
103
 
@@ -105,312 +110,263 @@ class Spectrogram:
105
110
  :param start_datetime: The datetime corresponding to the first spectrum, defaults to None.
106
111
  :raises ValueError: If times does not start at 0 or array shapes are inconsistent.
107
112
  """
108
-
113
+
109
114
  self._dynamic_spectra = dynamic_spectra
110
115
 
111
116
  if times[0] != 0:
112
117
  raise ValueError(f"The first spectrum must correspond to t=0")
113
118
  self._times = times
114
-
119
+
115
120
  self._frequencies = frequencies
116
121
  self._tag = tag
117
122
  self._spectrum_unit = spectrum_unit
118
- self._start_datetime = np.datetime64(start_datetime)
119
-
123
+ self._start_datetime = (
124
+ np.datetime64(start_datetime) if start_datetime is not None else None
125
+ )
126
+
120
127
  # by default, the background is evaluated over the whole spectrogram
121
- self._start_background_index = 0
122
- self._end_background_index = self.num_times
128
+ self._start_background_index = 0
129
+ self._end_background_index = self.num_times
123
130
  # the background interval can be set after instantiation
124
- self._start_background: Optional[str] = None
125
- self._end_background : Optional[str] = None
126
-
131
+ self._start_background: Optional[str] = None
132
+ self._end_background: Optional[str] = None
133
+
127
134
  # finally check that the spectrogram arrays are matching in shape
128
135
  self._check_shapes()
129
136
 
130
-
131
137
  @property
132
- def dynamic_spectra(
133
- self
134
- ) -> npt.NDArray[np.float32]:
138
+ def dynamic_spectra(self) -> npt.NDArray[np.float32]:
135
139
  """The dynamic spectra array.
136
-
137
- Returns the 2D array of spectrogram data with shape (num_frequencies, num_times),
140
+
141
+ Returns the 2D array of spectrogram data with shape (num_frequencies, num_times),
138
142
  where values are in the units specified by `spectrum_unit`.
139
143
  """
140
144
  return self._dynamic_spectra
141
-
142
145
 
143
146
  @property
144
- def times(
145
- self
146
- ) -> npt.NDArray[np.float32]:
147
+ def times(self) -> npt.NDArray[np.float32]:
147
148
  """A 1D array representing the elapsed time of each spectrum, in seconds, relative to the first
148
149
  in the spectrogram."""
149
150
  return self._times
150
-
151
-
151
+
152
152
  @property
153
- def num_times(
154
- self
155
- ) -> int:
153
+ def num_times(self) -> int:
156
154
  """The size of the times array. Equivalent to the number of spectrums in the spectrogram."""
157
155
  return len(self._times)
158
-
159
156
 
160
157
  @property
161
- def time_resolution(
162
- self
163
- ) -> float:
158
+ def time_resolution(self) -> float:
164
159
  """The time resolution of the dynamic spectra.
165
160
 
166
- Represents the spacing between consecutive time values in the times array,
161
+ Represents the spacing between consecutive time values in the times array,
167
162
  calculated as the median difference between adjacent elements.
168
163
  """
169
164
  return compute_resolution(self._times)
170
-
171
165
 
172
166
  @property
173
- def time_range(
174
- self
175
- ) -> float:
167
+ def time_range(self) -> float:
176
168
  """The time range of the dynamic spectra.
177
169
 
178
- Represents the difference between the first and last time values
170
+ Represents the difference between the first and last time values
179
171
  in the times array.
180
172
  """
181
173
  return compute_range(self._times)
182
-
183
174
 
184
175
  @property
185
- def frequencies(
186
- self
187
- ) -> npt.NDArray[np.float32]:
176
+ def frequencies(self) -> npt.NDArray[np.float32]:
188
177
  """A 1D array representing the physical frequencies assigned to each spectral component, in Hz."""
189
178
  return self._frequencies
190
179
 
191
-
192
180
  @property
193
- def num_frequencies(
194
- self
195
- ) -> int:
181
+ def num_frequencies(self) -> int:
196
182
  """The number of spectral components in the spectrogram.
197
183
 
198
184
  Equivalent to the size of the frequencies array.
199
185
  """
200
186
  return len(self._frequencies)
201
-
202
-
187
+
203
188
  @property
204
- def frequency_resolution(
205
- self
206
- ) -> float:
189
+ def frequency_resolution(self) -> float:
207
190
  """The frequency resolution of the dynamic spectra.
208
191
 
209
- Represents the spacing between consecutive frequency values in the frequencies array,
192
+ Represents the spacing between consecutive frequency values in the frequencies array,
210
193
  calculated as the median difference between adjacent elements.
211
194
  """
212
195
  return compute_resolution(self._frequencies)
213
-
214
196
 
215
197
  @property
216
- def frequency_range(
217
- self
218
- ) -> float:
198
+ def frequency_range(self) -> float:
219
199
  """The frequency range covered by the dynamic spectra.
220
200
 
221
- Represents the difference between the minimum and maximum frequency
201
+ Represents the difference between the minimum and maximum frequency
222
202
  values in the frequencies array.
223
203
  """
224
204
  return compute_range(self._frequencies)
225
-
226
205
 
227
206
  @property
228
- def tag(
229
- self
230
- ) -> str:
207
+ def tag(self) -> str:
231
208
  """The tag identifier for the spectrogram"""
232
209
  return self._tag
233
-
234
210
 
235
211
  @property
236
- def start_datetime_is_set(
237
- self
238
- ) -> bool:
212
+ def start_datetime_is_set(self) -> bool:
239
213
  """Indicates whether the start datetime for the spectrogram has been set."""
240
- return (self._start_datetime is not None)
241
-
242
-
214
+ return self._start_datetime is not None
215
+
243
216
  @property
244
- def start_datetime(
245
- self
246
- ) -> np.datetime64:
217
+ def start_datetime(self) -> np.datetime64:
247
218
  """The datetime assigned to the first spectrum in `dynamic_spectra`.
248
219
 
249
220
  :raises AttributeError: If the start_datetime has not been set.
250
221
  """
251
222
  if self._start_datetime is None:
252
- raise AttributeError(f"A start time has not been set.")
223
+ raise ValueError(f"A start time has not been set.")
253
224
  return self._start_datetime
254
-
255
-
225
+
256
226
  @property
257
- def datetimes(
258
- self
259
- ) -> npt.NDArray[np.datetime64]:
227
+ def datetimes(self) -> npt.NDArray[np.datetime64]:
260
228
  """The datetimes associated with each spectrum in `dynamic_spectra`.
261
229
 
262
- Returns a list of datetime objects, calculated by adding the elapsed
230
+ Returns a list of datetime objects, calculated by adding the elapsed
263
231
  times in the times array to the start_datetime.
264
232
  """
265
- return self.start_datetime + (1e6*self._times).astype('timedelta64[us]')
266
-
233
+ return self.start_datetime + (1e6 * self._times).astype("timedelta64[us]")
267
234
 
268
235
  @property
269
- def spectrum_unit(
270
- self
271
- ) -> SpectrumUnit:
236
+ def spectrum_unit(self) -> SpectrumUnit:
272
237
  """The units associated with the `dynamic_spectra` array."""
273
238
  return self._spectrum_unit
274
-
275
239
 
276
240
  @property
277
- def start_background(
278
- self
279
- ) -> Optional[str]:
241
+ def start_background(self) -> Optional[str]:
280
242
  """The start of the background interval.
281
243
 
282
- Returns a string-formatted datetime up to seconds precision, or None
244
+ Returns a string-formatted datetime up to seconds precision, or None
283
245
  if the background interval has not been set.
284
246
  """
285
247
  return self._start_background
286
-
287
248
 
288
249
  @property
289
- def end_background(
290
- self
291
- ) -> Optional[str]:
250
+ def end_background(self) -> Optional[str]:
292
251
  """The end of the background interval.
293
252
 
294
- Returns a string-formatted datetime up to seconds precision, or None
253
+ Returns a string-formatted datetime up to seconds precision, or None
295
254
  if the background interval has not been set.
296
255
  """
297
256
  return self._end_background
298
-
299
257
 
300
- def compute_background_spectrum(
301
- self
302
- ) -> npt.NDArray[np.float32]:
258
+ def compute_background_spectrum(self) -> npt.NDArray[np.float32]:
303
259
  """Compute the background spectrum by averaging the dynamic spectra in time.
304
260
 
305
- By default, the entire dynamic spectra is averaged. Use set_background to
261
+ By default, the entire dynamic spectra is averaged. Use set_background to
306
262
  specify a custom background interval.
307
263
 
308
- :return: A 1D array representing the time-averaged dynamic spectra over the
264
+ :return: A 1D array representing the time-averaged dynamic spectra over the
309
265
  specified background interval.
310
266
  """
311
- return np.nanmean(self._dynamic_spectra[:, self._start_background_index:self._end_background_index+1],
312
- axis=-1)
313
-
314
-
315
- def compute_dynamic_spectra_dBb(
316
- self
317
- ) -> npt.NDArray[np.float32]:
267
+ return np.nanmean(
268
+ self._dynamic_spectra[
269
+ :, self._start_background_index : self._end_background_index + 1
270
+ ],
271
+ axis=-1,
272
+ )
273
+
274
+ def compute_dynamic_spectra_dBb(self) -> npt.NDArray[np.float32]:
318
275
  """Compute the dynamic spectra in units of decibels above the background spectrum.
319
276
 
320
- The computation applies logarithmic scaling based on the `spectrum_unit`.
277
+ The computation applies logarithmic scaling based on the `spectrum_unit`.
321
278
 
322
279
  :raises NotImplementedError: If the spectrum_unit is unrecognised.
323
- :return: A 2D array with the same shape as `dynamic_spectra`, representing
280
+ :return: A 2D array with the same shape as `dynamic_spectra`, representing
324
281
  the values in decibels above the background.
325
282
  """
326
283
  # Create a 'background' spectrogram where each spectrum is identically the background spectrum
327
284
  background_spectrum = self.compute_background_spectrum()
328
285
  background_spectra = background_spectrum[:, np.newaxis]
329
286
  # 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)
287
+ with np.errstate(divide="ignore", invalid="ignore"):
288
+ if (
289
+ self._spectrum_unit == SpectrumUnit.AMPLITUDE
290
+ or self._spectrum_unit == SpectrumUnit.DIGITS
291
+ ):
292
+ dynamic_spectra_dBb = 10 * np.log10(
293
+ self._dynamic_spectra / background_spectra
294
+ )
333
295
  elif self._spectrum_unit == SpectrumUnit.POWER:
334
- dynamic_spectra_dBb = 20 * np.log10(self._dynamic_spectra / background_spectra)
296
+ dynamic_spectra_dBb = 20 * np.log10(
297
+ self._dynamic_spectra / background_spectra
298
+ )
335
299
  else:
336
- raise NotImplementedError(f"{self._spectrum_unit} is unrecognised; decibel conversion is uncertain!")
300
+ raise NotImplementedError(
301
+ f"{self._spectrum_unit} is unrecognised; decibel conversion is uncertain!"
302
+ )
337
303
  return dynamic_spectra_dBb.astype(np.float32)
338
-
339
-
340
- def format_start_time(
341
- self
342
- ) -> str:
304
+
305
+ def format_start_time(self) -> str:
343
306
  """Format the datetime assigned to the first spectrum in the dynamic spectra.
344
307
 
345
308
  :return: A string representation of the `start_datetime`, up to seconds precision.
346
309
  """
347
310
  dt = self.start_datetime.astype(datetime)
348
311
  return datetime.strftime(dt, TimeFormat.DATETIME)
349
-
350
-
351
- def set_background(
352
- self,
353
- start_background: str,
354
- end_background: str
355
- ) -> None:
312
+
313
+ def set_background(self, start_background: str, end_background: str) -> None:
356
314
  """Set the background interval for computing the background spectrum, and doing
357
315
  background subtractions.
358
316
 
359
- :param start_background: The start time of the background interval, formatted as
317
+ :param start_background: The start time of the background interval, formatted as
360
318
  a string in the format `TimeFormat.DATETIME` (up to seconds precision).
361
- :param end_background: The end time of the background interval, formatted as
319
+ :param end_background: The end time of the background interval, formatted as
362
320
  a string in the format `TimeFormat.DATETIME` (up to seconds precision).
363
321
  """
364
322
  self._start_background = start_background
365
- self._end_background = end_background
366
- self._update_background_indices_from_interval(self._start_background,
367
- self._end_background)
368
-
369
-
370
-
323
+ self._end_background = end_background
324
+ self._update_background_indices_from_interval(
325
+ self._start_background, self._end_background
326
+ )
327
+
371
328
  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,
379
- self.datetimes,
380
- enforce_strict_bounds=True)
381
- self._end_background_index = find_closest_index(end_background_datetime,
382
- self.datetimes,
383
- enforce_strict_bounds=True)
384
-
385
-
386
- def _check_shapes(
387
- self
329
+ self, start_background: str, end_background: str
388
330
  ) -> None:
331
+ start_background_datetime = np.datetime64(
332
+ datetime.strptime(start_background, TimeFormat.DATETIME)
333
+ )
334
+ end_background_datetime = np.datetime64(
335
+ datetime.strptime(end_background, TimeFormat.DATETIME)
336
+ )
337
+ self._start_background_index = find_closest_index(
338
+ start_background_datetime, self.datetimes, enforce_strict_bounds=True
339
+ )
340
+ self._end_background_index = find_closest_index(
341
+ end_background_datetime, self.datetimes, enforce_strict_bounds=True
342
+ )
343
+
344
+ def _check_shapes(self) -> None:
389
345
  """Check that the data array shapes are consistent with one another."""
390
346
  num_spectrogram_dims = np.ndim(self._dynamic_spectra)
391
347
  # Check if 'dynamic_spectra' is a 2D array
392
348
  if num_spectrogram_dims != 2:
393
- raise ValueError(f"Expected dynamic spectrogram to be a 2D array, but got {num_spectrogram_dims}D array")
349
+ raise ValueError(
350
+ f"Expected dynamic spectrogram to be a 2D array, but got {num_spectrogram_dims}D array"
351
+ )
394
352
  dynamic_spectra_shape = self.dynamic_spectra.shape
395
353
  # Check if the dimensions of 'dynamic_spectra' are consistent with the time and frequency arrays
396
354
  if dynamic_spectra_shape[0] != self.num_frequencies:
397
- raise ValueError(f"Mismatch in number of frequency bins: Expected {self.num_frequencies}, but got {dynamic_spectra_shape[0]}")
398
-
355
+ raise ValueError(
356
+ f"Mismatch in number of frequency bins: Expected {self.num_frequencies}, but got {dynamic_spectra_shape[0]}"
357
+ )
358
+
399
359
  if dynamic_spectra_shape[1] != self.num_times:
400
- raise ValueError(f"Mismatch in number of time bins: Expected {self.num_times}, but got {dynamic_spectra_shape[1]}")
401
-
360
+ raise ValueError(
361
+ f"Mismatch in number of time bins: Expected {self.num_times}, but got {dynamic_spectra_shape[1]}"
362
+ )
402
363
 
403
- def save(
404
- self
405
- ) -> None:
364
+ def save(self) -> None:
406
365
  """Write the spectrogram and its associated metadata to a batch file in the FITS format."""
407
366
  _save_spectrogram(self)
408
-
409
367
 
410
368
  def integrate_over_frequency(
411
- self,
412
- correct_background: bool = False,
413
- peak_normalise: bool = False
369
+ self, correct_background: bool = False, peak_normalise: bool = False
414
370
  ) -> npt.NDArray[np.float32]:
415
371
  """Numerically integrate the spectrogram over the frequency axis.
416
372
 
@@ -418,278 +374,291 @@ class Spectrogram:
418
374
  computing the integral, defaults to False
419
375
  :param peak_normalise: Indicates whether to normalise the integral such that
420
376
  the peak value is equal to unity, defaults to False
421
- :return: A 1D array containing each spectrum numerically integrated over the
377
+ :return: A 1D array containing each spectrum numerically integrated over the
422
378
  frequency axis.
423
379
  """
424
380
  # numerically integrate over frequency
425
381
  I = np.trapz(self._dynamic_spectra, self._frequencies, axis=0)
426
382
 
427
383
  if correct_background:
428
- I = subtract_background(I,
429
- self._start_background_index,
430
- self._end_background_index)
384
+ I = subtract_background(
385
+ I, self._start_background_index, self._end_background_index
386
+ )
431
387
  if peak_normalise:
432
388
  I = normalise_peak_intensity(I)
433
389
  return I
434
390
 
435
-
436
391
  def get_frequency_cut(
437
- self,
438
- at_time: float | str,
439
- dBb: bool = False,
440
- peak_normalise: bool = False
392
+ self, at_time: float | str, dBb: bool = False, peak_normalise: bool = False
441
393
  ) -> FrequencyCut:
442
394
  """Retrieve a cut of the dynamic spectra at a specific time.
443
395
 
444
- If `at_time` does not match exactly with a time in `times`, the closest match
396
+ If `at_time` does not match exactly with a time in `times`, the closest match
445
397
  is selected. The cut represents one of the spectrums in the spectrogram.
446
398
 
447
- :param at_time: The requested time for the cut. If a string, it is parsed
399
+ :param at_time: The requested time for the cut. If a string, it is parsed
448
400
  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,
401
+ :param dBb: If True, returns the cut in decibels above the background,
450
402
  defaults to False.
451
- :param peak_normalise: If True, normalises the cut such that its peak value
403
+ :param peak_normalise: If True, normalises the cut such that its peak value
452
404
  is equal to 1. Ignored if dBb is True, defaults to False.
453
405
  :raises ValueError: If at_time is not a recognised type.
454
406
  :return: A FrequencyCut object containing the spectral values and associated metadata.
455
407
  """
456
408
 
457
409
  if isinstance(at_time, str):
458
- _at_datetime = np.datetime64( datetime.strptime(at_time, TimeFormat.DATETIME) )
459
- index_of_cut = find_closest_index(_at_datetime,
460
- self.datetimes,
461
- enforce_strict_bounds = True)
462
- time_of_cut = self.datetimes[index_of_cut]
410
+ _at_datetime = np.datetime64(
411
+ datetime.strptime(at_time, TimeFormat.DATETIME)
412
+ )
413
+ index_of_cut = find_closest_index(
414
+ _at_datetime, self.datetimes, enforce_strict_bounds=True
415
+ )
416
+ time_of_cut = self.datetimes[index_of_cut]
463
417
 
464
418
  elif isinstance(at_time, float):
465
419
  _at_time = np.float32(at_time)
466
- index_of_cut = find_closest_index(_at_time,
467
- self._times,
468
- enforce_strict_bounds = True)
420
+ index_of_cut = find_closest_index(
421
+ _at_time, self._times, enforce_strict_bounds=True
422
+ )
469
423
  time_of_cut = self.times[index_of_cut]
470
-
424
+
471
425
  else:
472
426
  raise ValueError(f"'at_time' type '{type(at_time)}' is unsupported.")
473
-
427
+
474
428
  if dBb:
475
429
  ds = self.compute_dynamic_spectra_dBb()
476
430
  else:
477
431
  ds = self._dynamic_spectra
478
-
479
- cut = ds[:, index_of_cut].copy() # make a copy so to preserve the spectrum on transformations of the cut
432
+
433
+ cut = ds[
434
+ :, index_of_cut
435
+ ].copy() # make a copy so to preserve the spectrum on transformations of the cut
480
436
 
481
437
  if dBb:
482
438
  if peak_normalise:
483
- warn("Ignoring frequency cut normalisation, since dBb units have been specified")
439
+ warn(
440
+ "Ignoring frequency cut normalisation, since dBb units have been specified"
441
+ )
484
442
  else:
485
443
  if peak_normalise:
486
444
  cut = normalise_peak_intensity(cut)
487
-
488
- return FrequencyCut(time_of_cut,
489
- self._frequencies,
490
- cut,
491
- self._spectrum_unit)
492
445
 
493
-
446
+ return FrequencyCut(time_of_cut, self._frequencies, cut, self._spectrum_unit)
447
+
494
448
  def get_time_cut(
495
449
  self,
496
450
  at_frequency: float,
497
451
  dBb: bool = False,
498
- peak_normalise = False,
499
- correct_background = False,
500
- return_time_type: TimeType = TimeType.RELATIVE
452
+ peak_normalise=False,
453
+ correct_background=False,
454
+ return_time_type: TimeType = TimeType.RELATIVE,
501
455
  ) -> TimeCut:
502
456
  """Retrieve a cut of the dynamic spectra at a specific frequency.
503
457
 
504
- If `at_frequency` does not exactly match a frequency in `frequencies`, the
458
+ If `at_frequency` does not exactly match a frequency in `frequencies`, the
505
459
  closest match is selected. The cut represents the time series of some spectral
506
460
  component.
507
461
 
508
462
  :param at_frequency: The requested frequency for the cut, in Hz.
509
- :param dBb: If True, returns the cut in decibels above the background.
463
+ :param dBb: If True, returns the cut in decibels above the background.
510
464
  Defaults to False.
511
- :param peak_normalise: If True, normalises the cut so its peak value is 1.
465
+ :param peak_normalise: If True, normalises the cut so its peak value is 1.
512
466
  Ignored if dBb is True. Defaults to False.
513
- :param correct_background: If True, subtracts the background from the cut.
467
+ :param correct_background: If True, subtracts the background from the cut.
514
468
  Ignored if dBb is True. Defaults to False.
515
- :param return_time_type: Specifies the type of time values in the cut
469
+ :param return_time_type: Specifies the type of time values in the cut
516
470
  (TimeType.RELATIVE or TimeType.DATETIMES). Defaults to TimeType.RELATIVE.
517
471
  :raises ValueError: If return_time_type is not recognised.
518
472
  :return: A TimeCut object containing the temporal values and associated metadata.
519
473
  """
520
474
  _at_frequency = np.float32(at_frequency)
521
- index_of_cut = find_closest_index(_at_frequency,
522
- self._frequencies,
523
- enforce_strict_bounds = True)
524
- frequency_of_cut = float( self.frequencies[index_of_cut] )
475
+ index_of_cut = find_closest_index(
476
+ _at_frequency, self._frequencies, enforce_strict_bounds=True
477
+ )
478
+ frequency_of_cut = float(self.frequencies[index_of_cut])
525
479
 
526
480
  # dependent on the requested cut type, we return the dynamic spectra in the preferred units
527
481
  if dBb:
528
482
  ds = self.compute_dynamic_spectra_dBb()
529
483
  else:
530
484
  ds = self.dynamic_spectra
531
-
532
- cut = ds[index_of_cut,:].copy() # make a copy so to preserve the spectrum on transformations of the cut
485
+
486
+ cut = ds[
487
+ index_of_cut, :
488
+ ].copy() # make a copy so to preserve the spectrum on transformations of the cut
533
489
 
534
490
  # Warn if dBb is used with background correction or peak normalisation
535
491
  if dBb:
536
492
  if correct_background or peak_normalise:
537
- warn("Ignoring time cut normalisation, since dBb units have been specified")
493
+ warn(
494
+ "Ignoring time cut normalisation, since dBb units have been specified"
495
+ )
538
496
  else:
539
497
  # Apply background correction if required
540
498
  if correct_background:
541
- cut = subtract_background(cut,
542
- self._start_background_index,
543
- self._end_background_index)
544
-
499
+ cut = subtract_background(
500
+ cut, self._start_background_index, self._end_background_index
501
+ )
502
+
545
503
  # Apply peak normalisation if required
546
504
  if peak_normalise:
547
505
  cut = normalise_peak_intensity(cut)
548
506
 
549
507
  if return_time_type == TimeType.DATETIMES:
550
- return TimeCut(frequency_of_cut,
551
- self.datetimes,
552
- cut,
553
- self.spectrum_unit)
508
+ return TimeCut(frequency_of_cut, self.datetimes, cut, self.spectrum_unit)
554
509
  elif return_time_type == TimeType.RELATIVE:
555
- return TimeCut(frequency_of_cut,
556
- self.times,
557
- cut,
558
- self.spectrum_unit)
510
+ return TimeCut(frequency_of_cut, self.times, cut, self.spectrum_unit)
559
511
  else:
560
- raise ValueError(f"Invalid return_time_type. Got {return_time_type}, "
561
- f"expected one of 'datetimes' or 'seconds'")
512
+ raise ValueError(
513
+ f"Invalid return_time_type. Got {return_time_type}, "
514
+ f"expected one of 'datetimes' or 'seconds'"
515
+ )
562
516
 
563
-
564
517
 
565
- def _seconds_of_day(
566
- dt: datetime
567
- ) -> float:
518
+ def _seconds_of_day(dt: datetime) -> float:
568
519
  start_of_day = datetime(dt.year, dt.month, dt.day)
569
520
  return (dt - start_of_day).total_seconds()
570
521
 
571
522
 
572
523
  # Function to create a FITS file with the specified structure
573
- def _save_spectrogram(
574
- spectrogram: Spectrogram
575
- ) -> None:
524
+ def _save_spectrogram(spectrogram: Spectrogram) -> None:
576
525
  """Save the input spectrogram and associated metadata to a fits file. Some metadata
577
526
  will be fetched from the corresponding capture config.
578
-
527
+
579
528
  :param spectrogram: The spectrogram containing the data to be saved.
580
529
  """
581
530
  dt = spectrogram.start_datetime.astype(datetime)
582
531
  # making the write path
583
- batch_parent_path = get_batches_dir_path(year = dt.year,
584
- month = dt.month,
585
- day = dt.day)
532
+ batch_parent_path = get_batches_dir_path(year=dt.year, month=dt.month, day=dt.day)
586
533
  # file name formatted as a batch file
587
534
  file_name = f"{spectrogram.format_start_time()}_{spectrogram.tag}.fits"
588
- write_path = os.path.join(batch_parent_path,
589
- file_name)
590
-
535
+ write_path = os.path.join(batch_parent_path, file_name)
536
+
591
537
  # get optional metadata from the capture config
592
538
  capture_config = CaptureConfig(spectrogram.tag)
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))
600
-
539
+ ORIGIN = cast(str, capture_config.get_parameter_value(PName.ORIGIN))
540
+ INSTRUME = cast(str, capture_config.get_parameter_value(PName.INSTRUMENT))
541
+ TELESCOP = cast(str, capture_config.get_parameter_value(PName.TELESCOPE))
542
+ OBJECT = cast(str, capture_config.get_parameter_value(PName.OBJECT))
543
+ OBS_ALT = cast(float, capture_config.get_parameter_value(PName.OBS_ALT))
544
+ OBS_LAT = cast(float, capture_config.get_parameter_value(PName.OBS_LAT))
545
+ OBS_LON = cast(float, capture_config.get_parameter_value(PName.OBS_LON))
546
+
601
547
  # Primary HDU with data
602
- primary_data = spectrogram.dynamic_spectra.astype(dtype=np.float32)
548
+ primary_data = spectrogram.dynamic_spectra.astype(dtype=np.float32)
603
549
  primary_hdu = fits.PrimaryHDU(primary_data)
604
550
 
605
- primary_hdu.header.set('SIMPLE', True, 'file does conform to FITS standard')
606
- primary_hdu.header.set('BITPIX', -32, 'number of bits per data pixel')
607
- primary_hdu.header.set('NAXIS', 2, 'number of data axes')
608
- primary_hdu.header.set('NAXIS1', spectrogram.dynamic_spectra.shape[1], 'length of data axis 1')
609
- primary_hdu.header.set('NAXIS2', spectrogram.dynamic_spectra.shape[0], 'length of data axis 2')
610
- primary_hdu.header.set('EXTEND', True, 'FITS dataset may contain extensions')
551
+ primary_hdu.header.set("SIMPLE", True, "file does conform to FITS standard")
552
+ primary_hdu.header.set("BITPIX", -32, "number of bits per data pixel")
553
+ primary_hdu.header.set("NAXIS", 2, "number of data axes")
554
+ primary_hdu.header.set(
555
+ "NAXIS1", spectrogram.dynamic_spectra.shape[1], "length of data axis 1"
556
+ )
557
+ primary_hdu.header.set(
558
+ "NAXIS2", spectrogram.dynamic_spectra.shape[0], "length of data axis 2"
559
+ )
560
+ primary_hdu.header.set("EXTEND", True, "FITS dataset may contain extensions")
611
561
 
612
562
  # Add comments
613
563
  comments = [
614
564
  "FITS (Flexible Image Transport System) format defined in Astronomy and",
615
565
  "Astrophysics Supplement Series v44/p363, v44/p371, v73/p359, v73/p365.",
616
566
  "Contact the NASA Science Office of Standards and Technology for the",
617
- "FITS Definition document #100 and other FITS information."
567
+ "FITS Definition document #100 and other FITS information.",
618
568
  ]
619
-
569
+
620
570
  # The comments section remains unchanged since add_comment is the correct approach
621
571
  for comment in comments:
622
572
  primary_hdu.header.add_comment(comment)
623
573
 
624
574
  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")
627
-
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")
631
-
632
- primary_hdu.header.set('DATE', start_date, 'time of observation')
633
- primary_hdu.header.set('CONTENT', f'{start_date} dynamic spectrogram', 'title of image')
634
- primary_hdu.header.set('ORIGIN', f'{ORIGIN}')
635
- primary_hdu.header.set('TELESCOP', f'{TELESCOP}', 'type of instrument')
636
- primary_hdu.header.set('INSTRUME', f'{INSTRUME}')
637
- primary_hdu.header.set('OBJECT', f'{OBJECT}', 'object description')
638
-
639
- primary_hdu.header.set('DATE-OBS', f'{start_date}', 'date observation starts')
640
- primary_hdu.header.set('TIME-OBS', f'{start_time}', 'time observation starts')
641
- primary_hdu.header.set('DATE-END', f'{end_date}', 'date observation ends')
642
- primary_hdu.header.set('TIME-END', f'{end_time}', 'time observation ends')
643
-
644
- primary_hdu.header.set('BZERO', 0, 'scaling offset')
645
- primary_hdu.header.set('BSCALE', 1, 'scaling factor')
646
- primary_hdu.header.set('BUNIT', f"{spectrogram.spectrum_unit.value}", 'z-axis title')
647
-
648
- primary_hdu.header.set('DATAMIN', np.nanmin(spectrogram.dynamic_spectra), 'minimum element in image')
649
- primary_hdu.header.set('DATAMAX', np.nanmax(spectrogram.dynamic_spectra), 'maximum element in image')
650
-
651
- primary_hdu.header.set('CRVAL1', f'{_seconds_of_day( start_datetime)}', 'value on axis 1 at reference pixel [sec of day]')
652
- primary_hdu.header.set('CRPIX1', 0, 'reference pixel of axis 1')
653
- primary_hdu.header.set('CTYPE1', 'TIME [UT]', 'title of axis 1')
654
- primary_hdu.header.set('CDELT1', spectrogram.time_resolution, 'step between first and second element in x-axis')
655
-
656
- primary_hdu.header.set('CRVAL2', 0, 'value on axis 2 at reference pixel')
657
- primary_hdu.header.set('CRPIX2', 0, 'reference pixel of axis 2')
658
- primary_hdu.header.set('CTYPE2', 'Frequency [Hz]', 'title of axis 2')
659
- primary_hdu.header.set('CDELT2', spectrogram.frequency_resolution, 'step between first and second element in axis')
660
-
661
- primary_hdu.header.set('OBS_LAT', f'{OBS_LAT}', 'observatory latitude in degree')
662
- primary_hdu.header.set('OBS_LAC', 'N', 'observatory latitude code {N,S}')
663
- primary_hdu.header.set('OBS_LON', f'{OBS_LON}', 'observatory longitude in degree')
664
- primary_hdu.header.set('OBS_LOC', 'W', 'observatory longitude code {E,W}')
665
- primary_hdu.header.set('OBS_ALT', f'{OBS_ALT}', 'observatory altitude in meter asl')
666
-
575
+ start_date = start_datetime.strftime("%Y-%m-%d")
576
+ start_time = start_datetime.strftime("%H:%M:%S.%f")
577
+
578
+ end_datetime = cast(datetime, spectrogram.datetimes[-1].astype(datetime))
579
+ end_date = end_datetime.strftime("%Y-%m-%d")
580
+ end_time = end_datetime.strftime("%H:%M:%S.%f")
581
+
582
+ primary_hdu.header.set("DATE", start_date, "time of observation")
583
+ primary_hdu.header.set(
584
+ "CONTENT", f"{start_date} dynamic spectrogram", "title of image"
585
+ )
586
+ primary_hdu.header.set("ORIGIN", f"{ORIGIN}")
587
+ primary_hdu.header.set("TELESCOP", f"{TELESCOP}", "type of instrument")
588
+ primary_hdu.header.set("INSTRUME", f"{INSTRUME}")
589
+ primary_hdu.header.set("OBJECT", f"{OBJECT}", "object description")
590
+
591
+ primary_hdu.header.set("DATE-OBS", f"{start_date}", "date observation starts")
592
+ primary_hdu.header.set("TIME-OBS", f"{start_time}", "time observation starts")
593
+ primary_hdu.header.set("DATE-END", f"{end_date}", "date observation ends")
594
+ primary_hdu.header.set("TIME-END", f"{end_time}", "time observation ends")
595
+
596
+ primary_hdu.header.set("BZERO", 0, "scaling offset")
597
+ primary_hdu.header.set("BSCALE", 1, "scaling factor")
598
+ primary_hdu.header.set(
599
+ "BUNIT", f"{spectrogram.spectrum_unit.value}", "z-axis title"
600
+ )
601
+
602
+ primary_hdu.header.set(
603
+ "DATAMIN", np.nanmin(spectrogram.dynamic_spectra), "minimum element in image"
604
+ )
605
+ primary_hdu.header.set(
606
+ "DATAMAX", np.nanmax(spectrogram.dynamic_spectra), "maximum element in image"
607
+ )
608
+
609
+ primary_hdu.header.set(
610
+ "CRVAL1",
611
+ f"{_seconds_of_day( start_datetime)}",
612
+ "value on axis 1 at reference pixel [sec of day]",
613
+ )
614
+ primary_hdu.header.set("CRPIX1", 0, "reference pixel of axis 1")
615
+ primary_hdu.header.set("CTYPE1", "TIME [UT]", "title of axis 1")
616
+ primary_hdu.header.set(
617
+ "CDELT1",
618
+ spectrogram.time_resolution,
619
+ "step between first and second element in x-axis",
620
+ )
621
+
622
+ primary_hdu.header.set("CRVAL2", 0, "value on axis 2 at reference pixel")
623
+ primary_hdu.header.set("CRPIX2", 0, "reference pixel of axis 2")
624
+ primary_hdu.header.set("CTYPE2", "Frequency [Hz]", "title of axis 2")
625
+ primary_hdu.header.set(
626
+ "CDELT2",
627
+ spectrogram.frequency_resolution,
628
+ "step between first and second element in axis",
629
+ )
630
+
631
+ primary_hdu.header.set("OBS_LAT", f"{OBS_LAT}", "observatory latitude in degree")
632
+ primary_hdu.header.set("OBS_LAC", "N", "observatory latitude code {N,S}")
633
+ primary_hdu.header.set("OBS_LON", f"{OBS_LON}", "observatory longitude in degree")
634
+ primary_hdu.header.set("OBS_LOC", "W", "observatory longitude code {E,W}")
635
+ primary_hdu.header.set("OBS_ALT", f"{OBS_ALT}", "observatory altitude in meter asl")
667
636
 
668
637
  # Wrap arrays in an additional dimension to mimic the e-CALLISTO storage
669
638
  times_wrapped = np.array([spectrogram.times.astype(np.float32)])
670
639
  # To mimic e-Callisto storage, convert frequencies to MHz
671
640
  frequencies_MHz = spectrogram.frequencies * 1e-6
672
641
  frequencies_wrapped = np.array([frequencies_MHz.astype(np.float32)])
673
-
642
+
674
643
  # Binary Table HDU (extension)
675
- col1 = fits.Column(name='TIME', format='PD', array=times_wrapped)
676
- col2 = fits.Column(name='FREQUENCY', format='PD', array=frequencies_wrapped)
644
+ col1 = fits.Column(name="TIME", format="PD", array=times_wrapped)
645
+ col2 = fits.Column(name="FREQUENCY", format="PD", array=frequencies_wrapped)
677
646
  cols = fits.ColDefs([col1, col2])
678
647
 
679
648
  bin_table_hdu = fits.BinTableHDU.from_columns(cols)
680
649
 
681
- bin_table_hdu.header.set('PCOUNT', 0, 'size of special data area')
682
- bin_table_hdu.header.set('GCOUNT', 1, 'one data group (required keyword)')
683
- bin_table_hdu.header.set('TFIELDS', 2, 'number of fields in each row')
684
- bin_table_hdu.header.set('TTYPE1', 'TIME', 'label for field 1')
685
- bin_table_hdu.header.set('TFORM1', 'D', 'data format of field: 8-byte DOUBLE')
686
- bin_table_hdu.header.set('TTYPE2', 'FREQUENCY', 'label for field 2')
687
- bin_table_hdu.header.set('TFORM2', 'D', 'data format of field: 8-byte DOUBLE')
688
- bin_table_hdu.header.set('TSCAL1', 1, '')
689
- bin_table_hdu.header.set('TZERO1', 0, '')
690
- bin_table_hdu.header.set('TSCAL2', 1, '')
691
- bin_table_hdu.header.set('TZERO2', 0, '')
650
+ bin_table_hdu.header.set("PCOUNT", 0, "size of special data area")
651
+ bin_table_hdu.header.set("GCOUNT", 1, "one data group (required keyword)")
652
+ bin_table_hdu.header.set("TFIELDS", 2, "number of fields in each row")
653
+ bin_table_hdu.header.set("TTYPE1", "TIME", "label for field 1")
654
+ bin_table_hdu.header.set("TFORM1", "D", "data format of field: 8-byte DOUBLE")
655
+ bin_table_hdu.header.set("TTYPE2", "FREQUENCY", "label for field 2")
656
+ bin_table_hdu.header.set("TFORM2", "D", "data format of field: 8-byte DOUBLE")
657
+ bin_table_hdu.header.set("TSCAL1", 1, "")
658
+ bin_table_hdu.header.set("TZERO1", 0, "")
659
+ bin_table_hdu.header.set("TSCAL2", 1, "")
660
+ bin_table_hdu.header.set("TZERO2", 0, "")
692
661
 
693
662
  # Create HDU list and write to file
694
663
  hdul = fits.HDUList([primary_hdu, bin_table_hdu])
695
- hdul.writeto(write_path, overwrite=True)
664
+ hdul.writeto(write_path, overwrite=True)