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,461 @@
1
+ # SPDX-FileCopyrightText: © 2024 Jimmy Fitzpatrick <jcfitzpatrick12@gmail.com>
2
+ # This file is part of SPECTRE
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import os
6
+ from typing import Optional, Any
7
+ from warnings import warn
8
+ from datetime import datetime, timedelta
9
+ from dataclasses import dataclass
10
+
11
+ import numpy as np
12
+ from astropy.io import fits
13
+
14
+ from spectre_core.file_handlers.configs import FitsConfig
15
+ from spectre_core.cfg import DEFAULT_DATETIME_FORMAT, get_chunks_dir_path
16
+ from spectre_core.spectrograms.array_operations import (
17
+ find_closest_index,
18
+ normalise_peak_intensity,
19
+ compute_resolution,
20
+ compute_range,
21
+ subtract_background,
22
+ )
23
+
24
+ @dataclass
25
+ class FrequencyCut:
26
+ time: float | datetime
27
+ frequencies: np.ndarray
28
+ cut: np.ndarray
29
+ spectrum_type: str
30
+
31
+
32
+ @dataclass
33
+ class TimeCut:
34
+ frequency: float
35
+ times: np.ndarray
36
+ cut: np.ndarray
37
+ spectrum_type: str
38
+
39
+
40
+ class Spectrogram:
41
+ def __init__(self,
42
+ dynamic_spectra: np.ndarray, # holds the spectrogram data
43
+ times: np.ndarray, # holds the time stamp [s] for each spectrum
44
+ frequencies: np.ndarray, # physical frequencies [Hz] for each spectral component
45
+ tag: str,
46
+ chunk_start_time: Optional[str] = None,
47
+ microsecond_correction: int = 0,
48
+ spectrum_type: Optional[str] = None,
49
+ start_background: Optional[str] = None,
50
+ end_background: Optional[str] = None):
51
+
52
+ # dynamic spectra
53
+ self._dynamic_spectra = dynamic_spectra
54
+ self._dynamic_spectra_as_dBb: Optional[np.ndarray] = None # cache
55
+
56
+ # assigned times and frequencies
57
+ self._times = times
58
+ self._datetimes: Optional[list[datetime]] = None # cache
59
+ self._frequencies = frequencies
60
+
61
+ # general metadata
62
+ self._tag = tag
63
+ self._chunk_start_time = chunk_start_time
64
+ self._chunk_start_datetime: Optional[datetime] = None # cache
65
+ self._microsecond_correction = microsecond_correction
66
+ self._spectrum_type = spectrum_type
67
+
68
+ # background metadata
69
+ self._background_spectrum: Optional[np.ndarray] = None # cache
70
+ self._start_background = start_background
71
+ self._end_background = end_background
72
+ self._start_background_index = 0 # by default
73
+ self._end_background_index = self.num_times # by default
74
+ self._check_shapes()
75
+
76
+
77
+ @property
78
+ def dynamic_spectra(self) -> np.ndarray:
79
+ return self._dynamic_spectra
80
+
81
+
82
+ @property
83
+ def times(self) -> np.ndarray:
84
+ return self._times
85
+
86
+
87
+ @property
88
+ def num_times(self) -> int:
89
+ return len(self._times)
90
+
91
+
92
+ @property
93
+ def time_resolution(self) -> float:
94
+ return compute_resolution(self._times)
95
+
96
+
97
+ @property
98
+ def time_range(self) -> float:
99
+ return compute_range(self._times)
100
+
101
+
102
+ @property
103
+ def datetimes(self) -> list[datetime]:
104
+ if self._datetimes is None:
105
+ self._datetimes = [self.chunk_start_datetime + timedelta(seconds=(t + self.microsecond_correction*1e-6)) for t in self._times]
106
+ return self._datetimes
107
+
108
+
109
+ @property
110
+ def frequencies(self) -> np.ndarray:
111
+ return self._frequencies
112
+
113
+
114
+ @property
115
+ def num_frequencies(self) -> int:
116
+ return len(self._frequencies)
117
+
118
+
119
+ @property
120
+ def frequency_resolution(self) -> float:
121
+ return compute_resolution(self._frequencies)
122
+
123
+
124
+ @property
125
+ def frequency_range(self) -> float:
126
+ return compute_range(self._frequencies)
127
+
128
+
129
+ @property
130
+ def tag(self) -> str:
131
+ return self._tag
132
+
133
+
134
+ @property
135
+ def chunk_start_time(self) -> str:
136
+ if self._chunk_start_time is None:
137
+ raise AttributeError(f"Chunk start time has not been set.")
138
+ return self._chunk_start_time
139
+
140
+
141
+ @property
142
+ def chunk_start_datetime(self) -> datetime:
143
+ if self._chunk_start_datetime is None:
144
+ self._chunk_start_datetime = datetime.strptime(self.chunk_start_time, DEFAULT_DATETIME_FORMAT)
145
+ return self._chunk_start_datetime
146
+
147
+
148
+ @property
149
+ def microsecond_correction(self) -> int:
150
+ return self._microsecond_correction
151
+
152
+
153
+ @property
154
+ def spectrum_type(self) -> Optional[str]:
155
+ return self._spectrum_type
156
+
157
+
158
+ @property
159
+ def start_background(self) -> Optional[str]:
160
+ return self._start_background
161
+
162
+
163
+ @property
164
+ def end_background(self) -> Optional[str]:
165
+ return self._end_background
166
+
167
+
168
+ @property
169
+ def background_spectrum(self) -> np.ndarray:
170
+ if self._background_spectrum is None:
171
+ self._background_spectrum = np.nanmean(self._dynamic_spectra[:, self._start_background_index:self._end_background_index+1],
172
+ axis=-1)
173
+ return self._background_spectrum
174
+
175
+
176
+ @property
177
+ def dynamic_spectra_as_dBb(self) -> np.ndarray:
178
+ if self._dynamic_spectra_as_dBb is None:
179
+ # Create an artificial spectrogram where each spectrum is identically the background spectrum
180
+ background_spectra = self.background_spectrum[:, np.newaxis]
181
+ # Suppress divide by zero and invalid value warnings for this block of code
182
+ with np.errstate(divide='ignore'):
183
+ # Depending on the spectrum type, compute the dBb values differently
184
+ if self._spectrum_type == "amplitude" or self._spectrum_type == "digits":
185
+ self._dynamic_spectra_as_dBb = 10 * np.log10(self._dynamic_spectra / background_spectra)
186
+ elif self._spectrum_type == "power":
187
+ self._dynamic_spectra_as_dBb = 20 * np.log10(self._dynamic_spectra / background_spectra)
188
+ else:
189
+ raise NotImplementedError(f"{self.spectrum_type} unrecognised, uncertain decibel conversion!")
190
+ return self._dynamic_spectra_as_dBb
191
+
192
+
193
+ def set_background(self,
194
+ start_background: str,
195
+ end_background: str) -> None:
196
+ """Public setter for start and end of the background"""
197
+ self._dynamic_spectra_as_dBb = None # reset cache
198
+ self._background_spectrum = None # reset cache
199
+ self._start_background = start_background
200
+ self._end_background = end_background
201
+ self._update_background_indices_from_interval()
202
+
203
+
204
+
205
+ def _update_background_indices_from_interval(self) -> None:
206
+ start_background = datetime.strptime(self._start_background, DEFAULT_DATETIME_FORMAT)
207
+ self._start_background_index = find_closest_index(start_background,
208
+ self.datetimes,
209
+ enforce_strict_bounds=True)
210
+
211
+ end_background = datetime.strptime(self._end_background, DEFAULT_DATETIME_FORMAT)
212
+ self._end_background_index = find_closest_index(end_background,
213
+ self.datetimes,
214
+ enforce_strict_bounds=True)
215
+
216
+
217
+ def _check_shapes(self) -> None:
218
+ num_spectrogram_dims = np.ndim(self._dynamic_spectra)
219
+ # Check if 'dynamic_spectra' is a 2D array
220
+ if num_spectrogram_dims != 2:
221
+ raise ValueError(f"Expected dynamic spectrogram to be a 2D array, but got {num_spectrogram_dims}D array")
222
+ dynamic_spectra_shape = self.dynamic_spectra.shape
223
+ # Check if the dimensions of 'dynamic_spectra' are consistent with the time and frequency arrays
224
+ if dynamic_spectra_shape[0] != self.num_frequencies:
225
+ raise ValueError(f"Mismatch in number of frequency bins: Expected {self.num_frequencies}, but got {dynamic_spectra_shape[0]}")
226
+
227
+ if dynamic_spectra_shape[1] != self.num_times:
228
+ raise ValueError(f"Mismatch in number of time bins: Expected {self.num_times}, but got {dynamic_spectra_shape[1]}")
229
+
230
+
231
+ def save(self) -> None:
232
+ fits_config = FitsConfig(self._tag)
233
+ fits_config = fits_config if fits_config.exists else {}
234
+
235
+ chunk_start_datetime = self.chunk_start_datetime
236
+ chunk_parent_path = get_chunks_dir_path(year = chunk_start_datetime.year,
237
+ month = chunk_start_datetime.month,
238
+ day = chunk_start_datetime.day)
239
+ file_name = f"{self.chunk_start_time}_{self._tag}.fits"
240
+ write_path = os.path.join(chunk_parent_path, file_name)
241
+ _save_spectrogram(write_path, self, fits_config)
242
+
243
+
244
+ def integrate_over_frequency(self,
245
+ correct_background: bool = False,
246
+ peak_normalise: bool = False) -> np.ndarray[np.float32]:
247
+
248
+ # integrate over frequency
249
+ I = np.trapz(self._dynamic_spectra, self._frequencies, axis=0)
250
+
251
+ if correct_background:
252
+ I = subtract_background(I,
253
+ self._start_background_index,
254
+ self._end_background_index)
255
+ if peak_normalise:
256
+ I = normalise_peak_intensity(I)
257
+ return I
258
+
259
+
260
+ def get_frequency_cut(self,
261
+ at_time: float | str,
262
+ dBb: bool = False,
263
+ peak_normalise: bool = False) -> FrequencyCut:
264
+
265
+ # it is important to note that the "at time" specified by the user likely does not correspond
266
+ # exactly to one of the times assigned to each spectrogram. So, we compute the nearest achievable,
267
+ # and return it from the function as output too.
268
+ if isinstance(at_time, str):
269
+ at_time = datetime.strptime(at_time, DEFAULT_DATETIME_FORMAT)
270
+ index_of_cut = find_closest_index(at_time,
271
+ self.datetimes,
272
+ enforce_strict_bounds = True)
273
+ time_of_cut = self.datetimes[index_of_cut]
274
+
275
+ elif isinstance(at_time, (float, int)):
276
+ index_of_cut = find_closest_index(at_time,
277
+ self._times,
278
+ enforce_strict_bounds = True)
279
+ time_of_cut = self.times[index_of_cut]
280
+
281
+ else:
282
+ raise ValueError(f"Type of at_time is unsupported: {type(at_time)}")
283
+
284
+ if dBb:
285
+ ds = self.dynamic_spectra_as_dBb
286
+ else:
287
+ ds = self._dynamic_spectra
288
+
289
+ cut = ds[:, index_of_cut].copy() # make a copy so to preserve the spectrum on transformations of the cut
290
+
291
+ if dBb:
292
+ if peak_normalise:
293
+ warn("Ignoring frequency cut normalisation, since dBb units have been specified")
294
+ else:
295
+ if peak_normalise:
296
+ cut = normalise_peak_intensity(cut)
297
+
298
+ return FrequencyCut(time_of_cut,
299
+ self._frequencies,
300
+ cut,
301
+ self._spectrum_type)
302
+
303
+
304
+ def get_time_cut(self,
305
+ at_frequency: float,
306
+ dBb: bool = False,
307
+ peak_normalise = False,
308
+ correct_background = False,
309
+ return_time_type: str = "seconds") -> TimeCut:
310
+
311
+ # it is important to note that the "at frequency" specified by the user likely does not correspond
312
+ # exactly to one of the physical frequencies assigned to each spectral component. So, we compute the nearest achievable,
313
+ # and return it from the function as output too.
314
+ index_of_cut = find_closest_index(at_frequency,
315
+ self._frequencies,
316
+ enforce_strict_bounds = True)
317
+ frequency_of_cut = self.frequencies[index_of_cut]
318
+
319
+ if return_time_type == "datetimes":
320
+ times = self.datetimes
321
+ elif return_time_type == "seconds":
322
+ times = self.times
323
+ else:
324
+ raise ValueError(f"Invalid return_time_type. Got {return_time_type}, expected one of 'datetimes' or 'seconds'")
325
+
326
+ # dependent on the requested cut type, we return the dynamic spectra in the preferred units
327
+ if dBb:
328
+ ds = self.dynamic_spectra_as_dBb
329
+ else:
330
+ ds = self.dynamic_spectra
331
+
332
+ cut = ds[index_of_cut,:].copy() # make a copy so to preserve the spectrum on transformations of the cut
333
+
334
+ # Warn if dBb is used with background correction or peak normalisation
335
+ if dBb:
336
+ if correct_background or peak_normalise:
337
+ warn("Ignoring time cut normalisation, since dBb units have been specified")
338
+ else:
339
+ # Apply background correction if required
340
+ if correct_background:
341
+ cut = subtract_background(cut,
342
+ self._start_background_index,
343
+ self._end_background_index)
344
+
345
+ # Apply peak normalisation if required
346
+ if peak_normalise:
347
+ cut = normalise_peak_intensity(cut)
348
+
349
+ return TimeCut(frequency_of_cut,
350
+ times,
351
+ cut,
352
+ self.spectrum_type)
353
+
354
+
355
+ def _seconds_of_day(dt: datetime) -> float:
356
+ start_of_day = datetime(dt.year, dt.month, dt.day)
357
+ return (dt - start_of_day).total_seconds()
358
+
359
+
360
+ # Function to create a FITS file with the specified structure
361
+ def _save_spectrogram(write_path: str,
362
+ spectrogram: Spectrogram,
363
+ fits_config: FitsConfig | dict[str, Any]) -> None:
364
+ if spectrogram.chunk_start_time is None:
365
+ raise ValueError(f"Spectrogram must have a defined chunk_start_time. Received {spectrogram.chunk_start_time}")
366
+
367
+ # Primary HDU with data
368
+ primary_data = spectrogram.dynamic_spectra.astype(dtype=np.float32)
369
+ primary_hdu = fits.PrimaryHDU(primary_data)
370
+
371
+ primary_hdu.header.set('SIMPLE', True, 'file does conform to FITS standard')
372
+ primary_hdu.header.set('BITPIX', -32, 'number of bits per data pixel')
373
+ primary_hdu.header.set('NAXIS', 2, 'number of data axes')
374
+ primary_hdu.header.set('NAXIS1', spectrogram.dynamic_spectra.shape[1], 'length of data axis 1')
375
+ primary_hdu.header.set('NAXIS2', spectrogram.dynamic_spectra.shape[0], 'length of data axis 2')
376
+ primary_hdu.header.set('EXTEND', True, 'FITS dataset may contain extensions')
377
+
378
+ # Add comments
379
+ comments = [
380
+ "FITS (Flexible Image Transport System) format defined in Astronomy and",
381
+ "Astrophysics Supplement Series v44/p363, v44/p371, v73/p359, v73/p365.",
382
+ "Contact the NASA Science Office of Standards and Technology for the",
383
+ "FITS Definition document #100 and other FITS information."
384
+ ]
385
+
386
+ # The comments section remains unchanged since add_comment is the correct approach
387
+ for comment in comments:
388
+ primary_hdu.header.add_comment(comment)
389
+
390
+ start_datetime = spectrogram.datetimes[0]
391
+ start_date = start_datetime.strftime("%Y-%m-%d")
392
+ start_time = start_datetime.strftime("%H:%M:%S.%f")
393
+
394
+ end_datetime = spectrogram.datetimes[-1]
395
+ end_date = end_datetime.strftime("%Y-%m-%d")
396
+ end_time = end_datetime.strftime("%H:%M:%S.%f")
397
+
398
+ primary_hdu.header.set('DATE', start_date, 'time of observation')
399
+ primary_hdu.header.set('CONTENT', f'{start_date} dynamic spectrogram', 'title of image')
400
+ primary_hdu.header.set('ORIGIN', f'{fits_config.get("ORIGIN")}')
401
+ primary_hdu.header.set('TELESCOP', f'{fits_config.get("TELESCOP")} tag: {spectrogram.tag}', 'type of instrument')
402
+ primary_hdu.header.set('INSTRUME', f'{fits_config.get("INSTRUME")}')
403
+ primary_hdu.header.set('OBJECT', f'{fits_config.get("OBJECT")}', 'object description')
404
+
405
+ primary_hdu.header.set('DATE-OBS', f'{start_date}', 'date observation starts')
406
+ primary_hdu.header.set('TIME-OBS', f'{start_time}', 'time observation starts')
407
+ primary_hdu.header.set('DATE-END', f'{end_date}', 'date observation ends')
408
+ primary_hdu.header.set('TIME-END', f'{end_time}', 'time observation ends')
409
+
410
+ primary_hdu.header.set('BZERO', 0, 'scaling offset')
411
+ primary_hdu.header.set('BSCALE', 1, 'scaling factor')
412
+ primary_hdu.header.set('BUNIT', f"{spectrogram.spectrum_type}", 'z-axis title')
413
+
414
+ primary_hdu.header.set('DATAMIN', np.nanmin(spectrogram.dynamic_spectra), 'minimum element in image')
415
+ primary_hdu.header.set('DATAMAX', np.nanmax(spectrogram.dynamic_spectra), 'maximum element in image')
416
+
417
+ primary_hdu.header.set('CRVAL1', f'{_seconds_of_day(start_datetime)}', 'value on axis 1 at reference pixel [sec of day]')
418
+ primary_hdu.header.set('CRPIX1', 0, 'reference pixel of axis 1')
419
+ primary_hdu.header.set('CTYPE1', 'TIME [UT]', 'title of axis 1')
420
+ primary_hdu.header.set('CDELT1', spectrogram.time_resolution, 'step between first and second element in x-axis')
421
+
422
+ primary_hdu.header.set('CRVAL2', 0, 'value on axis 2 at reference pixel')
423
+ primary_hdu.header.set('CRPIX2', 0, 'reference pixel of axis 2')
424
+ primary_hdu.header.set('CTYPE2', 'Frequency [Hz]', 'title of axis 2')
425
+ primary_hdu.header.set('CDELT2', spectrogram.frequency_resolution, 'step between first and second element in axis')
426
+
427
+ primary_hdu.header.set('OBS_LAT', f'{fits_config.get("OBS_LAT")}', 'observatory latitude in degree')
428
+ primary_hdu.header.set('OBS_LAC', 'N', 'observatory latitude code {N,S}')
429
+ primary_hdu.header.set('OBS_LON', f'{fits_config.get("OBS_LON")}', 'observatory longitude in degree')
430
+ primary_hdu.header.set('OBS_LOC', 'W', 'observatory longitude code {E,W}')
431
+ primary_hdu.header.set('OBS_ALT', f'{fits_config.get("OBS_ALT")}', 'observatory altitude in meter asl')
432
+
433
+
434
+ # Wrap arrays in an additional dimension to mimic the e-CALLISTO storage
435
+ times_wrapped = np.array([spectrogram.times.astype(np.float32)])
436
+ # To mimic e-Callisto storage, convert frequencies to MHz
437
+ frequencies_MHz = spectrogram.frequencies *1e-6
438
+ frequencies_wrapped = np.array([frequencies_MHz.astype(np.float32)])
439
+
440
+ # Binary Table HDU (extension)
441
+ col1 = fits.Column(name='TIME', format='PD', array=times_wrapped)
442
+ col2 = fits.Column(name='FREQUENCY', format='PD', array=frequencies_wrapped)
443
+ cols = fits.ColDefs([col1, col2])
444
+
445
+ bin_table_hdu = fits.BinTableHDU.from_columns(cols)
446
+
447
+ bin_table_hdu.header.set('PCOUNT', 0, 'size of special data area')
448
+ bin_table_hdu.header.set('GCOUNT', 1, 'one data group (required keyword)')
449
+ bin_table_hdu.header.set('TFIELDS', 2, 'number of fields in each row')
450
+ bin_table_hdu.header.set('TTYPE1', 'TIME', 'label for field 1')
451
+ bin_table_hdu.header.set('TFORM1', 'D', 'data format of field: 8-byte DOUBLE')
452
+ bin_table_hdu.header.set('TTYPE2', 'FREQUENCY', 'label for field 2')
453
+ bin_table_hdu.header.set('TFORM2', 'D', 'data format of field: 8-byte DOUBLE')
454
+ bin_table_hdu.header.set('TSCAL1', 1, '')
455
+ bin_table_hdu.header.set('TZERO1', 0, '')
456
+ bin_table_hdu.header.set('TSCAL2', 1, '')
457
+ bin_table_hdu.header.set('TZERO2', 0, '')
458
+
459
+ # Create HDU list and write to file
460
+ hdul = fits.HDUList([primary_hdu, bin_table_hdu])
461
+ hdul.writeto(write_path, overwrite=True)