fitscube 2.3.2__tar.gz → 2.4.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 (36) hide show
  1. {fitscube-2.3.2 → fitscube-2.4.0}/.github/workflows/cd.yml +4 -4
  2. {fitscube-2.3.2 → fitscube-2.4.0}/.github/workflows/ci.yml +5 -5
  3. {fitscube-2.3.2 → fitscube-2.4.0}/PKG-INFO +1 -1
  4. {fitscube-2.3.2 → fitscube-2.4.0}/fitscube/__init__.py +2 -1
  5. {fitscube-2.3.2 → fitscube-2.4.0}/fitscube/_version.py +2 -2
  6. {fitscube-2.3.2 → fitscube-2.4.0}/fitscube/bounding_box.py +60 -8
  7. {fitscube-2.3.2 → fitscube-2.4.0}/fitscube/combine_fits.py +172 -71
  8. {fitscube-2.3.2 → fitscube-2.4.0}/fitscube/exceptions.py +8 -0
  9. {fitscube-2.3.2 → fitscube-2.4.0}/tests/conftest.py +70 -0
  10. fitscube-2.4.0/tests/test_combine.py +44 -0
  11. fitscube-2.4.0/tests/test_combine_guards.py +209 -0
  12. {fitscube-2.3.2 → fitscube-2.4.0}/tests/test_times.py +0 -41
  13. {fitscube-2.3.2 → fitscube-2.4.0}/.github/CONTRIBUTING.md +0 -0
  14. {fitscube-2.3.2 → fitscube-2.4.0}/.github/dependabot.yml +0 -0
  15. {fitscube-2.3.2 → fitscube-2.4.0}/.github/release.yml +0 -0
  16. {fitscube-2.3.2 → fitscube-2.4.0}/.gitignore +0 -0
  17. {fitscube-2.3.2 → fitscube-2.4.0}/.pre-commit-config.yaml +0 -0
  18. {fitscube-2.3.2 → fitscube-2.4.0}/CHANGELOG.md +0 -0
  19. {fitscube-2.3.2 → fitscube-2.4.0}/LICENSE +0 -0
  20. {fitscube-2.3.2 → fitscube-2.4.0}/README.md +0 -0
  21. {fitscube-2.3.2 → fitscube-2.4.0}/fitscube/asyncio.py +0 -0
  22. {fitscube-2.3.2 → fitscube-2.4.0}/fitscube/cli.py +0 -0
  23. {fitscube-2.3.2 → fitscube-2.4.0}/fitscube/extract.py +0 -0
  24. {fitscube-2.3.2 → fitscube-2.4.0}/fitscube/logging.py +0 -0
  25. {fitscube-2.3.2 → fitscube-2.4.0}/fitscube/version.pyi +0 -0
  26. {fitscube-2.3.2 → fitscube-2.4.0}/noxfile.py +0 -0
  27. {fitscube-2.3.2 → fitscube-2.4.0}/pyproject.toml +0 -0
  28. {fitscube-2.3.2 → fitscube-2.4.0}/tests/__init__.py +0 -0
  29. {fitscube-2.3.2 → fitscube-2.4.0}/tests/data/cube.zip +0 -0
  30. {fitscube-2.3.2 → fitscube-2.4.0}/tests/data/images.zip +0 -0
  31. {fitscube-2.3.2 → fitscube-2.4.0}/tests/data/time_images.zip +0 -0
  32. {fitscube-2.3.2 → fitscube-2.4.0}/tests/data/timecube.zip +0 -0
  33. {fitscube-2.3.2 → fitscube-2.4.0}/tests/test_bb.py +0 -0
  34. {fitscube-2.3.2 → fitscube-2.4.0}/tests/test_extract.py +0 -0
  35. {fitscube-2.3.2 → fitscube-2.4.0}/tests/test_frequencies.py +0 -0
  36. {fitscube-2.3.2 → fitscube-2.4.0}/tests/test_package.py +0 -0
@@ -25,11 +25,11 @@ jobs:
25
25
  runs-on: ubuntu-latest
26
26
 
27
27
  steps:
28
- - uses: actions/checkout@v5
28
+ - uses: actions/checkout@v7
29
29
  with:
30
30
  fetch-depth: 0
31
31
 
32
- - uses: hynek/build-and-inspect-python-package@v2
32
+ - uses: hynek/build-and-inspect-python-package@v2.18.0
33
33
 
34
34
  publish:
35
35
  needs: [dist]
@@ -43,13 +43,13 @@ jobs:
43
43
  if: github.event_name == 'release' && github.event.action == 'published'
44
44
 
45
45
  steps:
46
- - uses: actions/download-artifact@v5
46
+ - uses: actions/download-artifact@v8
47
47
  with:
48
48
  name: Packages
49
49
  path: dist
50
50
 
51
51
  - name: Generate artifact attestation for sdist and wheel
52
- uses: actions/attest-build-provenance@v2.4.0
52
+ uses: actions/attest-build-provenance@v4.1.1
53
53
  with:
54
54
  subject-path: "dist/*"
55
55
 
@@ -21,10 +21,10 @@ jobs:
21
21
  name: Format
22
22
  runs-on: ubuntu-latest
23
23
  steps:
24
- - uses: actions/checkout@v5
24
+ - uses: actions/checkout@v7
25
25
  with:
26
26
  fetch-depth: 0
27
- - uses: actions/setup-python@v5
27
+ - uses: actions/setup-python@v7
28
28
  with:
29
29
  python-version: "3.x"
30
30
  - uses: pre-commit/action@v3.0.1
@@ -42,11 +42,11 @@ jobs:
42
42
  runs-on: [ubuntu-latest]
43
43
 
44
44
  steps:
45
- - uses: actions/checkout@v5
45
+ - uses: actions/checkout@v7
46
46
  with:
47
47
  fetch-depth: 0
48
48
 
49
- - uses: actions/setup-python@v5
49
+ - uses: actions/setup-python@v7
50
50
  with:
51
51
  python-version: ${{ matrix.python-version }}
52
52
  allow-prereleases: true
@@ -62,6 +62,6 @@ jobs:
62
62
  --durations=20
63
63
 
64
64
  - name: Upload coverage report
65
- uses: codecov/codecov-action@v5.5.0
65
+ uses: codecov/codecov-action@v7.0.0
66
66
  with:
67
67
  token: ${{ secrets.CODECOV_TOKEN }}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fitscube
3
- Version: 2.3.2
3
+ Version: 2.4.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
@@ -2,8 +2,9 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ from fitscube.bounding_box import BoundingBox, get_common_bounding_box
5
6
  from fitscube.combine_fits import combine_fits
6
7
 
7
8
  from ._version import version as __version__
8
9
 
9
- __all__ = ["__version__", "combine_fits"]
10
+ __all__ = ["BoundingBox", "__version__", "combine_fits", "get_common_bounding_box"]
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '2.3.2'
22
- __version_tuple__ = version_tuple = (2, 3, 2)
21
+ __version__ = version = '2.4.0'
22
+ __version_tuple__ = version_tuple = (2, 4, 0)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -10,28 +10,43 @@ from pathlib import Path
10
10
  import numpy as np
11
11
  from astropy.io import fits
12
12
 
13
+ from fitscube.asyncio import gather_with_limit, sync_wrapper
13
14
  from fitscube.logging import logger
14
15
 
15
16
 
16
17
  @dataclass(frozen=True)
17
18
  class BoundingBox:
18
- """Simple container to represent a bounding box. Maximum values can be
19
- used as is when slicing."""
19
+ """Simple container to represent a bounding box.
20
+
21
+ .. warning::
22
+ ``x`` and ``y`` here are the *numpy* axes, which are the reverse of the
23
+ FITS ``NAXIS`` convention:
24
+
25
+ - ``x`` is axis ``-2`` -- image rows -- which is ``NAXIS2`` (declination)
26
+ - ``y`` is axis ``-1`` -- image columns -- which is ``NAXIS1`` (right ascension)
27
+
28
+ So a plane is sliced as ``data[..., xmin:xmax, ymin:ymax]`` and the
29
+ trimmed header takes ``NAXIS1 = y_span``, ``NAXIS2 = x_span``.
30
+
31
+ Minimum values are inclusive and maximum values are exclusive, so both can
32
+ be used as is when slicing. Note that ``xmax``/``ymax`` were *inclusive*
33
+ prior to ``v2.3.2``.
34
+ """
20
35
 
21
36
  xmin: int
22
- """Minimum x pixel"""
37
+ """Minimum row pixel (numpy axis -2, FITS NAXIS2). Inclusive."""
23
38
  xmax: int
24
- """Maximum x pixel"""
39
+ """Maximum row pixel (numpy axis -2, FITS NAXIS2). Can be used as is in a slice (e.g. is exclusive)."""
25
40
  ymin: int
26
- """Minimum y pixel. Can be used as is in slice (e.g. is exclusive). """
41
+ """Minimum column pixel (numpy axis -1, FITS NAXIS1). Inclusive."""
27
42
  ymax: int
28
- """Maximum y pixel Can be used as is in slice (e.g. is exclusive)."""
43
+ """Maximum column pixel (numpy axis -1, FITS NAXIS1). Can be used as is in a slice (e.g. is exclusive)."""
29
44
  original_shape: tuple[int, int]
30
45
  """The original shape of the image. If constructed against a cube this is the shape of a single plane."""
31
46
  y_span: int
32
- """The span between ymax and ymin"""
47
+ """The span between ymax and ymin (i.e. the trimmed NAXIS1)"""
33
48
  x_span: int
34
- """The span between xmax and xmin"""
49
+ """The span between xmax and xmin (i.e. the trimmed NAXIS2)"""
35
50
 
36
51
 
37
52
  def create_bound_box_plane(image_data: np.ndarray) -> BoundingBox | None:
@@ -151,3 +166,40 @@ async def get_bounding_box_for_fits_coro(
151
166
  data[data == 0.0] = np.nan
152
167
 
153
168
  return await asyncio.to_thread(create_bound_box_plane, image_data=data)
169
+
170
+
171
+ async def get_common_bounding_box_coro(
172
+ file_list: list[Path],
173
+ invalidate_zeros: bool = False,
174
+ max_workers: int | None = None,
175
+ ) -> BoundingBox:
176
+ """Compute the single bounding box that encompasses the valid data of every
177
+ image in ``file_list``.
178
+
179
+ This is the box that ``combine_fits`` computes internally when
180
+ ``bounding_box=True``. Compute it once with this function and pass the
181
+ result to ``combine_fits(bounding_box=...)`` when several cubes (e.g. an
182
+ image cube and its weights cube) must land on an identical pixel grid.
183
+
184
+ Args:
185
+ file_list (list[Path]): The FITS images to consider
186
+ invalidate_zeros (bool, optional): Mark pixels that are exactly 0.0 as invalid (NaN them). Defaults to False.
187
+ max_workers (int | None, optional): Maximum number of concurrent reads. Defaults to None.
188
+
189
+ Returns:
190
+ BoundingBox: The smallest bounding box that contains all valid data
191
+ """
192
+ boxes = await gather_with_limit(
193
+ max_workers,
194
+ *(
195
+ get_bounding_box_for_fits_coro(
196
+ fits_path=fits_path, invalidate_zeros=invalidate_zeros
197
+ )
198
+ for fits_path in file_list
199
+ ),
200
+ desc="Bounding boxes",
201
+ )
202
+ return extract_common_bounding_box(bounding_boxes=boxes)
203
+
204
+
205
+ get_common_bounding_box = sync_wrapper(get_common_bounding_box_coro)
@@ -3,7 +3,7 @@
3
3
 
4
4
  Assumes:
5
5
  - All files have the same WCS
6
- - All files have the same shape / pixel grid
6
+ - All files have the same shape / pixel grid (checked, see `check_matching_shapes`)
7
7
  - All the relevant information is in the first header of the first image
8
8
  - Frequency is either a WCS axis or in the REFFREQ header keyword OR
9
9
  - Time is present in the DATE-OBS header keyword for time-domain-mode
@@ -16,7 +16,7 @@ import asyncio
16
16
  import warnings
17
17
  from io import BufferedRandom
18
18
  from pathlib import Path
19
- from typing import Literal, NamedTuple, TypeVar
19
+ from typing import Any, Literal, NamedTuple, TypeVar
20
20
 
21
21
  import astropy.units as u
22
22
  import numpy as np
@@ -25,7 +25,7 @@ from astropy.io.fits.verify import VerifyWarning
25
25
  from astropy.table import Table
26
26
  from astropy.time import Time
27
27
  from astropy.wcs import WCS
28
- from numpy.typing import ArrayLike
28
+ from numpy.typing import ArrayLike, NDArray
29
29
  from radio_beam import Beam, Beams
30
30
  from radio_beam.beam import NoBeamException
31
31
  from tqdm.asyncio import tqdm
@@ -33,9 +33,9 @@ from tqdm.asyncio import tqdm
33
33
  from fitscube.asyncio import gather_with_limit, sync_wrapper
34
34
  from fitscube.bounding_box import (
35
35
  BoundingBox,
36
- extract_common_bounding_box,
37
- get_bounding_box_for_fits_coro,
36
+ get_common_bounding_box_coro,
38
37
  )
38
+ from fitscube.exceptions import AxisOrderException, ShapeMismatchException
39
39
  from fitscube.logging import TQDM_OUT, logger, set_verbosity
40
40
 
41
41
  T = TypeVar("T")
@@ -73,7 +73,7 @@ class SpequencyInfo(NamedTuple):
73
73
 
74
74
  specs: u.Quantity
75
75
  """Frequencies/Times"""
76
- missing_chan_idx: ArrayLike
76
+ missing_chan_idx: NDArray[np.bool_]
77
77
  """Missing channel indices"""
78
78
 
79
79
 
@@ -84,13 +84,19 @@ class FileSpequencyInfo(NamedTuple):
84
84
  """Frequencies or times matching each file"""
85
85
  specs: u.Quantity
86
86
  """Frequency/time in Hz or s"""
87
- missing_chan_idx: ArrayLike
87
+ missing_chan_idx: NDArray[np.bool_]
88
88
  """Missing channel/time indices"""
89
89
 
90
90
 
91
91
  async def write_channel_to_cube_coro(
92
- file_handle: BufferedRandom, plane: ArrayLike, chan: int, header: fits.Header
92
+ file_handle: BufferedRandom,
93
+ plane: NDArray[np.floating[Any]],
94
+ chan: int,
95
+ header: fits.Header,
93
96
  ) -> None:
97
+ # Seeking by whole planes is only valid when every plane is the same size
98
+ # (enforced by check_matching_shapes_coro) and the spectral axis is the
99
+ # slowest-varying one (enforced by create_output_cube_coro).
94
100
  msg = f"Writing channel {chan} to cube"
95
101
  logger.info(msg)
96
102
  seek_length = len(header.tostring()) + (plane.nbytes * chan)
@@ -101,8 +107,58 @@ async def write_channel_to_cube_coro(
101
107
  write_channel_to_cube = sync_wrapper(write_channel_to_cube_coro)
102
108
 
103
109
 
110
+ async def check_matching_shapes_coro(
111
+ file_list: list[Path],
112
+ max_workers: int | None = None,
113
+ ) -> tuple[int, int]:
114
+ """Confirm every input image shares the same NAXIS1/NAXIS2 pixel grid.
115
+
116
+ Planes are written to the cube at a fixed byte offset per channel, so
117
+ inputs of differing shape would silently slide against each other and
118
+ produce a scrambled cube with a self-consistent header.
119
+
120
+ Args:
121
+ file_list (list[Path]): The FITS images to check
122
+ max_workers (int | None, optional): Maximum number of concurrent header reads. Defaults to None.
123
+
124
+ Raises:
125
+ ShapeMismatchException: If any image differs in shape from the first
126
+
127
+ Returns:
128
+ tuple[int, int]: The common (NAXIS1, NAXIS2)
129
+ """
130
+ headers = await gather_with_limit(
131
+ max_workers,
132
+ *(asyncio.to_thread(fits.getheader, fits_path) for fits_path in file_list),
133
+ desc="Checking shapes",
134
+ )
135
+ shapes = [(header["NAXIS1"], header["NAXIS2"]) for header in headers]
136
+ expected = shapes[0]
137
+ offenders = [
138
+ f"{fits_path} has (NAXIS1, NAXIS2)={shape}"
139
+ for fits_path, shape in zip(file_list, shapes)
140
+ if shape != expected
141
+ ]
142
+ if offenders:
143
+ # Keep the message readable when a whole run is mismatched
144
+ shown = offenders[:10]
145
+ if len(offenders) > len(shown):
146
+ shown.append(f"...and {len(offenders) - len(shown)} more")
147
+ listing = "\n".join(shown)
148
+ msg = (
149
+ "All input images must share the same pixel grid. Expected "
150
+ f"(NAXIS1, NAXIS2)={expected} from {file_list[0]}, but found:\n{listing}"
151
+ )
152
+ raise ShapeMismatchException(msg)
153
+
154
+ return expected
155
+
156
+
157
+ check_matching_shapes = sync_wrapper(check_matching_shapes_coro)
158
+
159
+
104
160
  # https://stackoverflow.com/a/66082278
105
- def np_arange_fix(start: float, stop: float, step: float) -> ArrayLike:
161
+ def np_arange_fix(start: float, stop: float, step: float) -> NDArray[np.float64]:
106
162
  n = (stop - start) / step + 1
107
163
  x = n - int(n)
108
164
  stop += step * max(0.1, x) if x < 0.5 else 0
@@ -110,16 +166,18 @@ def np_arange_fix(start: float, stop: float, step: float) -> ArrayLike:
110
166
 
111
167
 
112
168
  def isin_close(
113
- element: ArrayLike, test_element: ArrayLike, time_domain_mode: bool = False
114
- ) -> ArrayLike:
169
+ element: NDArray[np.floating[Any]],
170
+ test_element: ArrayLike,
171
+ time_domain_mode: bool = False,
172
+ ) -> NDArray[np.bool_]:
115
173
  """Check if element is in test_element, within a tolerance.
116
174
 
117
175
  Args:
118
- element (ArrayLike): Element to check
176
+ element (NDArray[np.floating[Any]]): Element to check
119
177
  test_element (ArrayLike): Element to check against
120
178
 
121
179
  Returns:
122
- ArrayLike: Boolean array
180
+ NDArray[np.bool_]: Boolean array
123
181
  """
124
182
  if time_domain_mode:
125
183
  # the following should be sufficient to test integration times to ~5ms accuracy
@@ -210,23 +268,7 @@ async def create_cube_from_scratch_coro(
210
268
  msg = f"Creating a new FITS file with shape {output_shape}"
211
269
  logger.info(msg)
212
270
 
213
- # If the output shape is less than 1801, we can create a blank array
214
- # in memory and write it to disk
215
- if np.prod(output_shape) < 1801:
216
- msg = "Output cube is small enough to create in memory"
217
- logger.warning(msg)
218
- out_arr = np.zeros(output_shape)
219
- fits.writeto(output_file, out_arr, output_header, overwrite=overwrite)
220
- with fits.open(output_file, mode="denywrite", memmap=True) as hdu_list:
221
- hdu = hdu_list[0]
222
- data = hdu.data
223
- on_disk_shape = data.shape
224
- assert data.shape == output_shape, (
225
- f"Output shape {on_disk_shape} does not match header {output_shape}!"
226
- )
227
- return fits.getheader(output_file)
228
-
229
- logger.info("Output cube is too large to create in memory. Creating a blank file.")
271
+ logger.info("Creating a blank file.")
230
272
 
231
273
  small_size = [1 for _ in output_shape]
232
274
  data = np.zeros(small_size)
@@ -387,7 +429,7 @@ async def create_output_cube_coro(
387
429
 
388
430
  if ignore_spec or not even_spec:
389
431
  logger.info(
390
- f"Ignore the specrency information, {ignore_spec=} or {not even_spec=}"
432
+ f"Ignore the spequency information, {ignore_spec=} or {not even_spec=}"
391
433
  )
392
434
  new_header[f"CDELT{fits_idx}"] = 1
393
435
  del new_header[f"CUNIT{fits_idx}"]
@@ -402,15 +444,17 @@ async def create_output_cube_coro(
402
444
  "Full beam information is stored in the second FITS extension."
403
445
  )
404
446
  new_header["COMMENT"] = (
405
- f"The value '{tiny}' repsenents a NaN PSF in the beamtable."
447
+ f"The value '{tiny}' represents a NaN PSF in the beamtable."
406
448
  )
407
- del new_header["BMAJ"], new_header["BMIN"], new_header["BPA"]
449
+ for k in ("BMAJ", "BMIN", "BPA"):
450
+ new_header.pop(k, None)
408
451
 
409
452
  if bounding_box:
410
453
  logger.info("Updating CRPIX1 and CRPIX2 header values to reflect bounding box")
454
+ # NOTE: BoundingBox x/y are numpy axes, so x -> NAXIS2 and y -> NAXIS1
411
455
  new_header["CRPIX1"] -= bounding_box.ymin
412
456
  new_header["CRPIX2"] -= bounding_box.xmin
413
- logger.info("Updating NAXIS1 and NAXIS2 ro reflect bounding box")
457
+ logger.info("Updating NAXIS1 and NAXIS2 to reflect bounding box")
414
458
  new_header["NAXIS1"] = bounding_box.y_span
415
459
  new_header["NAXIS2"] = bounding_box.x_span
416
460
 
@@ -421,7 +465,7 @@ async def create_output_cube_coro(
421
465
  else:
422
466
  cube_shape[idx] = n_chan
423
467
 
424
- logger.critical(f"{float_length=} {new_header['BITPIX']=}")
468
+ logger.debug(f"{float_length=} {new_header['BITPIX']=}")
425
469
  if float_length is not None:
426
470
  # Per astropy docs
427
471
  # bITPIX numpy data type
@@ -438,6 +482,21 @@ async def create_output_cube_coro(
438
482
  logger.info(f"Specified {float_length=}, corresponding to {bit_pix=}")
439
483
  new_header["BITPIX"] = -abs(bit_pix)
440
484
 
485
+ # Planes are written at an offset of (plane bytes * channel), which only
486
+ # lands on the right plane when the spectral axis is the slowest-varying
487
+ # one - i.e. every axis above it in the cube is degenerate.
488
+ trailing_axes = {
489
+ f"NAXIS{axis}": new_header[f"NAXIS{axis}"]
490
+ for axis in range(fits_idx + 1, new_header["NAXIS"] + 1)
491
+ }
492
+ if any(naxis != 1 for naxis in trailing_axes.values()):
493
+ msg = (
494
+ f"The {ctype} axis (NAXIS{fits_idx}) must be the slowest-varying axis of "
495
+ f"the output cube, but non-degenerate axes sit above it: {trailing_axes}. "
496
+ "Reorder the axes of the input images before combining."
497
+ )
498
+ raise AxisOrderException(msg)
499
+
441
500
  output_header = await create_cube_from_scratch_coro(
442
501
  output_file=out_cube, output_header=new_header, overwrite=overwrite
443
502
  )
@@ -685,26 +744,14 @@ def make_beam_table(beams: Beams, old_header: fits.Header) -> fits.BinTableHDU:
685
744
  return tab_hdu
686
745
 
687
746
 
688
- async def process_channel(
689
- file_handle: BufferedRandom,
690
- new_header: fits.Header,
691
- new_channel: int,
692
- old_channel: int,
693
- is_missing: bool,
694
- file_list: list[Path],
747
+ def load_and_preprocess_fits_data(
748
+ file_path: Path,
695
749
  bounding_box: BoundingBox | None = None,
696
750
  invalidate_zeros: bool = False,
697
- ) -> None:
698
- msg = f"Processing channel {new_channel}"
699
- logger.info(msg)
751
+ wipe_with_nan: bool = False,
752
+ ) -> NDArray[np.floating[Any]]:
700
753
  # Use memmap=False to force the data to be read into memory - gives a speedup
701
- if is_missing:
702
- plane = await asyncio.to_thread(fits.getdata, file_list[0], memamp=False)
703
- plane *= np.nan
704
- else:
705
- plane = await asyncio.to_thread(
706
- fits.getdata, file_list[old_channel], memmap=False
707
- )
754
+ plane: NDArray[np.floating[Any]] = fits.getdata(filename=file_path, memmap=False)
708
755
 
709
756
  if bounding_box is not None:
710
757
  plane = plane[
@@ -712,9 +759,39 @@ async def process_channel(
712
759
  bounding_box.xmin : bounding_box.xmax,
713
760
  bounding_box.ymin : bounding_box.ymax,
714
761
  ]
762
+
763
+ # Bail out early if we need to replace with nans
764
+ if wipe_with_nan:
765
+ plane *= np.nan
766
+ return plane
767
+
715
768
  if invalidate_zeros:
716
769
  plane[plane == 0.0] = np.nan
717
770
 
771
+ return plane
772
+
773
+
774
+ async def process_channel(
775
+ file_handle: BufferedRandom,
776
+ new_header: fits.Header,
777
+ new_channel: int,
778
+ old_channel: int,
779
+ is_missing: bool,
780
+ file_list: list[Path],
781
+ bounding_box: BoundingBox | None = None,
782
+ invalidate_zeros: bool = False,
783
+ ) -> None:
784
+ msg = f"Processing channel {new_channel}"
785
+ logger.info(msg)
786
+ file_to_load = file_list[0] if is_missing else file_list[old_channel]
787
+ plane = await asyncio.to_thread(
788
+ load_and_preprocess_fits_data,
789
+ file_to_load,
790
+ bounding_box=bounding_box,
791
+ invalidate_zeros=invalidate_zeros,
792
+ wipe_with_nan=is_missing,
793
+ )
794
+
718
795
  if "BITPIX" in new_header:
719
796
  bit_pix = abs(new_header["BITPIX"])
720
797
  float_type = FLOAT_TYPE[bit_pix]
@@ -729,6 +806,30 @@ async def process_channel(
729
806
  del plane
730
807
 
731
808
 
809
+ def check_for_any_beam(file_list: list[Path]) -> bool:
810
+ """Check to see if any input files have a beam encoded in the header
811
+
812
+ Args:
813
+ file_list (list[Path]): The collection of files to examine
814
+
815
+ Returns:
816
+ bool: Whether beam properties were found in any of the files
817
+ """
818
+ # This is the same as any(), but avoids reading un-necessary headers
819
+ # TODO: Should we ever do a test for consistent WCSs up front this should be
820
+ # moved over to that check
821
+ for file in file_list:
822
+ logger.debug(f"Examining {file=} for beam properties")
823
+ file_header = fits.getheader(file)
824
+ if "BMAJ" in file_header:
825
+ logger.info(f"Found beam properties in {file}")
826
+ return True
827
+
828
+ # No beams were found among any of the inputs, so no beam information
829
+ # can be recorded in the output
830
+ return False
831
+
832
+
732
833
  async def combine_fits_coro(
733
834
  file_list: list[Path],
734
835
  out_cube: Path,
@@ -739,7 +840,7 @@ async def combine_fits_coro(
739
840
  overwrite: bool = False,
740
841
  max_workers: int | None = None,
741
842
  time_domain_mode: bool = False,
742
- bounding_box: bool = False,
843
+ bounding_box: bool | BoundingBox = False,
743
844
  invalidate_zeros: bool = False,
744
845
  float_length: FLOAT_LENGTH | None = None,
745
846
  ) -> u.Quantity:
@@ -752,15 +853,20 @@ async def combine_fits_coro(
752
853
  ignore_spec (bool, optional): Ignore frequency/time information. Defaults to False.
753
854
  create_blanks (bool, optional): Attempt to create even frequency spacing. Defaults to False.
754
855
  time_domain_mode (bool, optional): Work in time domain mode - make a time-cube. Default = False.
755
- bounding_box (bool, optional): Clip invalid/padded pixels when crafting the fits cube. When True an extra read of the input daata is needed, but output cube is smaller. Defaults to False.
856
+ bounding_box (bool | BoundingBox, optional): Clip invalid/padded pixels when crafting the fits cube. When True an extra read of the input data is needed, but output cube is smaller. A BoundingBox may be supplied directly (see `get_common_bounding_box`) to force several cubes onto an identical pixel grid. Defaults to False.
756
857
  invalidate_zeros (bool, optionals): Set pixels whose values are exactly zero to NaNs. Defaults to False.
757
858
  float_length (Literal[16, 32, 64] | None, optional): The floating point precision in bits to use when creating the output cube. If None the size of the input data are used. Defaults to None.
758
859
 
860
+ Raises:
861
+ ShapeMismatchException: If the input images do not share a pixel grid
862
+
759
863
  Returns:
760
864
  tuple[fits.HDUList, u.Quantity]: The combined FITS cube and frequencies
761
865
  """
762
866
  # TODO: Check that all files have the same WCS
763
867
 
868
+ await check_matching_shapes_coro(file_list=file_list, max_workers=max_workers)
869
+
764
870
  file_specs, specs, missing_chan_idx = await parse_specs_coro(
765
871
  spec_file=spec_file,
766
872
  spec_list=spec_list,
@@ -769,10 +875,8 @@ async def combine_fits_coro(
769
875
  create_blanks=create_blanks,
770
876
  time_domain_mode=time_domain_mode,
771
877
  )
772
- has_beams = "BMAJ" in fits.getheader(file_list[0])
878
+ has_beams = check_for_any_beam(file_list=file_list)
773
879
  if has_beams:
774
- msg = f"Found beam in {file_list[0]} - assuming all files have beams"
775
- logger.info(msg)
776
880
  beams = parse_beams(file_list)
777
881
  for beam in beams:
778
882
  logger.info(f"{beams[0]==beam=}")
@@ -784,7 +888,7 @@ async def combine_fits_coro(
784
888
  & np.isclose(beams[0].minor, beams.minor)
785
889
  & np.isclose(beams[0].pa, beams.pa)
786
890
  )
787
- single_beam = np.all(same_beam)
891
+ single_beam = bool(np.all(same_beam))
788
892
 
789
893
  if single_beam:
790
894
  logger.info("All beams are the same")
@@ -799,19 +903,16 @@ async def combine_fits_coro(
799
903
  specs = specs[new_sort_idx]
800
904
  missing_chan_idx = missing_chan_idx[new_sort_idx]
801
905
 
802
- # Get the bounding box, if requested
803
- final_bounding_box = None
804
- if bounding_box:
805
- boxes_futures = [
806
- get_bounding_box_for_fits_coro(
807
- fits_path=fits_path, invalidate_zeros=invalidate_zeros
808
- )
809
- for fits_path in file_list
810
- ]
811
- boxes = await gather_with_limit(
812
- max_workers, *boxes_futures, desc="Bounding boxes"
906
+ # Get the bounding box, if requested. A caller supplied box is used as is,
907
+ # so that separate cubes can be forced onto a common pixel grid.
908
+ final_bounding_box = bounding_box if isinstance(bounding_box, BoundingBox) else None
909
+ if bounding_box is True:
910
+ final_bounding_box = await get_common_bounding_box_coro(
911
+ file_list=file_list,
912
+ invalidate_zeros=invalidate_zeros,
913
+ max_workers=max_workers,
813
914
  )
814
- final_bounding_box = extract_common_bounding_box(bounding_boxes=boxes)
915
+ if final_bounding_box is not None:
815
916
  logger.info(f"The final bounding box is: {final_bounding_box=}")
816
917
 
817
918
  # Initialize the data cube
@@ -835,7 +936,7 @@ async def combine_fits_coro(
835
936
 
836
937
  coros = []
837
938
  with out_cube.open("rb+") as file_handle:
838
- for new_channel in new_channels:
939
+ for new_channel in range(len(new_channels)):
839
940
  is_missing = missing_chan_idx[new_channel]
840
941
  msg = f"Channel {new_channel} missing == {is_missing}"
841
942
  logger.info(msg)
@@ -11,3 +11,11 @@ class TargetAxisMissingException(FITSCubeException):
11
11
 
12
12
  class ChannelMissingException(FITSCubeException):
13
13
  """Raised when a channel can not be accessed"""
14
+
15
+
16
+ class ShapeMismatchException(FITSCubeException):
17
+ """Input images do not share a common pixel grid"""
18
+
19
+
20
+ class AxisOrderException(FITSCubeException):
21
+ """The spectral axis is not the slowest-varying axis of the cube"""
@@ -3,8 +3,11 @@ from __future__ import annotations
3
3
  from pathlib import Path
4
4
  from shutil import unpack_archive
5
5
 
6
+ import astropy.units as u
7
+ import numpy as np
6
8
  import pytest
7
9
  from astropy.io import fits
10
+ from astropy.time import Time
8
11
 
9
12
  EXAMPLE_HEADER = "SIMPLE = T / conforms to FITS standard BITPIX = -32 / array data type NAXIS = 4 / number of array dimensions NAXIS1 = 6192 NAXIS2 = 6192 NAXIS3 = 1 NAXIS4 = 72 EXTEND = T BSCALE = 1.0 BZERO = 0.0 BUNIT = 'JY/BEAM ' BMAJ = 0.00398184964207042 BMIN = 0.00332268385509243 BPA = 77.3858939868987 EQUINOX = 2000.0 LONPOLE = 180.0 BTYPE = 'Intensity' TELESCOP= 'ASKAP ' OBJECT = 'EMU_1141-55' ORIGIN = 'WSClean ' CTYPE1 = 'RA---SIN' CRPIX1 = 3097.0 CRVAL1 = 173.522555019647 CDELT1 = -0.000625 CUNIT1 = 'deg ' CTYPE2 = 'DEC--SIN' CRPIX2 = 3097.0 CRVAL2 = -55.3190947400628 CDELT2 = 0.000625 CUNIT2 = 'deg ' CTYPE3 = 'STOKES ' CRPIX3 = 1.0 CRVAL3 = 1.0 CDELT3 = 1.0 CUNIT3 = '' CTYPE4 = 'FREQ ' CRPIX4 = 1 CRVAL4 = 801490740.740741 CDELT4 = 4000000.0 CUNIT4 = 'Hz ' SPECSYS = 'TOPOCENT' DATE-OBS= '2023-01-08T15:36:40.9' WSCDATAC= 'DATA ' WSCVDATE= '2022-10-21' WSCVERSI= '3.2 ' WSCWEIGH= 'Briggs''(-0)' WSCENVIS= 1026544.24656423 WSCFIELD= 0.0 WSCGAIN = 0.1 WSCGKRNL= 7.0 WSCIMGWG= 4052355.35920529 WSCMAJOR= 9.0 WSCMGAIN= 0.9 WSCMINOR= 62860.0 WSCNEGCM= 1.0 WSCNEGST= 0.0 WSCNITER= 5000000.0 WSCNORMF= 4052355.35920529 WSCNVIS = 7648518.0 WSCNWLAY= 1.0 WSCTHRES= 0.0 WSCVWSUM= 30594072.0 COMMENT FITS (Flexible Image Transport System) format is defined in 'AstronomyCOMMENT and Astrophysics', volume 376, page 359; bibcode: 2001A&A...376..359H HISTORY wsclean -abs-mem 200 -local-rms-window 20 -size 6192 6192 -local-rms -foHISTORY rce-mask-rounds 4 -auto-mask 8.0 -auto-threshold 3.0 -channels-out 72 -mHISTORY gain 0.9 -nmiter 10 -niter 5000000 -multiscale-scale-bias 0.8 -multiscalHISTORY e-scales 0,4,8,16,24,32,48,64,92,128,196,512,796,1025 -fit-spectral-pol HISTORY 4 -weight briggs -0 -data-column DATA -scale 2.25asec -gridder wgridder HISTORY -wgridder-accuracy 0.0001 -join-channels -minuv-l 50.0 -beam-fitting-sizHISTORY e 1.25 -deconvolution-channels 8 -parallel-gridding 18 -temp-dir /dev/shHISTORY m/gal16b.8974844 -pol i -save-source-list -name /dev/shm/gal16b.8974844/HISTORY SB47138.EMU_1141-55.beam19.i /scratch3/gal16b/emu_download/flint_jollytrHISTORY actor/47138/SB47138.EMU_1141-55.beam19.ms END "
10
13
 
@@ -94,3 +97,70 @@ def time_image_paths_withzeroborder(tmpdir) -> list[Path]:
94
97
  output_paths.append(image_output)
95
98
 
96
99
  return output_paths
100
+
101
+
102
+ @pytest.fixture
103
+ def even_specs() -> u.Quantity:
104
+ rng = np.random.default_rng()
105
+ # mjd 60000 to 60000.2
106
+ start = rng.integers(5184000000, 5184017280)
107
+ end = rng.integers(5184025920, 5184043200)
108
+ # start = rng.integers(0, 17280)
109
+ # end = rng.integers(25920, 43200)
110
+ num = rng.integers(6, 10)
111
+ # num=6
112
+ return np.linspace(start, end, num) * u.s
113
+
114
+
115
+ @pytest.fixture
116
+ def output_file():
117
+ yield Path("test.fits")
118
+ Path("test.fits").unlink()
119
+
120
+
121
+ @pytest.fixture
122
+ def file_list(even_specs: u.Quantity):
123
+ image = np.ones((1, 10, 10))
124
+ for i, spec in enumerate(even_specs):
125
+ header = fits.Header()
126
+ header["CRVAL3"] = spec.to(u.s).value
127
+ header["CDELT3"] = 9.98
128
+ header["CRPIX3"] = 1
129
+ header["CTYPE3"] = "TIME"
130
+ header["CUNIT3"] = "s"
131
+ header["DATE-OBS"] = Time(spec.to(u.d).value, format="mjd").isot
132
+ header["MJD-OBS"] = Time(spec.to(u.d).value, format="mjd").mjd
133
+ hdu = fits.PrimaryHDU(image * i, header=header)
134
+ hdu.writeto(f"plane_{i}.fits", overwrite=True)
135
+
136
+ yield [Path(f"plane_{i}.fits") for i in range(len(even_specs))]
137
+
138
+ for i in range(len(even_specs)):
139
+ Path(f"plane_{i}.fits").unlink()
140
+
141
+
142
+ @pytest.fixture
143
+ def file_list_onebeam(even_specs: u.Quantity):
144
+ """Same as above but one file with have a beam"""
145
+ image = np.ones((1, 10, 10))
146
+ for i, spec in enumerate(even_specs):
147
+ header = fits.Header()
148
+ header["CRVAL3"] = spec.to(u.s).value
149
+ header["CDELT3"] = 9.98
150
+ header["CRPIX3"] = 1
151
+ header["CTYPE3"] = "TIME"
152
+ header["CUNIT3"] = "s"
153
+ header["DATE-OBS"] = Time(spec.to(u.d).value, format="mjd").isot
154
+ header["MJD-OBS"] = Time(spec.to(u.d).value, format="mjd").mjd
155
+ if i == 3:
156
+ header["BMAJ"] = 1
157
+ header["BMIN"] = 1
158
+ header["BPA"] = 1
159
+
160
+ hdu = fits.PrimaryHDU(image * i, header=header)
161
+ hdu.writeto(f"plane_{i}.fits", overwrite=True)
162
+
163
+ yield [Path(f"plane_{i}.fits") for i in range(len(even_specs))]
164
+
165
+ for i in range(len(even_specs)):
166
+ Path(f"plane_{i}.fits").unlink()
@@ -0,0 +1,44 @@
1
+ """Tests related to specific combine functionality"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import numpy as np
8
+ from astropy.io import fits
9
+ from fitscube.combine_fits import check_for_any_beam, combine_fits
10
+
11
+
12
+ def test_check_for_any_beams_no_beams(file_list) -> None:
13
+ """See if we can confirm is all beams are in fits files"""
14
+ # file_list returns fits files without beam information
15
+ assert not check_for_any_beam(file_list=file_list)
16
+
17
+
18
+ def test_check_for_any_beam_real_images(time_image_paths) -> None:
19
+ """See if beam is in any of these images"""
20
+ assert check_for_any_beam(file_list=time_image_paths)
21
+
22
+
23
+ def test_check_for_any_beam_one_beam(file_list_onebeam) -> None:
24
+ """See if beam is in any of these images. Only one of the files should have the beamn properties"""
25
+ assert check_for_any_beam(file_list=file_list_onebeam)
26
+
27
+
28
+ def test_combine_beam_not_in_first_file(
29
+ file_list_onebeam: list[Path], output_file: Path
30
+ ) -> None:
31
+ """A beam only on the fourth input should still reach the output cube"""
32
+ combine_fits(
33
+ file_list=file_list_onebeam,
34
+ out_cube=output_file,
35
+ time_domain_mode=True,
36
+ overwrite=True,
37
+ )
38
+
39
+ with fits.open(output_file) as hdul:
40
+ assert hdul[1].name == "BEAMS"
41
+ bmaj = hdul[1].data["BMAJ"]
42
+ # Only plane 3 carries a beam; the rest are the NaN sentinel
43
+ assert np.isclose(bmaj[3], 3600.0)
44
+ assert np.all(bmaj[np.arange(len(bmaj)) != 3] < 1e-30)
@@ -0,0 +1,209 @@
1
+ # Regression tests for the cube-scrambling and blank-channel bugs
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import astropy.units as u
8
+ import numpy as np
9
+ import pytest
10
+ from astropy.io import fits
11
+ from fitscube.bounding_box import get_common_bounding_box
12
+ from fitscube.combine_fits import check_matching_shapes, combine_fits
13
+ from fitscube.exceptions import AxisOrderException, ShapeMismatchException
14
+
15
+
16
+ def make_plane(
17
+ path: Path,
18
+ spec: u.Quantity,
19
+ shape: tuple[int, ...] = (1, 1, 8, 8),
20
+ value: float = 1.0,
21
+ pol_outer: bool = False,
22
+ ) -> Path:
23
+ """Write a single-channel image with a FREQ axis at ``spec``.
24
+
25
+ By default the axes are (FREQ, STOKES, DEC, RA), the usual ASKAP ordering.
26
+ With ``pol_outer`` they are (STOKES, FREQ, DEC, RA), which puts a
27
+ non-degenerate STOKES axis above FREQ in the output cube.
28
+ """
29
+ header = fits.Header()
30
+ header["CTYPE1"] = "RA---SIN"
31
+ header["CRPIX1"] = 1.0
32
+ header["CRVAL1"] = 0.0
33
+ header["CDELT1"] = -1e-3
34
+ header["CUNIT1"] = "deg"
35
+ header["CTYPE2"] = "DEC--SIN"
36
+ header["CRPIX2"] = 1.0
37
+ header["CRVAL2"] = 0.0
38
+ header["CDELT2"] = 1e-3
39
+ header["CUNIT2"] = "deg"
40
+ spec_axis, pol_axis = (4, 3) if not pol_outer else (3, 4)
41
+ header[f"CTYPE{spec_axis}"] = "FREQ"
42
+ header[f"CRPIX{spec_axis}"] = 1.0
43
+ header[f"CRVAL{spec_axis}"] = spec.to(u.Hz).value
44
+ header[f"CDELT{spec_axis}"] = 1e6
45
+ header[f"CUNIT{spec_axis}"] = "Hz"
46
+ header[f"CTYPE{pol_axis}"] = "STOKES"
47
+ header[f"CRPIX{pol_axis}"] = 1.0
48
+ header[f"CRVAL{pol_axis}"] = 1.0
49
+ header[f"CDELT{pol_axis}"] = 1.0
50
+ fits.PrimaryHDU(np.full(shape, value, dtype=np.float32), header=header).writeto(
51
+ path, overwrite=True
52
+ )
53
+ return path
54
+
55
+
56
+ @pytest.fixture
57
+ def specs() -> u.Quantity:
58
+ return np.arange(4) * 1e6 * u.Hz + 1e9 * u.Hz
59
+
60
+
61
+ @pytest.fixture
62
+ def file_list(tmp_path: Path, specs: u.Quantity) -> list[Path]:
63
+ return [
64
+ make_plane(tmp_path / f"plane_{i}.fits", spec, value=float(i))
65
+ for i, spec in enumerate(specs)
66
+ ]
67
+
68
+
69
+ def test_mismatched_shapes_raise(
70
+ tmp_path: Path, file_list: list[Path], specs: u.Quantity
71
+ ) -> None:
72
+ """Differing NAXIS1/NAXIS2 used to slide the planes against each other."""
73
+ odd_one_out = make_plane(
74
+ tmp_path / "plane_odd.fits", specs[-1] + 1e6 * u.Hz, shape=(1, 1, 6, 6)
75
+ )
76
+
77
+ with pytest.raises(ShapeMismatchException, match="plane_odd"):
78
+ combine_fits(
79
+ file_list=[*file_list, odd_one_out],
80
+ out_cube=tmp_path / "cube.fits",
81
+ overwrite=True,
82
+ )
83
+
84
+
85
+ def test_check_matching_shapes(file_list: list[Path]) -> None:
86
+ assert check_matching_shapes(file_list=file_list) == (8, 8)
87
+
88
+
89
+ def test_spectral_axis_must_be_slowest(tmp_path: Path, specs: u.Quantity) -> None:
90
+ """A non-degenerate axis above FREQ breaks the per-plane seek."""
91
+ file_list = [
92
+ make_plane(tmp_path / f"pol_{i}.fits", spec, shape=(2, 1, 8, 8), pol_outer=True)
93
+ for i, spec in enumerate(specs)
94
+ ]
95
+
96
+ with pytest.raises(AxisOrderException, match="NAXIS4"):
97
+ combine_fits(
98
+ file_list=file_list, out_cube=tmp_path / "cube.fits", overwrite=True
99
+ )
100
+
101
+
102
+ def test_small_cube_keeps_input_precision(
103
+ tmp_path: Path, file_list: list[Path]
104
+ ) -> None:
105
+ """The small-cube path used to promote the output to float64."""
106
+ out_cube = tmp_path / "cube.fits"
107
+ combine_fits(file_list=file_list, out_cube=out_cube, overwrite=True)
108
+
109
+ header = fits.getheader(out_cube)
110
+ assert header["BITPIX"] == -32
111
+ cube = fits.getdata(out_cube)
112
+ assert cube.dtype.itemsize == 4
113
+ assert cube.shape == (len(file_list), 1, 8, 8)
114
+ for chan in range(len(file_list)):
115
+ assert np.allclose(cube[chan], fits.getdata(file_list[chan]))
116
+
117
+
118
+ def test_float_length_is_respected(tmp_path: Path, file_list: list[Path]) -> None:
119
+ out_cube = tmp_path / "cube.fits"
120
+ combine_fits(
121
+ file_list=file_list, out_cube=out_cube, overwrite=True, float_length=64
122
+ )
123
+
124
+ assert fits.getheader(out_cube)["BITPIX"] == -64
125
+ assert fits.getdata(out_cube).dtype.itemsize == 8
126
+
127
+
128
+ def test_blank_channels_are_created(
129
+ tmp_path: Path, specs: u.Quantity, monkeypatch: pytest.MonkeyPatch
130
+ ) -> None:
131
+ """create_blanks used to hit `fits.getdata(..., memamp=False)`.
132
+
133
+ Some astropy versions swallow the unknown keyword, so reject it explicitly
134
+ here the way a strict astropy does.
135
+ """
136
+ real_getdata = fits.getdata
137
+
138
+ def strict_getdata(*args: Any, **kwargs: Any) -> Any:
139
+ unexpected = set(kwargs) - {
140
+ "filename",
141
+ "header",
142
+ "memmap",
143
+ "lazy_load_hdus",
144
+ "ext",
145
+ }
146
+ if unexpected:
147
+ msg = f"getdata() got unexpected keyword arguments {unexpected}"
148
+ raise TypeError(msg)
149
+ return real_getdata(*args, **kwargs)
150
+
151
+ monkeypatch.setattr(fits, "getdata", strict_getdata)
152
+
153
+ # Drop the second channel to leave a gap in the frequency grid
154
+ gapped = [specs[0], specs[2], specs[3]]
155
+ file_list = [
156
+ make_plane(tmp_path / f"gap_{i}.fits", spec, value=float(i))
157
+ for i, spec in enumerate(gapped)
158
+ ]
159
+
160
+ out_cube = tmp_path / "cube.fits"
161
+ out_specs = combine_fits(
162
+ file_list=file_list,
163
+ out_cube=out_cube,
164
+ create_blanks=True,
165
+ overwrite=True,
166
+ )
167
+
168
+ assert len(out_specs) == 4
169
+ cube = fits.getdata(out_cube)
170
+ assert np.isnan(cube[1]).all()
171
+ assert np.allclose(cube[0], 0.0)
172
+ assert np.allclose(cube[2], 1.0)
173
+ assert np.allclose(cube[3], 2.0)
174
+
175
+
176
+ def test_bounding_box_can_be_supplied(tmp_path: Path, specs: u.Quantity) -> None:
177
+ """A caller-supplied box is used as is, so two cubes can share a grid."""
178
+ # Images blanked to different extents, as per-channel linmos mosaics are
179
+ images = []
180
+ weights = []
181
+ for i, spec in enumerate(specs):
182
+ image = make_plane(tmp_path / f"image_{i}.fits", spec, value=float(i))
183
+ with fits.open(image, mode="update") as hdu_list:
184
+ hdu_list[0].data[..., : i + 1, :] = np.nan
185
+ images.append(image)
186
+ weights.append(make_plane(tmp_path / f"weight_{i}.fits", spec, value=1.0))
187
+
188
+ common_box = get_common_bounding_box(file_list=images)
189
+ assert common_box.x_span == 8 - 1 # first row blanked in every image
190
+
191
+ image_cube = tmp_path / "image_cube.fits"
192
+ weight_cube = tmp_path / "weight_cube.fits"
193
+ for file_list, out_cube in ((images, image_cube), (weights, weight_cube)):
194
+ combine_fits(
195
+ file_list=file_list,
196
+ out_cube=out_cube,
197
+ overwrite=True,
198
+ bounding_box=common_box,
199
+ )
200
+
201
+ image_header = fits.getheader(image_cube)
202
+ weight_header = fits.getheader(weight_cube)
203
+ assert fits.getdata(image_cube).shape == fits.getdata(weight_cube).shape
204
+ for key in ("NAXIS1", "NAXIS2", "CRPIX1", "CRPIX2"):
205
+ assert image_header[key] == weight_header[key]
206
+ assert image_header["NAXIS2"] == common_box.x_span
207
+
208
+ # The weights alone would have given the full, untrimmed grid
209
+ assert get_common_bounding_box(file_list=weights).x_span == 8
@@ -6,50 +6,9 @@ import astropy.units as u
6
6
  import numpy as np
7
7
  import pytest
8
8
  from astropy.io import fits
9
- from astropy.time import Time
10
9
  from fitscube.combine_fits import combine_fits, parse_specs
11
10
 
12
11
 
13
- @pytest.fixture
14
- def even_specs() -> u.Quantity:
15
- rng = np.random.default_rng()
16
- # mjd 60000 to 60000.2
17
- start = rng.integers(5184000000, 5184017280)
18
- end = rng.integers(5184025920, 5184043200)
19
- # start = rng.integers(0, 17280)
20
- # end = rng.integers(25920, 43200)
21
- num = rng.integers(6, 10)
22
- # num=6
23
- return np.linspace(start, end, num) * u.s
24
-
25
-
26
- @pytest.fixture
27
- def output_file():
28
- yield Path("test.fits")
29
- Path("test.fits").unlink()
30
-
31
-
32
- @pytest.fixture
33
- def file_list(even_specs: u.Quantity):
34
- image = np.ones((1, 10, 10))
35
- for i, spec in enumerate(even_specs):
36
- header = fits.Header()
37
- header["CRVAL3"] = spec.to(u.s).value
38
- header["CDELT3"] = 9.98
39
- header["CRPIX3"] = 1
40
- header["CTYPE3"] = "TIME"
41
- header["CUNIT3"] = "s"
42
- header["DATE-OBS"] = Time(spec.to(u.d).value, format="mjd").isot
43
- header["MJD-OBS"] = Time(spec.to(u.d).value, format="mjd").mjd
44
- hdu = fits.PrimaryHDU(image * i, header=header)
45
- hdu.writeto(f"plane_{i}.fits", overwrite=True)
46
-
47
- yield [Path(f"plane_{i}.fits") for i in range(len(even_specs))]
48
-
49
- for i in range(len(even_specs)):
50
- Path(f"plane_{i}.fits").unlink()
51
-
52
-
53
12
  def test_parse_specs(file_list: list[Path], even_specs: u.Quantity):
54
13
  file_specs, specs, missing_chan_idx = parse_specs(file_list, time_domain_mode=True)
55
14
  # assert np.array_equal(file_specs, even_specs)
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
File without changes
File without changes