fitscube 2.0.0__tar.gz → 2.2.0__tar.gz

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 (32) hide show
  1. {fitscube-2.0.0 → fitscube-2.2.0}/.github/workflows/cd.yml +1 -1
  2. {fitscube-2.0.0 → fitscube-2.2.0}/.github/workflows/ci.yml +2 -2
  3. {fitscube-2.0.0 → fitscube-2.2.0}/.pre-commit-config.yaml +2 -2
  4. {fitscube-2.0.0 → fitscube-2.2.0}/PKG-INFO +1 -1
  5. {fitscube-2.0.0 → fitscube-2.2.0}/fitscube/_version.py +2 -2
  6. {fitscube-2.0.0 → fitscube-2.2.0}/fitscube/combine_fits.py +62 -11
  7. {fitscube-2.0.0 → fitscube-2.2.0}/fitscube/exceptions.py +2 -2
  8. {fitscube-2.0.0 → fitscube-2.2.0}/fitscube/extract.py +158 -47
  9. {fitscube-2.0.0 → fitscube-2.2.0}/tests/conftest.py +23 -0
  10. fitscube-2.2.0/tests/data/time_images.zip +0 -0
  11. fitscube-2.2.0/tests/data/timecube.zip +0 -0
  12. {fitscube-2.0.0 → fitscube-2.2.0}/tests/test_extract.py +95 -14
  13. {fitscube-2.0.0 → fitscube-2.2.0}/tests/test_times.py +23 -0
  14. {fitscube-2.0.0 → fitscube-2.2.0}/.github/CONTRIBUTING.md +0 -0
  15. {fitscube-2.0.0 → fitscube-2.2.0}/.github/dependabot.yml +0 -0
  16. {fitscube-2.0.0 → fitscube-2.2.0}/.github/release.yml +0 -0
  17. {fitscube-2.0.0 → fitscube-2.2.0}/.gitignore +0 -0
  18. {fitscube-2.0.0 → fitscube-2.2.0}/CHANGELOG.md +0 -0
  19. {fitscube-2.0.0 → fitscube-2.2.0}/LICENSE +0 -0
  20. {fitscube-2.0.0 → fitscube-2.2.0}/README.md +0 -0
  21. {fitscube-2.0.0 → fitscube-2.2.0}/fitscube/__init__.py +0 -0
  22. {fitscube-2.0.0 → fitscube-2.2.0}/fitscube/asyncio.py +0 -0
  23. {fitscube-2.0.0 → fitscube-2.2.0}/fitscube/cli.py +0 -0
  24. {fitscube-2.0.0 → fitscube-2.2.0}/fitscube/logging.py +0 -0
  25. {fitscube-2.0.0 → fitscube-2.2.0}/fitscube/version.pyi +0 -0
  26. {fitscube-2.0.0 → fitscube-2.2.0}/noxfile.py +0 -0
  27. {fitscube-2.0.0 → fitscube-2.2.0}/pyproject.toml +0 -0
  28. {fitscube-2.0.0 → fitscube-2.2.0}/tests/__init__.py +0 -0
  29. {fitscube-2.0.0 → fitscube-2.2.0}/tests/data/cube.zip +0 -0
  30. {fitscube-2.0.0 → fitscube-2.2.0}/tests/data/images.zip +0 -0
  31. {fitscube-2.0.0 → fitscube-2.2.0}/tests/test_frequencies.py +0 -0
  32. {fitscube-2.0.0 → fitscube-2.2.0}/tests/test_package.py +0 -0
@@ -49,7 +49,7 @@ jobs:
49
49
  path: dist
50
50
 
51
51
  - name: Generate artifact attestation for sdist and wheel
52
- uses: actions/attest-build-provenance@v2.2.2
52
+ uses: actions/attest-build-provenance@v2.4.0
53
53
  with:
54
54
  subject-path: "dist/*"
55
55
 
@@ -39,7 +39,7 @@ jobs:
39
39
  fail-fast: false
40
40
  matrix:
41
41
  python-version: ["3.10", "3.11", "3.12", "3.13"]
42
- runs-on: [ubuntu-latest, windows-latest, macos-14]
42
+ runs-on: [ubuntu-latest]
43
43
 
44
44
  steps:
45
45
  - uses: actions/checkout@v4
@@ -62,6 +62,6 @@ jobs:
62
62
  --durations=20
63
63
 
64
64
  - name: Upload coverage report
65
- uses: codecov/codecov-action@v5.4.0
65
+ uses: codecov/codecov-action@v5.4.3
66
66
  with:
67
67
  token: ${{ secrets.CODECOV_TOKEN }}
@@ -33,14 +33,14 @@ repos:
33
33
  - id: rst-inline-touching-normal
34
34
 
35
35
  - repo: https://github.com/astral-sh/ruff-pre-commit
36
- rev: "v0.12.4"
36
+ rev: "v0.12.7"
37
37
  hooks:
38
38
  - id: ruff
39
39
  args: ["--fix", "--show-fixes"]
40
40
  - id: ruff-format
41
41
 
42
42
  - repo: https://github.com/pre-commit/mirrors-mypy
43
- rev: "v1.17.0"
43
+ rev: "v1.17.1"
44
44
  hooks:
45
45
  - id: mypy
46
46
  files: src|tests
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fitscube
3
- Version: 2.0.0
3
+ Version: 2.2.0
4
4
  Summary: A package to produce produce FITS cubes.
5
5
  Project-URL: Homepage, https://github.com/AlecThomson/fitscube
6
6
  Project-URL: Bug Tracker, https://github.com/AlecThomson/fitscube/issues
@@ -17,5 +17,5 @@ __version__: str
17
17
  __version_tuple__: VERSION_TUPLE
18
18
  version_tuple: VERSION_TUPLE
19
19
 
20
- __version__ = version = '2.0.0'
21
- __version_tuple__ = version_tuple = (2, 0, 0)
20
+ __version__ = version = '2.2.0'
21
+ __version_tuple__ = version_tuple = (2, 2, 0)
@@ -160,7 +160,13 @@ async def create_cube_from_scratch_coro(
160
160
  if output_file.exists() and overwrite:
161
161
  output_file.unlink()
162
162
 
163
- output_wcs = WCS(output_header)
163
+ try:
164
+ output_wcs = WCS(output_header)
165
+ except Exception as e:
166
+ logger.error("Error creating new header")
167
+ for k in output_header:
168
+ logger.error(f"{k} = {output_header[k]}")
169
+ raise e
164
170
  output_shape = output_wcs.array_shape
165
171
  msg = f"Creating a new FITS file with shape {output_shape}"
166
172
  logger.info(msg)
@@ -191,6 +197,7 @@ async def create_cube_from_scratch_coro(
191
197
 
192
198
  for key, value in output_header.items():
193
199
  header[key] = value
200
+ logger.debug(f"{key}={value}")
194
201
 
195
202
  header.tofile(output_file, overwrite=overwrite)
196
203
 
@@ -253,11 +260,39 @@ async def create_output_cube_coro(
253
260
  ctype = "TIME" if time_domain_mode else "FREQ"
254
261
 
255
262
  old_data, old_header = fits.getdata(old_name, header=True, memmap=True)
256
- even_spec = np.diff(specs).std() < (1e-4 * unit)
263
+ sorted_specs = np.sort(specs)
264
+ if time_domain_mode:
265
+ logger.info("Computing time-differences")
266
+
267
+ # This attempts to constrain the deviation away from 'asbolute' time
268
+ # as far as it can be. If some time steps are not regularly space
269
+ # (differencce between the time of adjacent scans) can be positive or negative,
270
+ # but so long as the assumulated total is close to 0 then we can say
271
+ # it is close. This approach is catering to some strangeness in
272
+ # ASKAP data. If the accumulated error is small enough we can assume
273
+ # the FITS header can encoude the times as regular steps.
274
+ diff_time = np.diff(sorted_specs)
275
+ diff_diff_time = np.diff(diff_time)
276
+ running_deviation_from_zero = np.abs(np.cumsum(diff_diff_time))
277
+ even_spec = np.max(running_deviation_from_zero) < (np.mean(diff_time) * 0.02)
278
+
279
+ # This is a simpler way where no attempt is made to ensure the total
280
+ # error on the irregular steps accumulates and violates the regular
281
+ # spacing we can encode in the fits header. I am less trustworthy of this,
282
+ # if all deviations are negative they would accumulate. Individually
283
+ # they may not fail but across the whole TIME dimension, as encoded by the
284
+ # C-type header fields that define a regularly spaced interval, may.
285
+ # even_spec = np.all(np.abs(np.diff(np.diff(sorted_specs))) < (0.15*u.s))
286
+ else:
287
+ even_spec = np.diff(sorted_specs).std() < (1e-4 * unit)
288
+
289
+ logger.debug(f"{np.diff(sorted_specs).std()=}")
257
290
  if not even_spec:
258
291
  spequency = "Times" if time_domain_mode else "Frequencies"
259
292
  msg = f"{spequency} are not evenly spaced"
260
293
  logger.warning(msg)
294
+ logger.debug(f"{np.max(np.diff(sorted_specs))=}")
295
+ logger.debug(f"{np.min(np.diff(sorted_specs))=}")
261
296
 
262
297
  n_chan = len(specs)
263
298
 
@@ -271,15 +306,15 @@ async def create_output_cube_coro(
271
306
  try:
272
307
  idx = wcs.axis_type_names[::-1].index(ctype)
273
308
 
274
- except ValueError as e:
275
- msg = f"No {ctype} axis found in WCS."
309
+ fits_idx = wcs.axis_type_names.index(ctype) + 1
310
+ logger.info(f"{ctype} axis found at index {idx} (NAXIS{fits_idx})")
276
311
 
277
- raise ValueError(msg) from e
278
- fits_idx = wcs.axis_type_names.index(ctype) + 1
279
- logger.info(f"{ctype} axis found at index %s (NAXIS%s)", idx, fits_idx)
312
+ except ValueError:
313
+ msg = f"No {ctype} axis not found in WCS."
314
+ logger.info(msg)
315
+ fits_idx = len(old_data.shape) + 1
280
316
 
281
317
  new_header = old_header.copy()
282
- new_header["NAXIS"] = 3 if is_2d else len(old_data.shape)
283
318
  new_header[f"NAXIS{fits_idx}"] = n_chan
284
319
  new_header[f"CRPIX{fits_idx}"] = 1
285
320
  new_header[f"CRVAL{fits_idx}"] = specs[0].value
@@ -287,7 +322,25 @@ async def create_output_cube_coro(
287
322
  new_header[f"CUNIT{fits_idx}"] = f"{unit:fits}"
288
323
  new_header[f"CTYPE{fits_idx}"] = ctype
289
324
 
325
+ # Figure out the correct number of dimensions to use
326
+ _no_of_naxis = [k for k in new_header if k.startswith("NAXIS") and k != "NAXIS"]
327
+ new_header["NAXIS"] = len(_no_of_naxis)
328
+
329
+ for k in ["CTYPE", "CUNIT", "CDELT"]:
330
+ key = f"{k}{fits_idx}"
331
+ logger.debug(f"{key}={new_header[key]}")
332
+
333
+ # Add extra transform fields for consistency
334
+ if ("CD1_1" in new_header or "PC1_1" in new_header) and fits_idx != 1:
335
+ transform_type = "CD" if "CD1_1" in new_header else "PC"
336
+ pv1 = f"{transform_type}{fits_idx}_{fits_idx}"
337
+ logger.info(f"Adding {pv1} to header")
338
+ new_header[pv1] = 1.0
339
+
290
340
  if ignore_spec or not even_spec:
341
+ logger.info(
342
+ f"Ignore the specrency information, {ignore_spec=} or {not even_spec=}"
343
+ )
291
344
  new_header[f"CDELT{fits_idx}"] = 1
292
345
  del new_header[f"CUNIT{fits_idx}"]
293
346
  new_header[f"CTYPE{fits_idx}"] = "CHAN"
@@ -305,9 +358,6 @@ async def create_output_cube_coro(
305
358
  )
306
359
  del new_header["BMAJ"], new_header["BMIN"], new_header["BPA"]
307
360
 
308
- if time_domain_mode:
309
- new_header["COMMENT"] = "The frequency/chan axis in this cube represents TIME."
310
-
311
361
  plane_shape = list(old_data.shape)
312
362
  cube_shape = plane_shape.copy()
313
363
  if is_2d:
@@ -393,6 +443,7 @@ async def parse_specs_coro(
393
443
  spec_file (str | None, optional): File containing frequencies/times. Defaults to None.
394
444
  spec_list (list[float] | None, optional): List of frequencies/times. Defaults to None.
395
445
  ignore_spec (bool | None, optional): Ignore frequency/time information. Defaults to False.
446
+ time_domain_mode (bool, optional): Whether these cubes dhould be formed over the time axis. Defaults to False.
396
447
 
397
448
  Raises:
398
449
  ValueError: If both spec_file and spec_list are specified
@@ -5,8 +5,8 @@ class FITSCubeException(Exception):
5
5
  """Base container for FITSCube exceptions"""
6
6
 
7
7
 
8
- class FREQMissingException(FITSCubeException):
9
- """Missing FREQ axis in fits cube"""
8
+ class TargetAxisMissingException(FITSCubeException):
9
+ """The target axis is missing in fits cube"""
10
10
 
11
11
 
12
12
  class ChannelMissingException(FITSCubeException):
@@ -11,7 +11,7 @@ import numpy as np
11
11
  from astropy.io import fits
12
12
  from radio_beam import Beam, Beams
13
13
 
14
- from fitscube.exceptions import ChannelMissingException, FREQMissingException
14
+ from fitscube.exceptions import ChannelMissingException, TargetAxisMissingException
15
15
  from fitscube.logging import logger, set_verbosity
16
16
 
17
17
 
@@ -21,8 +21,10 @@ class ExtractOptions:
21
21
 
22
22
  hdu_index: int = 0
23
23
  """The HDU in the fits cube to access (e.g. for header and data)"""
24
- channel_index: int = 0
25
- """The channel of the cube to extract"""
24
+ channel_index: int | None = None
25
+ """The channel of the cube to extract. Defaults to None"""
26
+ time_index: int | None = None
27
+ """The timestep of the cube to extract. Defaults to None"""
26
28
  overwrite: bool = False
27
29
  """overwrite the output file, if it exists"""
28
30
  output_path: Path | None = None
@@ -30,8 +32,8 @@ class ExtractOptions:
30
32
 
31
33
 
32
34
  @dataclass
33
- class FreqWCS:
34
- """Exxtract of frequency information in the WCS taken straight from
35
+ class TargetWCS:
36
+ """Extract of target information in the WCS taken straight from
35
37
  the fits header."""
36
38
 
37
39
  axis: int
@@ -45,21 +47,90 @@ class FreqWCS:
45
47
  cdelt: float
46
48
  """The step between planes"""
47
49
  cunit: str
48
- """the unit of the frequency information"""
50
+ """the unit of the target axis"""
49
51
 
50
52
 
51
- def get_output_path(input_path: Path, channel_index: int) -> Path:
53
+ @dataclass
54
+ class TargetIndex:
55
+ """Simple container to capture the mode to extract."""
56
+
57
+ axis_name: str
58
+ """The name of the axis to search for (e.g. TIME or FREQ)"""
59
+ axis_index: int
60
+ """The index (e.g. channel or timestep) to extract from the cube"""
61
+ output_name: str
62
+ """The output name to put in output files"""
63
+
64
+
65
+ def create_target_index(
66
+ channel_index: int | None = None, time_index: int | None = None
67
+ ) -> TargetIndex:
68
+ """Define the properties of the target axis to subset based on the provided
69
+ index.
70
+
71
+ Args:
72
+ channel_index (int | None, optional): If not None, the frequency channel to extract. Defaults to None.
73
+ time_index (int | None, optional): If not None, the timestep to extract. Defaults to None.
74
+
75
+ Raises:
76
+ ValueError: If both channel and item indices are supplied
77
+ ValueError: If neither channel nor time index are set
78
+
79
+ Returns:
80
+ TargetIndex: Specified properties of target axis to subset
81
+ """
82
+ if time_index is not None and channel_index is not None:
83
+ msg = "Both time and channel index are set. Not allowed."
84
+ raise ValueError(msg)
85
+
86
+ if isinstance(time_index, int):
87
+ return TargetIndex(
88
+ axis_name="TIME", axis_index=time_index, output_name="timestep"
89
+ )
90
+ if isinstance(channel_index, int):
91
+ return TargetIndex(
92
+ axis_name="FREQ", axis_index=channel_index, output_name="channel"
93
+ )
94
+ msg = f"Something went wrong, target index could not be formed, {channel_index=} {time_index=}"
95
+ raise ValueError(msg)
96
+
97
+
98
+ def _check_extract_mode(extract_options: ExtractOptions) -> None:
99
+ """Verify the operation of the extract options axis.
100
+
101
+ Args:
102
+ extract_options (ExtractOptions): The settings providedd to extract fitscube
103
+
104
+ Raises:
105
+ ValueError: Raised if both channel_index and time_index are unset
106
+ ValueError: Raise if both channel index and time index are set
107
+ """
108
+
109
+ if extract_options.channel_index is None and extract_options.time_index is None:
110
+ msg = "Both channel index and time index are None. One needs to be set."
111
+ raise ValueError(msg)
112
+ if (
113
+ extract_options.channel_index is not None
114
+ and extract_options.time_index is not None
115
+ ):
116
+ msg = "Both channel index and time index are set. Only one may be set. "
117
+ raise ValueError(msg)
118
+
119
+
120
+ def get_output_path(input_path: Path, target_index: TargetIndex) -> Path:
52
121
  """Create the output path to write the plane to
53
122
 
54
123
  Args:
55
124
  input_path (Path): The base input path name
56
- channel_index (int): The channel index to extract
125
+ target_index (TargetIndex): The target index to extract
57
126
 
58
127
  Returns:
59
128
  Path: New output path for the plane-only fits image
60
129
  """
130
+ index_suffix = f"{target_index.output_name.lower()}-{target_index.axis_index}"
131
+
61
132
  # The input_path suffix returns a string with a period
62
- channel_suffix = f".channel-{channel_index}{input_path.suffix}"
133
+ channel_suffix = f".{index_suffix}{input_path.suffix}"
63
134
  output_path = input_path.with_suffix(channel_suffix)
64
135
 
65
136
  logger.debug(f"The formed {output_path=}")
@@ -86,14 +157,14 @@ def fits_file_contains_beam_table(header: fits.header.Header | Path) -> bool:
86
157
  return bool(loaded_header["CASAMBM"])
87
158
 
88
159
 
89
- def extract_beam_from_beam_table(fits_path: Path, channel_index: int) -> Beam:
160
+ def extract_beam_from_beam_table(fits_path: Path, index: int) -> Beam:
90
161
  """Extract the beam that corresponds to the channel requested. The beam
91
162
  is drawn from a beam table that is inserted into the FITS cube. It is
92
163
  expected that the beam table exists.
93
164
 
94
165
  Args:
95
166
  fits_path (Path): The fits table to inspect for a beam table, and return the channel beam
96
- channel_index (int): The channel to extract the beam for
167
+ index (int): The channel to extract the beam for
97
168
 
98
169
  Raises:
99
170
  ValueError: Raised when a beam table can not be found
@@ -116,26 +187,38 @@ def extract_beam_from_beam_table(fits_path: Path, channel_index: int) -> Beam:
116
187
 
117
188
  assert beams is not None, "beams is empty, which should not happen"
118
189
 
119
- return beams[channel_index]
190
+ return beams[index]
120
191
 
121
192
 
122
- def find_freq_axis(header: fits.header.Header) -> FreqWCS:
123
- """Attempt to find the axies of the channel in the data
124
- cube that corresponds to frequency/channels.
193
+ def find_target_axis(
194
+ header: fits.header.Header, target_index: TargetIndex | str = "FREQ"
195
+ ) -> TargetWCS:
196
+ """Attempt to find the axies of the target dimension in the data
197
+ cube that corresponds to the target type (e.g. time or frequency).
125
198
 
126
199
  Args:
127
200
  header (fits.header.Header): The header from the fits cube
201
+ target_index (TargetIndex | str): The name of axus to search for in the FITS header representing the axis to extract from. Defaults to FREQ.
128
202
 
129
203
  Returns:
130
- FreqWCS: The information in the FITS header describing the frequency of the cube
204
+ TargetWCS: The information in the FITS header describing the target dimension of the cube
131
205
  """
132
206
 
207
+ axis_name = "FREQ"
208
+ if isinstance(target_index, str):
209
+ axis_name = target_index
210
+ elif isinstance(target_index, TargetIndex):
211
+ axis_name = target_index.axis_name
212
+
213
+ logger.info(f"Searching for {axis_name=} in header")
214
+
133
215
  naxis = header["NAXIS"]
134
- # Remember that range upper limit is exclusive
216
+ # Remember that range upper limit is exclusive, and
217
+ # we start counting from 1
135
218
  for axis in range(1, naxis + 1):
136
- if "FREQ" in header[f"CTYPE{axis}"]:
137
- logger.info(f"Found FREQ at {axis=}")
138
- return FreqWCS(
219
+ if axis_name in header[f"CTYPE{axis}"]:
220
+ logger.info(f"Found {axis_name} at {axis=}")
221
+ return TargetWCS(
139
222
  axis=axis,
140
223
  ctype=header[f"CTYPE{axis}"],
141
224
  crpix=header[f"CRPIX{axis}"],
@@ -145,41 +228,56 @@ def find_freq_axis(header: fits.header.Header) -> FreqWCS:
145
228
  )
146
229
 
147
230
  msg = "Did not find the frequency axis"
148
- raise FREQMissingException(msg)
231
+ raise TargetAxisMissingException(msg)
149
232
 
150
233
 
151
- def create_plane_freq_wcs(original_freq_wcs: FreqWCS, channel_index: int) -> FreqWCS:
152
- """Create the frequency fields appropriate for a extracted channel
234
+ def create_plane_target_wcs(
235
+ original_freq_wcs: TargetWCS, target_index: int | TargetIndex
236
+ ) -> TargetWCS:
237
+ """Create the target fields appropriate for a extracted channel/time index
153
238
 
154
239
  Args:
155
- original_freq_wcs (FreqWCS): The frequency information describing the spectral axis
156
- channel_index (int): The channel to extract
240
+ original_freq_wcs (TargetWCS): The frequency information describing the spectral axis
241
+ target_index (int | TargetIndex): The index to extract from the cube
157
242
 
158
243
  Returns:
159
- FreqWCS: The frequency information for a channel
244
+ TargetWCS: The frequency information for a channel
160
245
  """
161
- channel_freq = original_freq_wcs.crval + (channel_index * original_freq_wcs.cdelt)
162
- return FreqWCS(
246
+ index = target_index if isinstance(target_index, int) else target_index.axis_index
247
+ update_index = original_freq_wcs.crval + (index * original_freq_wcs.cdelt)
248
+ return TargetWCS(
163
249
  axis=original_freq_wcs.axis,
164
250
  ctype=original_freq_wcs.ctype,
165
251
  crpix=1,
166
- crval=channel_freq,
252
+ crval=update_index,
167
253
  cdelt=original_freq_wcs.cdelt,
168
254
  cunit=original_freq_wcs.cunit,
169
255
  )
170
256
 
171
257
 
172
- def update_header_for_frequency(
258
+ def update_header_for_target_axis(
173
259
  header: fits.header.Header,
174
- freq_wcs: FreqWCS,
175
- channel_index: int,
260
+ target_wcs: TargetWCS,
261
+ target_index: TargetIndex,
176
262
  extract_beam_from_file: Path | None = None,
177
263
  ) -> fits.header.Header:
264
+ """Update the base header to indicate the new extracted characteristics
265
+ of the extracted plane.
266
+
267
+ Args:
268
+ header (fits.header.Header): The base header to examine and update
269
+ target_wcs (TargetWCS): The characteristics of the new extracted plane
270
+ channel_index (int): The extracted index
271
+ extract_beam_from_file (Path | None, optional): If not None, attempt to extract the beam table to update the stored beam inforionat. Defaults to None.
272
+
273
+ Returns:
274
+ fits.header.Header: The updated header
275
+ """
178
276
  # Get the new wcs items for the channels
179
- plane_freq_wcs = create_plane_freq_wcs(
180
- original_freq_wcs=freq_wcs, channel_index=channel_index
277
+ plane_freq_wcs = create_plane_target_wcs(
278
+ original_freq_wcs=target_wcs, target_index=target_index.axis_index
181
279
  )
182
- _idx = freq_wcs.axis
280
+ _idx = target_wcs.axis
183
281
  out_header = header.copy()
184
282
  out_header[f"CTYPE{_idx}"] = plane_freq_wcs.ctype
185
283
  out_header[f"CRPIX{_idx}"] = plane_freq_wcs.crpix
@@ -192,7 +290,7 @@ def update_header_for_frequency(
192
290
  ):
193
291
  try:
194
292
  channel_beam: Beam = extract_beam_from_beam_table(
195
- fits_path=extract_beam_from_file, channel_index=channel_index
293
+ fits_path=extract_beam_from_file, index=target_index.axis_index
196
294
  )
197
295
 
198
296
  out_header["BMAJ"] = channel_beam.major.to(u.deg).value
@@ -220,11 +318,20 @@ def extract_plane_from_cube(fits_cube: Path, extract_options: ExtractOptions) ->
220
318
  Returns:
221
319
  Path: The output file
222
320
  """
321
+ # Initial sanity check of the axis to extract
322
+ _check_extract_mode(extract_options=extract_options)
323
+
324
+ target_index = create_target_index(
325
+ channel_index=extract_options.channel_index,
326
+ time_index=extract_options.time_index,
327
+ )
328
+
223
329
  output_path: Path = (
224
330
  extract_options.output_path
225
331
  if extract_options.output_path
226
332
  else get_output_path(
227
- input_path=fits_cube, channel_index=extract_options.channel_index
333
+ input_path=fits_cube,
334
+ target_index=target_index,
228
335
  )
229
336
  )
230
337
 
@@ -241,21 +348,21 @@ def extract_plane_from_cube(fits_cube: Path, extract_options: ExtractOptions) ->
241
348
  logger.info("Extracted header and data")
242
349
 
243
350
  logger.info(f"Data shape: {data.shape}")
244
- freq_axis_wcs = find_freq_axis(header=header)
245
- freq_cube_index = len(data.shape) - freq_axis_wcs.axis
351
+ target_axis_wcs = find_target_axis(header=header, target_index=target_index)
352
+ target_cube_index = len(data.shape) - target_axis_wcs.axis
246
353
 
247
- if extract_options.channel_index > data.shape[freq_axis_wcs.axis] - 1:
248
- msg = f"{extract_options.channel_index=} outside of channel cube {data.shape=}"
354
+ if target_index.axis_index > data.shape[target_cube_index]:
355
+ msg = f"{extract_options.channel_index=} outside of channel cube {data.shape=}, axis shape of {data.shape[target_axis_wcs.axis - 1]}"
249
356
  raise ChannelMissingException(msg)
250
357
 
251
358
  # Get the channel index requested
252
- freq_plane_data = np.take(data, extract_options.channel_index, axis=freq_cube_index)
359
+ freq_plane_data = np.take(data, target_index.axis_index, axis=target_cube_index)
253
360
  # and pad it back so dimensions match
254
- freq_plane_data = np.expand_dims(freq_plane_data, axis=freq_cube_index)
255
- freq_plane_header = update_header_for_frequency(
361
+ freq_plane_data = np.expand_dims(freq_plane_data, axis=target_cube_index)
362
+ freq_plane_header = update_header_for_target_axis(
256
363
  header=header,
257
- freq_wcs=freq_axis_wcs,
258
- channel_index=extract_options.channel_index,
364
+ target_wcs=target_axis_wcs,
365
+ target_index=target_index,
259
366
  extract_beam_from_file=fits_cube
260
367
  if fits_file_contains_beam_table(header) # replace with "BEAMS" in opened fits?
261
368
  else None,
@@ -281,7 +388,10 @@ def get_parser(parser: ArgumentParser | None = None) -> ArgumentParser:
281
388
 
282
389
  parser.add_argument("fits_cube", type=Path, help="The cube to extract a plane from")
283
390
  parser.add_argument(
284
- "--channel-index", type=int, default=0, help="The channel to extract"
391
+ "--channel-index", type=int, default=None, help="The channel to extract"
392
+ )
393
+ parser.add_argument(
394
+ "--time-index", type=int, default=None, help="The channel to extract"
285
395
  )
286
396
  parser.add_argument(
287
397
  "--hdu-index",
@@ -315,6 +425,7 @@ def cli(args: Namespace | None = None) -> None:
315
425
  extract_options = ExtractOptions(
316
426
  hdu_index=args.hdu_index,
317
427
  channel_index=args.channel_index,
428
+ time_index=args.time_index,
318
429
  overwrite=args.overwrite,
319
430
  output_path=args.output_path,
320
431
  )
@@ -21,6 +21,16 @@ def headers() -> dict[str, str]:
21
21
  return {"base": EXAMPLE_HEADER, "beams": EXAMPLE_HEADER_WITH_BM}
22
22
 
23
23
 
24
+ @pytest.fixture
25
+ def timecube_path(tmpdir) -> Path:
26
+ tmp_dir = Path(tmpdir) / "timecube"
27
+ tmp_dir.mkdir(exist_ok=True, parents=True)
28
+ cube_zip = Path(__file__).parent / "data" / "timecube.zip"
29
+
30
+ unpack_archive(cube_zip, tmp_dir)
31
+ return tmp_dir / "test_timecube.fits"
32
+
33
+
24
34
  @pytest.fixture
25
35
  def cube_path(tmpdir) -> Path:
26
36
  tmp_dir = Path(tmpdir) / "cube"
@@ -42,3 +52,16 @@ def image_paths(tmpdir) -> list[Path]:
42
52
  image_paths.sort()
43
53
 
44
54
  return image_paths
55
+
56
+
57
+ @pytest.fixture
58
+ def time_image_paths(tmpdir) -> list[Path]:
59
+ tmp_dir = Path(tmpdir) / "time_images"
60
+ tmp_dir.mkdir(exist_ok=True, parents=True)
61
+ images_zip = Path(__file__).parent / "data" / "time_images.zip"
62
+
63
+ unpack_archive(images_zip, tmp_dir)
64
+ image_paths = list(tmp_dir.glob("*fits"))
65
+ image_paths.sort()
66
+
67
+ return image_paths
Binary file
@@ -7,27 +7,74 @@ from pathlib import Path
7
7
  import numpy as np
8
8
  import pytest
9
9
  from astropy.io import fits
10
- from fitscube.exceptions import ChannelMissingException
10
+ from fitscube.exceptions import ChannelMissingException, TargetAxisMissingException
11
11
  from fitscube.extract import (
12
12
  ExtractOptions,
13
- create_plane_freq_wcs,
13
+ TargetIndex,
14
+ _check_extract_mode,
15
+ create_plane_target_wcs,
16
+ create_target_index,
14
17
  extract_plane_from_cube,
15
- find_freq_axis,
18
+ find_target_axis,
16
19
  fits_file_contains_beam_table,
17
20
  get_output_path,
18
- update_header_for_frequency,
21
+ update_header_for_target_axis,
19
22
  )
20
23
 
21
24
 
25
+ def test_target_index() -> None:
26
+ """Simple test of the target container"""
27
+ target_index = create_target_index(channel_index=2)
28
+ assert isinstance(target_index, TargetIndex)
29
+ assert target_index.axis_name == "FREQ"
30
+ assert target_index.axis_index == 2
31
+
32
+ target_index = create_target_index(time_index=20)
33
+ assert isinstance(target_index, TargetIndex)
34
+ assert target_index.axis_name == "TIME"
35
+ assert target_index.axis_index == 20
36
+
37
+ # int of 0 is False
38
+ target_index = create_target_index(time_index=0)
39
+ assert isinstance(target_index, TargetIndex)
40
+ assert target_index.axis_name == "TIME"
41
+ assert target_index.axis_index == 0
42
+
43
+ with pytest.raises(ValueError, match="index"):
44
+ create_target_index()
45
+
46
+ with pytest.raises(ValueError, match="index"):
47
+ create_target_index(channel_index=2, time_index=4)
48
+
49
+
50
+ def test_check_mode_for_consistency() -> None:
51
+ """See if the basic consistency checks for the extraction mode makes sense"""
52
+ extract_options = ExtractOptions()
53
+ with pytest.raises(ValueError, match="index"):
54
+ _check_extract_mode(extract_options=extract_options)
55
+
56
+ extract_options = ExtractOptions(channel_index=3, time_index=4)
57
+ with pytest.raises(ValueError, match="index"):
58
+ _check_extract_mode(extract_options=extract_options)
59
+
60
+
22
61
  def test_get_output_path() -> None:
23
62
  """Make sure the output path generated is correct"""
24
63
 
64
+ target_index = create_target_index(channel_index=10)
25
65
  in_fits = Path("some.example.cube.fits")
26
- channel_index = 10
27
66
  expected_fits = Path("some.example.cube.channel-10.fits")
28
67
 
29
68
  assert expected_fits == get_output_path(
30
- input_path=in_fits, channel_index=channel_index
69
+ input_path=in_fits, target_index=target_index
70
+ )
71
+
72
+ target_index = create_target_index(time_index=10)
73
+ in_fits = Path("some.example.cube.fits")
74
+ expected_fits = Path("some.example.cube.timestep-10.fits")
75
+
76
+ assert expected_fits == get_output_path(
77
+ input_path=in_fits, target_index=target_index
31
78
  )
32
79
 
33
80
 
@@ -43,7 +90,7 @@ def test_find_freq_axis(example_header) -> None:
43
90
  """Find the components associated with frequency from the header"""
44
91
  header = fits.header.Header.fromstring(example_header)
45
92
 
46
- freq_wcs = find_freq_axis(header=header)
93
+ freq_wcs = find_target_axis(header=header)
47
94
  assert freq_wcs.axis == 4
48
95
  assert freq_wcs.crpix == 1
49
96
  assert freq_wcs.crval == 801490740.740741
@@ -54,15 +101,15 @@ def test_create_plane_freq_wcs(example_header) -> None:
54
101
  """Update the freq wcs to indicate a plane"""
55
102
  header = fits.header.Header.fromstring(example_header)
56
103
 
57
- freq_wcs = find_freq_axis(header=header)
58
- plane_wcs = create_plane_freq_wcs(original_freq_wcs=freq_wcs, channel_index=1)
104
+ freq_wcs = find_target_axis(header=header)
105
+ plane_wcs = create_plane_target_wcs(original_freq_wcs=freq_wcs, target_index=1)
59
106
 
60
107
  assert plane_wcs.axis == freq_wcs.axis
61
108
  assert plane_wcs.crpix == 1
62
109
  assert plane_wcs.crval == 805490740.740741
63
110
  assert plane_wcs.cdelt == freq_wcs.cdelt
64
111
 
65
- plane_wcs = create_plane_freq_wcs(original_freq_wcs=freq_wcs, channel_index=0)
112
+ plane_wcs = create_plane_target_wcs(original_freq_wcs=freq_wcs, target_index=0)
66
113
 
67
114
  assert plane_wcs.axis == freq_wcs.axis
68
115
  assert plane_wcs.crpix == 1
@@ -76,10 +123,10 @@ def test_update_header_for_frequency(example_header) -> None:
76
123
 
77
124
  header = fits.header.Header.fromstring(example_header)
78
125
 
79
- freq_wcs = find_freq_axis(header=header)
80
-
81
- new_header = update_header_for_frequency(
82
- header=header, freq_wcs=freq_wcs, channel_index=1
126
+ freq_wcs = find_target_axis(header=header)
127
+ target_index = create_target_index(channel_index=1)
128
+ new_header = update_header_for_target_axis(
129
+ header=header, target_wcs=freq_wcs, target_index=target_index
83
130
  )
84
131
  assert new_header["CRPIX4"] == 1
85
132
  assert new_header["CRVAL4"] == 805490740.740741
@@ -167,3 +214,37 @@ def test_compare_extracted_to_image_bad_channel(cube_path, tmpdir) -> None:
167
214
  )
168
215
  with pytest.raises(ChannelMissingException):
169
216
  extract_plane_from_cube(fits_cube=cube_path, extract_options=extract_options)
217
+
218
+
219
+ def test_extract_time_from_freq(cube_path, tmpdir) -> None:
220
+ """Attempt to extract a timestep from a cube without TIME. Should
221
+ raise an error
222
+ """
223
+ output_file = Path(tmpdir) / "extract" / "test.fits"
224
+ output_file.parent.mkdir(parents=True, exist_ok=True)
225
+ timestep = 0
226
+
227
+ extract_options = ExtractOptions(
228
+ hdu_index=0, time_index=timestep, output_path=output_file
229
+ )
230
+
231
+ with pytest.raises(TargetAxisMissingException):
232
+ extract_plane_from_cube(fits_cube=cube_path, extract_options=extract_options)
233
+
234
+
235
+ def test_extract_time_cube(timecube_path, tmpdir) -> None:
236
+ """Attempt to extract a timestep from a cube without TIME. Should
237
+ raise an error
238
+ """
239
+ output_file = Path(tmpdir) / "extract" / "test.fits"
240
+ output_file.parent.mkdir(parents=True, exist_ok=True)
241
+ timestep = 200
242
+
243
+ extract_options = ExtractOptions(
244
+ hdu_index=0, time_index=timestep, output_path=output_file
245
+ )
246
+
247
+ timecube = extract_plane_from_cube(
248
+ fits_cube=timecube_path, extract_options=extract_options
249
+ )
250
+ assert timecube.exists()
@@ -126,3 +126,26 @@ def test_uneven_combine(
126
126
  assert chan in (1, 2)
127
127
  continue
128
128
  assert np.allclose(plane, image)
129
+
130
+
131
+ @pytest.mark.filterwarnings("ignore:'datfix' made the change")
132
+ def test_wsclean_images_create_axis(time_image_paths, tmpdir) -> None:
133
+ """Ensure that the combined cube conforms to the input data"""
134
+
135
+ tmpdir = Path(tmpdir) / "time_cube_combine"
136
+ tmpdir.mkdir(parents=True, exist_ok=True)
137
+ out_cube = tmpdir / "time_cube_mate.fits"
138
+
139
+ combine_fits(
140
+ file_list=time_image_paths,
141
+ out_cube=out_cube,
142
+ overwrite=True,
143
+ time_domain_mode=True,
144
+ )
145
+
146
+ cube_data = fits.getdata(out_cube)
147
+ for i, time_image_path in enumerate(time_image_paths):
148
+ image_data = fits.getdata(time_image_path)
149
+ # The TIME axis will be appended as a new dimension
150
+ cube_image_data = cube_data[i]
151
+ assert np.allclose(image_data.squeeze(), cube_image_data.squeeze())
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes