spectre-core 0.0.12__py3-none-any.whl → 0.0.14__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.14.dist-info}/METADATA +14 -7
  70. spectre_core-0.0.14.dist-info/RECORD +75 -0
  71. {spectre_core-0.0.12.dist-info → spectre_core-0.0.14.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.14.dist-info}/LICENSE +0 -0
  88. {spectre_core-0.0.12.dist-info → spectre_core-0.0.14.dist-info}/top_level.txt +0 -0
@@ -2,97 +2,142 @@
2
2
  # This file is part of SPECTRE
3
3
  # SPDX-License-Identifier: GPL-3.0-or-later
4
4
 
5
- from logging import getLogger
6
- _LOGGER = getLogger(__name__)
7
-
8
5
  import os
9
- from typing import Optional
6
+ from typing import Optional, TypeVar, Type, Generic, Iterator
10
7
  from collections import OrderedDict
11
- import warnings
12
8
  from datetime import datetime
13
9
 
10
+ from spectre_core.config import TimeFormat
14
11
  from spectre_core.spectrograms import Spectrogram, time_chop, join_spectrograms
15
- from spectre_core.config import get_batches_dir_path, TimeFormats
12
+ from spectre_core.config import get_batches_dir_path
16
13
  from spectre_core.exceptions import (
17
- SpectrogramNotFoundError,
18
14
  BatchNotFoundError
19
15
  )
20
16
  from ._base import BaseBatch
21
- from ._factory import get_batch_cls_from_tag
22
-
23
- class Batches:
24
- """A collection of batches for a given day of the year."""
25
- def __init__(self,
26
- tag: str,
27
- year: Optional[int] = None,
28
- month: Optional[int] = None,
29
- day: Optional[int] = None):
17
+
18
+ T = TypeVar('T', bound=BaseBatch)
19
+ class Batches(Generic[T]):
20
+ """Managed collection of `Batch` instances for a given tag. Provides a simple
21
+ interface for read operations on batched data files."""
22
+ def __init__(
23
+ self,
24
+ tag: str,
25
+ batch_cls: Type[T],
26
+ year: Optional[int] = None,
27
+ month: Optional[int] = None,
28
+ day: Optional[int] = None
29
+ ) -> None:
30
+ """Initialise a `Batches` instance.
31
+
32
+ :param tag: The batch name tag.
33
+ :param batch_cls: The `Batch` class used to read data files tagged by `tag`.
34
+ :param year: Filter batch files under a numeric year. Defaults to None.
35
+ :param month: Filter batch files under a numeric month. Defaults to None.
36
+ :param day: Filter batch files under a numeric day. Defaults to None.
37
+ """
30
38
  self._tag = tag
31
- self._Batch = get_batch_cls_from_tag(tag)
32
- self._batch_map: dict[str, BaseBatch] = OrderedDict()
39
+ self._batch_cls = batch_cls
40
+ self._batch_map: dict[str, T] = OrderedDict()
33
41
  self.set_date(year, month, day)
34
42
 
35
43
 
36
44
  @property
37
- def tag(self) -> str:
38
- """Tag identifier for each batch."""
45
+ def tag(
46
+ self
47
+ ) -> str:
48
+ """The batch name tag."""
39
49
  return self._tag
40
50
 
41
51
 
42
52
  @property
43
- def year(self) -> int:
44
- """The numeric year."""
53
+ def batch_cls(
54
+ self
55
+ ) -> Type[T]:
56
+ """The `Batch` class used to read the batched files."""
57
+ return self._batch_cls
58
+
59
+
60
+ @property
61
+ def year(
62
+ self
63
+ ) -> Optional[int]:
64
+ """The numeric year, to filter batch files."""
45
65
  return self._year
46
66
 
47
67
 
48
68
  @property
49
- def month(self) -> int:
50
- """The numeric month of the year."""
69
+ def month(
70
+ self
71
+ ) -> Optional[int]:
72
+ """The numeric month of the year, to filter batch files."""
51
73
  return self._month
52
74
 
53
75
 
54
76
  @property
55
- def day(self) -> int:
56
- """The numeric day of the year."""
77
+ def day(
78
+ self
79
+ ) -> Optional[int]:
80
+ """The numeric day of the year, to filter batch files."""
57
81
  return self._day
58
82
 
59
83
 
60
84
  @property
61
- def batches_dir_path(self) -> str:
62
- """The parent directory for all the batches."""
85
+ def batches_dir_path(
86
+ self
87
+ ) -> str:
88
+ """The shared ancestral path for all the batches. `Batches` recursively searches
89
+ this directory to find all batches whose batch name contains `tag`."""
63
90
  return get_batches_dir_path(self.year, self.month, self.day)
64
91
 
65
92
 
66
93
  @property
67
- def batch_list(self) -> list[BaseBatch]:
68
- """A list of all the batch instances."""
94
+ def batch_list(
95
+ self
96
+ ) -> list[T]:
97
+ """A list of all batches found within `batches_dir_path`."""
69
98
  return list(self._batch_map.values())
70
99
 
71
100
 
72
101
  @property
73
- def start_times(self) -> list[str]:
74
- """The start times of each batch."""
102
+ def start_times(
103
+ self
104
+ ) -> list[str]:
105
+ """The start times of each batch found within `batches_dir_path`."""
75
106
  return list(self._batch_map.keys())
76
107
 
77
108
 
78
109
  @property
79
- def num_batches(self) -> int:
80
- """The number of batches in the batch parent directory."""
110
+ def num_batches(
111
+ self
112
+ ) -> int:
113
+ """The total number of batches found within `batches_dir_path`."""
81
114
  return len(self.batch_list)
82
115
 
83
116
 
84
- def set_date(self,
85
- year: Optional[int],
86
- month: Optional[int],
87
- day: Optional[int]) -> None:
88
- """Update the parent directory for the batches according to the numeric date."""
117
+ def set_date(
118
+ self,
119
+ year: Optional[int],
120
+ month: Optional[int],
121
+ day: Optional[int]
122
+ ) -> None:
123
+ """Reset `batches_dir_path` according to the numeric date, and refresh the list
124
+ of available batches.
125
+
126
+ :param year: Filter by the numeric year.
127
+ :param month: Filter by the numeric month of the year.
128
+ :param day: Filter by the numeric day of the month.
129
+ """
89
130
  self._year = year
90
131
  self._month = month
91
132
  self._day = day
92
- self._update_batch_map()
133
+ self.update()
93
134
 
94
135
 
95
- def _update_batch_map(self) -> None:
136
+ def update(
137
+ self
138
+ ) -> None:
139
+ """Perform a fresh search all files in `batches_dir_path` for batches
140
+ with `tag` in the batch name."""
96
141
  # reset cache
97
142
  self._batch_map = OrderedDict()
98
143
 
@@ -103,95 +148,100 @@ class Batches:
103
148
  batch_name, _ = os.path.splitext(batch_file_name)
104
149
  start_time, tag = batch_name.split("_", 1)
105
150
  if tag == self._tag:
106
- self._batch_map[start_time] = self._Batch(start_time, tag)
151
+ self._batch_map[start_time] = self.batch_cls(start_time, tag)
107
152
 
108
153
  self._batch_map = OrderedDict(sorted(self._batch_map.items()))
109
-
110
-
111
- def update(self) -> None:
112
- """Public alias for setting batch map"""
113
- self._update_batch_map()
114
154
 
115
155
 
116
- def __iter__(self):
156
+ def __iter__(
157
+ self
158
+ ) -> Iterator[T]:
117
159
  """Iterate over the stored batch instances."""
118
160
  yield from self.batch_list
161
+
162
+
163
+ def __len__(
164
+ self
165
+ ):
166
+ return self.num_batches
119
167
 
120
168
 
121
- def _get_from_start_time(self,
122
- start_time: str) -> BaseBatch:
123
- """Get the batch according to the input start time."""
169
+ def _get_from_start_time(
170
+ self,
171
+ start_time: str
172
+ ) -> T:
173
+ """Find and return the `Batch` instance based on the string formatted start time."""
124
174
  try:
125
175
  return self._batch_map[start_time]
126
176
  except KeyError:
127
177
  raise BatchNotFoundError(f"Batch with start time {start_time} could not be found within {self.batches_dir_path}")
128
178
 
129
179
 
130
- def _get_from_index(self,
131
- index: int) -> BaseBatch:
132
- """Get the batch according to its index, where the batches are ordered in time."""
133
- num_batches = len(self.batch_list)
134
- if num_batches == 0:
180
+ def _get_from_index(
181
+ self,
182
+ index: int
183
+ ) -> T:
184
+ """Find and return the `Batch` instance based on its numeric index.
185
+
186
+ Batches are ordered sequentially in time, so index `0` corresponds to the oldest
187
+ `Batch` with respect to the start time.
188
+ """
189
+ if self.num_batches == 0:
135
190
  raise BatchNotFoundError("No batches are available")
136
- index = index % num_batches # Use modulo to make the index wrap around. Allows the user to iterate over all the batches via index cyclically.
191
+ elif index > self.num_batches:
192
+ raise IndexError(f"Index '{index}' is greater than the number of batches '{self.num_batches}'")
137
193
  return self.batch_list[index]
138
194
 
139
195
 
140
- def __getitem__(self, subscript: str | int):
196
+ def __getitem__(
197
+ self,
198
+ subscript: str | int
199
+ ) -> T:
200
+ """Get a `Batch` instanced based on either the start time or chronological index.
201
+
202
+ :param subscript: If the subscript is a string, interpreted as a formatted start time.
203
+ If the subscript is an integer, it is interpreted as a chronological index.
204
+ :return: The corresponding `BaseBatch` subclass.
205
+ """
141
206
  if isinstance(subscript, str):
142
207
  return self._get_from_start_time(subscript)
143
208
  elif isinstance(subscript, int):
144
209
  return self._get_from_index(subscript)
145
-
146
210
 
147
- def num_batch_files(self,
148
- extension: str) -> int:
149
- """Get the number of existing batch files with the given extension."""
150
- return sum(1 for batch_file in self if batch_file.has_file(extension))
151
211
 
212
+ def get_spectrogram_from_range(
213
+ self,
214
+ start_time: str,
215
+ end_time: str
216
+ ) -> Spectrogram:
217
+ """
218
+ Retrieve a spectrogram spanning the specified time range.
152
219
 
153
- def get_spectrogram_from_range(self,
154
- start_time: str,
155
- end_time: str) -> Spectrogram:
156
- """Return a spectrogram over the input time range."""
220
+ :param start_time: The start time of the range (inclusive).
221
+ :param end_time: The end time of the range (inclusive).
222
+ :raises FileNotFoundError: If no spectrogram data is available within the specified time range.
223
+ :return: A spectrogram created by stitching together data from all matching batches.
224
+ """
157
225
  # Convert input strings to datetime objects
158
- start_datetime = datetime.strptime(start_time, TimeFormats.DATETIME)
159
- end_datetime = datetime.strptime(end_time, TimeFormats.DATETIME)
160
-
161
- if start_datetime.day != end_datetime.day:
162
- warning_message = "Joining spectrograms across multiple days"
163
- _LOGGER.warning(warning_message)
164
- warnings.warn(warning_message, RuntimeWarning)
226
+ start_datetime = datetime.strptime(start_time, TimeFormat.DATETIME)
227
+ end_datetime = datetime.strptime(end_time, TimeFormat.DATETIME)
165
228
 
166
229
  spectrograms = []
167
- num_fits_batch_files = self.num_batch_files("fits")
168
-
169
- for i, batch in enumerate(self):
170
- # skip batches without fits files
171
- if not batch.has_file("fits"):
230
+ for batch in self:
231
+ # skip batches without spectrogram data
232
+ if not batch.spectrogram_file.exists:
172
233
  continue
173
-
174
- # rather than reading all files to evaluate the actual upper bound to their time range (slow)
175
- # place an upper bound by using the start datetime for the next batch
176
- # this assumes that the batches are non-overlapping (reasonable assumption)
177
- lower_bound = batch.start_datetime
178
- if i < num_fits_batch_files:
179
- next_batch = self[i + 1]
180
- upper_bound = next_batch.start_datetime
181
- # if there is no "next batch" then we do have to read the file
182
- else:
183
- fits_batch = batch.get_file("fits")
184
- upper_bound = fits_batch.datetimes[-1]
185
-
186
- # if the batch overlaps with the input time range, then read the fits file
234
+
235
+ spectrogram = batch.read_spectrogram()
236
+ lower_bound = spectrogram.datetimes[0]
237
+ upper_bound = spectrogram.datetimes[-1]
238
+
239
+ # Check if the batch overlaps with the input time range
187
240
  if start_datetime <= upper_bound and lower_bound <= end_datetime:
188
- spectrogram = batch.read_file("fits")
189
- spectrogram = time_chop(spectrogram, start_time, end_time)
190
- # if we have a non-empty spectrogram, append it to the list of spectrograms
191
- if spectrogram:
192
- spectrograms.append(spectrogram)
241
+ spectrograms.append( time_chop(spectrogram, start_time, end_time) )
193
242
 
194
243
  if spectrograms:
195
244
  return join_spectrograms(spectrograms)
196
245
  else:
197
- raise SpectrogramNotFoundError("No spectrogram data found for the given time range")
246
+ raise FileNotFoundError(f"No spectrogram data found for the time range "
247
+ f"{start_time} to {end_time}.")
@@ -2,26 +2,68 @@
2
2
  # This file is part of SPECTRE
3
3
  # SPDX-License-Identifier: GPL-3.0-or-later
4
4
 
5
- from spectre_core.capture_configs import CaptureConfig, PNames
5
+ from typing import Literal, overload, Type, cast
6
+
6
7
  from spectre_core.exceptions import BatchNotFoundError
7
- from ._register import batch_map
8
+ from spectre_core.capture_configs import CaptureConfig, PName
8
9
  from ._base import BaseBatch
10
+ from ._register import batch_map
11
+ from .plugins._batch_keys import BatchKey
12
+ from .plugins._callisto import CallistoBatch
13
+ from .plugins._iq_stream import IQStreamBatch
14
+
15
+
16
+ @overload
17
+ def get_batch_cls(
18
+ batch_key: Literal[BatchKey.CALLISTO],
19
+ ) -> Type[CallistoBatch]:
20
+ ...
9
21
 
10
22
 
11
- def _get_batch_cls(batch_key: str) -> BaseBatch:
12
- Batch = batch_map.get(batch_key)
13
- if Batch is None:
23
+ @overload
24
+ def get_batch_cls(
25
+ batch_key: Literal[BatchKey.IQ_STREAM],
26
+ ) -> Type[IQStreamBatch]:
27
+ ...
28
+
29
+
30
+ @overload
31
+ def get_batch_cls(batch_key: BatchKey) -> Type[BaseBatch]:
32
+ ...
33
+
34
+
35
+ def get_batch_cls(
36
+ batch_key: BatchKey,
37
+ ) -> Type[BaseBatch]:
38
+ """Get a registered `BaseBatch` subclass.
39
+
40
+ :param batch_key: The key used to register the `BaseBatch` subclass.
41
+ :raises BatchNotFoundError: If an invalid `batch_key` is provided.
42
+ :return: The `BaseBatch` subclass corresponding to the input key.
43
+ """
44
+ batch_cls = batch_map.get(batch_key)
45
+ if batch_cls is None:
14
46
  valid_batch_keys = list(batch_map.keys())
15
- raise BatchNotFoundError(f"No batch found for the batch key: {batch_key}. Valid batch keys are: {valid_batch_keys}")
16
- return Batch
47
+ raise BatchNotFoundError(f"No batch found for the batch key: {batch_key}. "
48
+ f"Valid batch keys are: {valid_batch_keys}")
49
+ return batch_cls
17
50
 
18
51
 
19
- def get_batch_cls_from_tag(tag: str) -> BaseBatch:
20
- # if we are dealing with a callisto batch, the batch key is equal to the tag
52
+ def get_batch_cls_from_tag(
53
+ tag: str
54
+ ) -> Type[BaseBatch]:
55
+ # if the tag is reserved (i.e., corresponds to third-party spectrogram data)
56
+ # directly fetch the right class.
21
57
  if "callisto" in tag:
22
- batch_key = "callisto"
23
- # otherwise, we fetch the batch key from the capture config
58
+ return get_batch_cls(BatchKey.CALLISTO)
59
+
60
+ # otherwise, assume that the tag has an associated capture config,
24
61
  else:
25
- capture_config= CaptureConfig(tag)
26
- batch_key = capture_config.get_parameter_value(PNames.BATCH_KEY)
27
- return _get_batch_cls(batch_key)
62
+ capture_config = CaptureConfig(tag)
63
+ if PName.BATCH_KEY not in capture_config.parameters.name_list:
64
+ raise ValueError(f"Could not infer batch class from the tag 'tag'. "
65
+ f"A parameter with name `{PName.BATCH_KEY.value}` "
66
+ f"does not exist.")
67
+
68
+ batch_key = BatchKey( cast(str, capture_config.get_parameter_value( PName.BATCH_KEY) ) )
69
+ return get_batch_cls( batch_key )
@@ -2,14 +2,29 @@
2
2
  # This file is part of SPECTRE
3
3
  # SPDX-License-Identifier: GPL-3.0-or-later
4
4
 
5
- # Global dictionaries to hold the mappings
6
- batch_map = {}
5
+ from typing import Type, Callable, TypeVar
7
6
 
8
- # classes decorated with @register_batch([BATCH_KEY])
9
- # will be added to batch_map
10
- def register_batch(batch_key: str):
11
- def decorator(cls):
7
+ from ._base import BaseBatch
8
+ from .plugins._batch_keys import BatchKey
9
+
10
+ # Map populated at runtime via the `register_batch` decorator.
11
+ batch_map: dict[BatchKey, Type[BaseBatch]] = {}
12
+
13
+ T = TypeVar('T', bound=BaseBatch)
14
+ def register_batch(
15
+ batch_key: BatchKey
16
+ ) -> Callable[[Type[T]], Type[T]]:
17
+ """Decorator to register a `BaseBatch` subclass under a specified `BatchKey`.
18
+
19
+ :param batch_key: The key to register the `BaseBatch` subclass under.
20
+ :raises ValueError: If the provided `batch_key` is already registered.
21
+ :return: A decorator that registers the `BaseBatch` subclass under the given `batch_key`.
22
+ """
23
+ def decorator(
24
+ cls: Type[T]
25
+ ) -> Type[T]:
26
+ if batch_key in batch_map:
27
+ raise ValueError(f"A batch with key '{batch_key}' is already registered!")
12
28
  batch_map[batch_key] = cls
13
29
  return cls
14
- return decorator
15
-
30
+ return decorator
@@ -0,0 +1,16 @@
1
+ # SPDX-FileCopyrightText: © 2024 Jimmy Fitzpatrick <jcfitzpatrick12@gmail.com>
2
+ # This file is part of SPECTRE
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ from enum import Enum
6
+
7
+ class BatchKey(Enum):
8
+ """Key bound to a `Batch` plugin class.
9
+
10
+ :ivar IQ_STREAM: Represents the default batch data generated by `spectre`,
11
+ containing IQ stream data and other data derived from it.
12
+ :ivar CALLISTO: Represents FITS files generated by the e-Callisto network.
13
+ """
14
+ IQ_STREAM = "iq_stream"
15
+ CALLISTO = "callisto"
16
+
@@ -0,0 +1,183 @@
1
+ # SPDX-FileCopyrightText: © 2024 Jimmy Fitzpatrick <jcfitzpatrick12@gmail.com>
2
+ # This file is part of SPECTRE
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import datetime
7
+
8
+ import numpy as np
9
+ import numpy.typing as npt
10
+ from astropy.io import fits
11
+ from astropy.io.fits.hdu.image import PrimaryHDU
12
+ from astropy.io.fits.hdu.table import BinTableHDU
13
+ from astropy.io.fits.hdu.hdulist import HDUList
14
+
15
+ from spectre_core.spectrograms import Spectrogram, SpectrumUnit
16
+ from ._batch_keys import BatchKey
17
+ from .._base import BaseBatch, BatchFile
18
+ from .._register import register_batch
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class _BatchExtension:
23
+ """Supported extensions for a `CallistoBatch`.
24
+
25
+ :ivar FITS: Corresponds to the `.fits` file extension.
26
+ """
27
+ FITS: str = "fits"
28
+
29
+
30
+ class _FitsFile(BatchFile[Spectrogram]):
31
+ """Stores spectrogram data in the FITS format generated by the e-Callisto network."""
32
+ def __init__(
33
+ self,
34
+ parent_dir_path: str,
35
+ base_file_name: str
36
+ ) -> None:
37
+ """Initialise a `_FitsFile` instance.
38
+
39
+ :param parent_dir_path: The parent directory for the batch.
40
+ :param base_file_name: The batch name.
41
+ """
42
+ super().__init__(parent_dir_path,
43
+ base_file_name,
44
+ _BatchExtension.FITS)
45
+
46
+
47
+ def _read(
48
+ self
49
+ ) -> Spectrogram:
50
+ """Parses a FITS file to generate a `Spectrogram` instance.
51
+
52
+ Reverses the spectra along the frequency axis and converts units to linearised
53
+ values if necessary. Infers the spectrum type from the `BUNIT` header.
54
+
55
+ :raises NotImplementedError: If the `BUNIT` header value represents an unsupported spectrum type.
56
+ :return: A `Spectrogram` instance containing the parsed FITS file data.
57
+ """
58
+ with fits.open(self.file_path, mode='readonly') as hdulist:
59
+ primary_hdu = self._get_primary_hdu(hdulist)
60
+ dynamic_spectra = self._get_dynamic_spectra(primary_hdu)
61
+ spectrogram_start_datetime = self._get_spectrogram_start_datetime(primary_hdu)
62
+ bintable_hdu = self._get_bintable_hdu(hdulist)
63
+ times = self._get_times(bintable_hdu)
64
+ frequencies = self._get_frequencies(bintable_hdu)
65
+ bunit = self._get_bunit(primary_hdu)
66
+
67
+ # bunit is interpreted as a SpectrumUnit
68
+ spectrum_unit = SpectrumUnit(bunit)
69
+ if spectrum_unit == SpectrumUnit.DIGITS:
70
+ dynamic_spectra_linearised = self._convert_units_to_linearised(dynamic_spectra)
71
+
72
+ return Spectrogram(dynamic_spectra_linearised[::-1, :], # reverse the spectra along the frequency axis
73
+ times,
74
+ frequencies[::-1], # sort the frequencies in ascending order
75
+ self.tag,
76
+ spectrum_unit,
77
+ spectrogram_start_datetime)
78
+ else:
79
+ raise NotImplementedError(f"SPECTRE does not currently support spectrum type with BUNITS '{spectrum_unit}'")
80
+
81
+
82
+ def _get_primary_hdu(
83
+ self, hdulist: HDUList
84
+ ) -> PrimaryHDU:
85
+ return hdulist['PRIMARY']
86
+
87
+
88
+ def _get_bintable_hdu(
89
+ self,
90
+ hdulist: HDUList
91
+ ) -> BinTableHDU:
92
+ return hdulist[1]
93
+
94
+
95
+ def _get_dynamic_spectra(
96
+ self,
97
+ primary_hdu: PrimaryHDU
98
+ ) -> npt.NDArray[np.float32]:
99
+ return primary_hdu.data.astype(np.float32)
100
+
101
+
102
+ def _get_spectrogram_start_datetime(
103
+ self,
104
+ primary_hdu: PrimaryHDU
105
+ ) -> datetime:
106
+ date_obs = primary_hdu.header['DATE-OBS']
107
+ time_obs = primary_hdu.header['TIME-OBS']
108
+ return datetime.strptime(f"{date_obs}T{time_obs}", "%Y/%m/%dT%H:%M:%S.%f")
109
+
110
+
111
+ def _get_bunit(
112
+ self,
113
+ primary_hdu: PrimaryHDU
114
+ ) -> str:
115
+ return primary_hdu.header['BUNIT']
116
+
117
+
118
+ def _convert_units_to_linearised(
119
+ self,
120
+ raw_digits: npt.NDArray[np.float32]
121
+ ) -> npt.NDArray[np.float32]:
122
+ """Converts spectrogram data from raw digit values to linearised units.
123
+
124
+ Applies a transformation based on ADC specifications to convert raw values
125
+ to dB and then to linearised units.
126
+
127
+ :param dynamic_spectra: Raw dynamic spectra in digit values.
128
+ :return: The dynamic spectra with linearised units.
129
+ """
130
+ # conversion as per ADC specs [see email from C. Monstein]
131
+ dB = (raw_digits / 255) * (2500 / 25)
132
+ return 10 ** (dB / 10)
133
+
134
+
135
+ def _get_times(
136
+ self,
137
+ bintable_hdu: BinTableHDU
138
+ ) -> npt.NDArray[np.float32]:
139
+ """Extracts the elapsed times for each spectrum in seconds, with the first spectrum set to t=0
140
+ by convention.
141
+ """
142
+ return bintable_hdu.data['TIME'][0] # already in seconds
143
+
144
+
145
+ def _get_frequencies(
146
+ self,
147
+ bintable_hdu: BinTableHDU
148
+ ) -> npt.NDArray[np.float32]:
149
+ """Extracts the frequencies for each spectral component."""
150
+ frequencies_MHz = bintable_hdu.data['FREQUENCY'][0]
151
+ return frequencies_MHz * 1e6 # convert to Hz
152
+
153
+
154
+ @register_batch(BatchKey.CALLISTO)
155
+ class CallistoBatch(BaseBatch):
156
+ """A batch of data generated by the e-Callisto network.
157
+
158
+ Supports the following file extensions:
159
+ - `.fits` (via the `spectrogram_file` attribute)
160
+ """
161
+ def __init__(
162
+ self,
163
+ start_time: str,
164
+ tag: str
165
+ ) -> None:
166
+ """Initialise a `CallistoBatch` instance.
167
+
168
+ :param start_time: The start time of the batch.
169
+ :param tag: The batch name tag.
170
+ """
171
+ super().__init__(start_time, tag)
172
+ self._fits_file = _FitsFile(self.parent_dir_path, self.name)
173
+
174
+ # add files formally to the batch
175
+ self.add_file( self.spectrogram_file )
176
+
177
+
178
+ @property
179
+ def spectrogram_file(
180
+ self
181
+ ) -> _FitsFile:
182
+ """The batch file corresponding to the `.fits` extension."""
183
+ return self._fits_file