spectre-core 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- spectre_core/__init__.py +3 -0
- spectre_core/cfg.py +116 -0
- spectre_core/chunks/__init__.py +206 -0
- spectre_core/chunks/base.py +160 -0
- spectre_core/chunks/chunk_register.py +15 -0
- spectre_core/chunks/factory.py +26 -0
- spectre_core/chunks/library/__init__.py +8 -0
- spectre_core/chunks/library/callisto/__init__.py +0 -0
- spectre_core/chunks/library/callisto/chunk.py +101 -0
- spectre_core/chunks/library/fixed/__init__.py +0 -0
- spectre_core/chunks/library/fixed/chunk.py +185 -0
- spectre_core/chunks/library/sweep/__init__.py +0 -0
- spectre_core/chunks/library/sweep/chunk.py +400 -0
- spectre_core/dynamic_imports.py +22 -0
- spectre_core/exceptions.py +17 -0
- spectre_core/file_handlers/base.py +94 -0
- spectre_core/file_handlers/configs.py +269 -0
- spectre_core/file_handlers/json.py +36 -0
- spectre_core/file_handlers/text.py +21 -0
- spectre_core/logging.py +222 -0
- spectre_core/plotting/__init__.py +5 -0
- spectre_core/plotting/base.py +194 -0
- spectre_core/plotting/factory.py +26 -0
- spectre_core/plotting/format.py +19 -0
- spectre_core/plotting/library/__init__.py +7 -0
- spectre_core/plotting/library/frequency_cuts/panel.py +74 -0
- spectre_core/plotting/library/integral_over_frequency/panel.py +34 -0
- spectre_core/plotting/library/spectrogram/panel.py +92 -0
- spectre_core/plotting/library/time_cuts/panel.py +77 -0
- spectre_core/plotting/panel_register.py +13 -0
- spectre_core/plotting/panel_stack.py +148 -0
- spectre_core/receivers/__init__.py +6 -0
- spectre_core/receivers/base.py +415 -0
- spectre_core/receivers/factory.py +19 -0
- spectre_core/receivers/library/__init__.py +7 -0
- spectre_core/receivers/library/rsp1a/__init__.py +0 -0
- spectre_core/receivers/library/rsp1a/gr/__init__.py +0 -0
- spectre_core/receivers/library/rsp1a/gr/fixed.py +104 -0
- spectre_core/receivers/library/rsp1a/gr/sweep.py +129 -0
- spectre_core/receivers/library/rsp1a/receiver.py +68 -0
- spectre_core/receivers/library/rspduo/__init__.py +0 -0
- spectre_core/receivers/library/rspduo/gr/__init__.py +0 -0
- spectre_core/receivers/library/rspduo/gr/tuner_1_fixed.py +110 -0
- spectre_core/receivers/library/rspduo/gr/tuner_1_sweep.py +135 -0
- spectre_core/receivers/library/rspduo/receiver.py +68 -0
- spectre_core/receivers/library/test/__init__.py +0 -0
- spectre_core/receivers/library/test/gr/__init__.py +0 -0
- spectre_core/receivers/library/test/gr/cosine_signal_1.py +83 -0
- spectre_core/receivers/library/test/gr/tagged_staircase.py +93 -0
- spectre_core/receivers/library/test/receiver.py +174 -0
- spectre_core/receivers/receiver_register.py +22 -0
- spectre_core/receivers/validators.py +205 -0
- spectre_core/spectrograms/__init__.py +3 -0
- spectre_core/spectrograms/analytical.py +205 -0
- spectre_core/spectrograms/array_operations.py +77 -0
- spectre_core/spectrograms/spectrogram.py +461 -0
- spectre_core/spectrograms/transform.py +267 -0
- spectre_core/watchdog/__init__.py +6 -0
- spectre_core/watchdog/base.py +105 -0
- spectre_core/watchdog/event_handler_register.py +15 -0
- spectre_core/watchdog/factory.py +22 -0
- spectre_core/watchdog/library/__init__.py +10 -0
- spectre_core/watchdog/library/fixed/__init__.py +0 -0
- spectre_core/watchdog/library/fixed/event_handler.py +41 -0
- spectre_core/watchdog/library/sweep/event_handler.py +55 -0
- spectre_core/watchdog/watcher.py +50 -0
- spectre_core/web_fetch/callisto.py +101 -0
- spectre_core-0.0.1.dist-info/LICENSE +674 -0
- spectre_core-0.0.1.dist-info/METADATA +40 -0
- spectre_core-0.0.1.dist-info/RECORD +72 -0
- spectre_core-0.0.1.dist-info/WHEEL +5 -0
- spectre_core-0.0.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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)
|