fitscube 2.2.0__tar.gz → 2.3.1__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 (35) hide show
  1. {fitscube-2.2.0 → fitscube-2.3.1}/.github/workflows/cd.yml +2 -2
  2. {fitscube-2.2.0 → fitscube-2.3.1}/.github/workflows/ci.yml +3 -3
  3. {fitscube-2.2.0 → fitscube-2.3.1}/.pre-commit-config.yaml +4 -4
  4. {fitscube-2.2.0 → fitscube-2.3.1}/PKG-INFO +2 -1
  5. fitscube-2.3.1/fitscube/_version.py +24 -0
  6. fitscube-2.3.1/fitscube/bounding_box.py +153 -0
  7. {fitscube-2.2.0 → fitscube-2.3.1}/fitscube/combine_fits.py +111 -12
  8. {fitscube-2.2.0 → fitscube-2.3.1}/pyproject.toml +1 -0
  9. {fitscube-2.2.0 → fitscube-2.3.1}/tests/conftest.py +29 -0
  10. fitscube-2.3.1/tests/test_bb.py +97 -0
  11. {fitscube-2.2.0 → fitscube-2.3.1}/tests/test_times.py +118 -0
  12. fitscube-2.2.0/fitscube/_version.py +0 -21
  13. {fitscube-2.2.0 → fitscube-2.3.1}/.github/CONTRIBUTING.md +0 -0
  14. {fitscube-2.2.0 → fitscube-2.3.1}/.github/dependabot.yml +0 -0
  15. {fitscube-2.2.0 → fitscube-2.3.1}/.github/release.yml +0 -0
  16. {fitscube-2.2.0 → fitscube-2.3.1}/.gitignore +0 -0
  17. {fitscube-2.2.0 → fitscube-2.3.1}/CHANGELOG.md +0 -0
  18. {fitscube-2.2.0 → fitscube-2.3.1}/LICENSE +0 -0
  19. {fitscube-2.2.0 → fitscube-2.3.1}/README.md +0 -0
  20. {fitscube-2.2.0 → fitscube-2.3.1}/fitscube/__init__.py +0 -0
  21. {fitscube-2.2.0 → fitscube-2.3.1}/fitscube/asyncio.py +0 -0
  22. {fitscube-2.2.0 → fitscube-2.3.1}/fitscube/cli.py +0 -0
  23. {fitscube-2.2.0 → fitscube-2.3.1}/fitscube/exceptions.py +0 -0
  24. {fitscube-2.2.0 → fitscube-2.3.1}/fitscube/extract.py +0 -0
  25. {fitscube-2.2.0 → fitscube-2.3.1}/fitscube/logging.py +0 -0
  26. {fitscube-2.2.0 → fitscube-2.3.1}/fitscube/version.pyi +0 -0
  27. {fitscube-2.2.0 → fitscube-2.3.1}/noxfile.py +0 -0
  28. {fitscube-2.2.0 → fitscube-2.3.1}/tests/__init__.py +0 -0
  29. {fitscube-2.2.0 → fitscube-2.3.1}/tests/data/cube.zip +0 -0
  30. {fitscube-2.2.0 → fitscube-2.3.1}/tests/data/images.zip +0 -0
  31. {fitscube-2.2.0 → fitscube-2.3.1}/tests/data/time_images.zip +0 -0
  32. {fitscube-2.2.0 → fitscube-2.3.1}/tests/data/timecube.zip +0 -0
  33. {fitscube-2.2.0 → fitscube-2.3.1}/tests/test_extract.py +0 -0
  34. {fitscube-2.2.0 → fitscube-2.3.1}/tests/test_frequencies.py +0 -0
  35. {fitscube-2.2.0 → fitscube-2.3.1}/tests/test_package.py +0 -0
@@ -25,7 +25,7 @@ jobs:
25
25
  runs-on: ubuntu-latest
26
26
 
27
27
  steps:
28
- - uses: actions/checkout@v4
28
+ - uses: actions/checkout@v5
29
29
  with:
30
30
  fetch-depth: 0
31
31
 
@@ -43,7 +43,7 @@ jobs:
43
43
  if: github.event_name == 'release' && github.event.action == 'published'
44
44
 
45
45
  steps:
46
- - uses: actions/download-artifact@v4
46
+ - uses: actions/download-artifact@v5
47
47
  with:
48
48
  name: Packages
49
49
  path: dist
@@ -21,7 +21,7 @@ jobs:
21
21
  name: Format
22
22
  runs-on: ubuntu-latest
23
23
  steps:
24
- - uses: actions/checkout@v4
24
+ - uses: actions/checkout@v5
25
25
  with:
26
26
  fetch-depth: 0
27
27
  - uses: actions/setup-python@v5
@@ -42,7 +42,7 @@ jobs:
42
42
  runs-on: [ubuntu-latest]
43
43
 
44
44
  steps:
45
- - uses: actions/checkout@v4
45
+ - uses: actions/checkout@v5
46
46
  with:
47
47
  fetch-depth: 0
48
48
 
@@ -62,6 +62,6 @@ jobs:
62
62
  --durations=20
63
63
 
64
64
  - name: Upload coverage report
65
- uses: codecov/codecov-action@v5.4.3
65
+ uses: codecov/codecov-action@v5.5.0
66
66
  with:
67
67
  token: ${{ secrets.CODECOV_TOKEN }}
@@ -10,7 +10,7 @@ repos:
10
10
  # additional_dependencies: [black==24.*]
11
11
 
12
12
  - repo: https://github.com/pre-commit/pre-commit-hooks
13
- rev: "v5.0.0"
13
+ rev: "v6.0.0"
14
14
  hooks:
15
15
  - id: check-added-large-files
16
16
  - id: check-case-conflict
@@ -33,7 +33,7 @@ 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.7"
36
+ rev: "v0.12.11"
37
37
  hooks:
38
38
  - id: ruff
39
39
  args: ["--fix", "--show-fixes"]
@@ -56,7 +56,7 @@ repos:
56
56
  - tomli
57
57
 
58
58
  - repo: https://github.com/shellcheck-py/shellcheck-py
59
- rev: "v0.10.0.1"
59
+ rev: "v0.11.0.1"
60
60
  hooks:
61
61
  - id: shellcheck
62
62
 
@@ -75,7 +75,7 @@ repos:
75
75
  additional_dependencies: ["validate-pyproject-schema-store[all]"]
76
76
 
77
77
  - repo: https://github.com/python-jsonschema/check-jsonschema
78
- rev: "0.33.2"
78
+ rev: "0.33.3"
79
79
  hooks:
80
80
  - id: check-dependabot
81
81
  - id: check-github-workflows
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fitscube
3
- Version: 2.2.0
3
+ Version: 2.3.1
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
@@ -58,6 +58,7 @@ Requires-Dist: sphinx-autodoc-typehints; extra == 'docs'
58
58
  Requires-Dist: sphinx-copybutton; extra == 'docs'
59
59
  Requires-Dist: sphinx>=7.0; extra == 'docs'
60
60
  Provides-Extra: test
61
+ Requires-Dist: pytest-asyncio; extra == 'test'
61
62
  Requires-Dist: pytest-cov>=3; extra == 'test'
62
63
  Requires-Dist: pytest>=6; extra == 'test'
63
64
  Provides-Extra: uvloop
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '2.3.1'
22
+ __version_tuple__ = version_tuple = (2, 3, 1)
23
+
24
+ __commit_id__ = commit_id = None
@@ -0,0 +1,153 @@
1
+ """Some basic utilities to help with the creating of
2
+ bounding boxes to use in fitscube"""
3
+
4
+ from __future__ import annotations
5
+
6
+ import asyncio
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+ import numpy as np
11
+ from astropy.io import fits
12
+
13
+ from fitscube.logging import logger
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class BoundingBox:
18
+ """Simple container to represent a bounding box. Maximum values can be
19
+ used as is when slicing."""
20
+
21
+ xmin: int
22
+ """Minimum x pixel"""
23
+ xmax: int
24
+ """Maximum x pixel"""
25
+ ymin: int
26
+ """Minimum y pixel. Can be used as is in slice (e.g. is exclusive). """
27
+ ymax: int
28
+ """Maximum y pixel Can be used as is in slice (e.g. is exclusive)."""
29
+ original_shape: tuple[int, int]
30
+ """The original shape of the image. If constructed against a cube this is the shape of a single plane."""
31
+ y_span: int
32
+ """The span between ymax and ymin"""
33
+ x_span: int
34
+ """The span between xmax and xmin"""
35
+
36
+
37
+ def create_bound_box_plane(image_data: np.ndarray) -> BoundingBox | None:
38
+ """Create a bounding box around pixels in a 2D image. If all
39
+ pixels are not valid, then ``None`` is returned.
40
+
41
+ Args:
42
+ image_data (np.ndarray): The 2D image to construct a bounding box around
43
+
44
+ Returns:
45
+ Optional[BoundingBox]: None if no valid pixels, a bounding box with the (xmin,xmax,ymin,ymax) of valid pixels
46
+ """
47
+ assert len(image_data.shape) == 2, (
48
+ f"Only two-dimensional arrays supported, received {image_data.shape}"
49
+ )
50
+
51
+ # First convert to a boolean array
52
+ image_valid = np.isfinite(image_data)
53
+
54
+ if not any(image_valid.reshape(-1)):
55
+ logger.info("No pixels to creating bounding box for")
56
+ return None
57
+
58
+ # Then make them 1D arrays
59
+ x_valid = np.any(image_valid, axis=1)
60
+ y_valid = np.any(image_valid, axis=0)
61
+
62
+ # Now get the first and last index
63
+ xmin, xmax = np.where(x_valid)[0][[0, -1]]
64
+ ymin, ymax = np.where(y_valid)[0][[0, -1]]
65
+
66
+ xmax += 1
67
+ ymax += 1
68
+
69
+ y_span = ymax - ymin
70
+ x_span = xmax - xmin
71
+
72
+ return BoundingBox(
73
+ xmin=xmin,
74
+ xmax=xmax,
75
+ ymin=ymin,
76
+ ymax=ymax,
77
+ y_span=y_span,
78
+ x_span=x_span,
79
+ original_shape=image_data.shape[-2:],
80
+ )
81
+
82
+
83
+ def extract_common_bounding_box(
84
+ bounding_boxes: list[BoundingBox | None],
85
+ ) -> BoundingBox:
86
+ """Get the smallest bounding box that encompasses all bounding boxes
87
+
88
+ Args:
89
+ bounding_boxes (list[BoundingBox | None]): A list of bounding boxes. If None (returned for invalid images) skip it.
90
+
91
+ Raises:
92
+ ValueError: If all input bounding boxes are invalid
93
+ ValueError: If there is an `original_shape` mismatch
94
+
95
+ Returns:
96
+ BoundingBox: The smallest bounding box
97
+ """
98
+
99
+ # Step 1: filter out all Nones
100
+ valid_boxes: list[BoundingBox] = [bb for bb in bounding_boxes if bb is not None]
101
+
102
+ if len(valid_boxes) == 0:
103
+ msg = "No valid input boxes to consider"
104
+ raise ValueError(msg)
105
+
106
+ if not all(
107
+ valid_boxes[0].original_shape == bb.original_shape for bb in valid_boxes
108
+ ):
109
+ msg = "Different shapes, and not sure this is really supported or meaningful"
110
+ raise ValueError(msg)
111
+
112
+ xmin = int(np.min([bb.xmin for bb in valid_boxes]))
113
+ xmax = int(np.max([bb.xmax for bb in valid_boxes]))
114
+ ymin = int(np.min([bb.ymin for bb in valid_boxes]))
115
+ ymax = int(np.max([bb.ymax for bb in valid_boxes]))
116
+
117
+ y_span = ymax - ymin
118
+ x_span = xmax - xmin
119
+
120
+ return BoundingBox(
121
+ xmin=xmin,
122
+ xmax=xmax,
123
+ ymin=ymin,
124
+ ymax=ymax,
125
+ y_span=y_span,
126
+ x_span=x_span,
127
+ original_shape=valid_boxes[0].original_shape,
128
+ )
129
+
130
+
131
+ async def get_bounding_box_for_fits_coro(
132
+ fits_path: Path, invalidate_zeros: bool = False
133
+ ) -> BoundingBox | None:
134
+ """Create a bounding box for an image contained in a FITS file.
135
+
136
+ The assumption is that the FITS file contains an image, not a cube.
137
+ If the cube can bot be reshapped to an image without losing data
138
+ the underlying bounding box creation will fail.
139
+
140
+ Args:
141
+ fits_path (Path): The fits image to call
142
+ invalidate_zeros (bool, optional): Mark pixels that are exactly 0.0 as invalid (NaN them). Defaults to False.
143
+
144
+ Returns:
145
+ BoundingBox | None: The bounding box that describes the bounds of valid data. If all data are invalid (and not bounding box possible) None is returned.
146
+ """
147
+ data = await asyncio.to_thread(fits.getdata, fits_path, memmap=False)
148
+ data = np.squeeze(data)
149
+
150
+ if invalidate_zeros:
151
+ data[data == 0.0] = np.nan
152
+
153
+ return await asyncio.to_thread(create_bound_box_plane, image_data=data)
@@ -16,10 +16,7 @@ import asyncio
16
16
  import warnings
17
17
  from io import BufferedRandom
18
18
  from pathlib import Path
19
- from typing import (
20
- NamedTuple,
21
- TypeVar,
22
- )
19
+ from typing import Literal, NamedTuple, TypeVar
23
20
 
24
21
  import astropy.units as u
25
22
  import numpy as np
@@ -34,6 +31,11 @@ from radio_beam.beam import NoBeamException
34
31
  from tqdm.asyncio import tqdm
35
32
 
36
33
  from fitscube.asyncio import gather_with_limit, sync_wrapper
34
+ from fitscube.bounding_box import (
35
+ BoundingBox,
36
+ extract_common_bounding_box,
37
+ get_bounding_box_for_fits_coro,
38
+ )
37
39
  from fitscube.logging import TQDM_OUT, logger, set_verbosity
38
40
 
39
41
  T = TypeVar("T")
@@ -45,6 +47,8 @@ BIT_DICT = {
45
47
  16: 2,
46
48
  8: 1,
47
49
  }
50
+ FLOAT_LENGTH = Literal[16, 32, 64]
51
+ FLOAT_TYPE = {64: ">f8", 32: ">f4"}
48
52
 
49
53
  warnings.filterwarnings("ignore", category=UserWarning, module="astropy.io.fits")
50
54
  warnings.filterwarnings("ignore", category=VerifyWarning)
@@ -170,6 +174,7 @@ async def create_cube_from_scratch_coro(
170
174
  output_shape = output_wcs.array_shape
171
175
  msg = f"Creating a new FITS file with shape {output_shape}"
172
176
  logger.info(msg)
177
+
173
178
  # If the output shape is less than 1801, we can create a blank array
174
179
  # in memory and write it to disk
175
180
  if np.prod(output_shape) < 1801:
@@ -240,19 +245,27 @@ async def create_output_cube_coro(
240
245
  single_beam: bool = False,
241
246
  overwrite: bool = False,
242
247
  time_domain_mode: bool = False,
248
+ bounding_box: BoundingBox | None = None,
249
+ float_length: FLOAT_LENGTH | None = None,
243
250
  ) -> InitResult:
244
- """Initialize the data cube.
251
+ """Generate the output header and write a dummy cube to disk based on properties of the
252
+ inpute data. The output cube written here has a correctly formed header and a pre-zerod
253
+ data cube written as output.
245
254
 
246
255
  Args:
247
- old_name (str): Old FITS file name
248
- n_chan (int): Number of channels
249
-
250
- Raises:
251
- KeyError: If 2D and REFFREQ is not in header
252
- ValueError: If not 2D and FREQ is not in header
256
+ old_name (Path): The path to a representative image to draw the base fits header from
257
+ out_cube (Path): Path of the output cube to create
258
+ specs (u.Quantity): Specification of the unit that denotes the 'cube' axis
259
+ ignore_spec (bool, optional): Whether the provided `specs` axis should be ignored. If True dummy placeholder fields added. Defaults to False.
260
+ has_beams (bool, optional): Indicates whether a CASA Beam table will also be generated and added to the output cube. Defaults to False.
261
+ single_beam (bool, optional): Indicates whether a constant restoring beam has been used among all input images. If so only the beam fields are need. Defaults to False.
262
+ overwrite (bool, optional): If True the out the output cube will overwrite any existing file. Defaults to False.
263
+ time_domain_mode (bool, optional): Whether to join images via the DATE-OBS (e.g. time) axis. If False cube joined along frequency axis. Defaults to False.
264
+ bounding_box (BoundingBox | None, optional): Whether a trimming operation should be applied to input images. If a BoundingBox is supplied this will be used to update the reference pixel position and data shape indicators. Defaults to None.
265
+ float_length (Literal[16, 32, 64] | None, optional): The precision of the output data. If None drawn from input data. Otherwise values accepted are 16, 32 and 64. Defaults to None.
253
266
 
254
267
  Returns:
255
- InitResult: header, spec_idx, spec_fits_idx, is_2d
268
+ InitResult: Details of the output cube, including the output header
256
269
  """
257
270
 
258
271
  # define units if in time or freq domain
@@ -358,6 +371,14 @@ async def create_output_cube_coro(
358
371
  )
359
372
  del new_header["BMAJ"], new_header["BMIN"], new_header["BPA"]
360
373
 
374
+ if bounding_box:
375
+ logger.info("Updating CRPIX1 and CRPIX2 header values to reflect bounding box")
376
+ new_header["CRPIX1"] -= bounding_box.ymin
377
+ new_header["CRPIX2"] -= bounding_box.xmin
378
+ logger.info("Updating NAXIS1 and NAXIS2 ro reflect bounding box")
379
+ new_header["NAXIS1"] = bounding_box.y_span
380
+ new_header["NAXIS2"] = bounding_box.x_span
381
+
361
382
  plane_shape = list(old_data.shape)
362
383
  cube_shape = plane_shape.copy()
363
384
  if is_2d:
@@ -365,6 +386,23 @@ async def create_output_cube_coro(
365
386
  else:
366
387
  cube_shape[idx] = n_chan
367
388
 
389
+ logger.critical(f"{float_length=} {new_header['BITPIX']=}")
390
+ if float_length is not None:
391
+ # Per astropy docs
392
+ # bITPIX numpy data type
393
+ # 8 numpy.uint8 (note it is UNsigned integer)
394
+ # 16 numpy.int16
395
+ # 32 numpy.int32
396
+ # 64 numpy.int64
397
+ # -32 numpy.float32
398
+ # -64 numpy.float64
399
+ assert float_length in list(BIT_DICT.keys()), (
400
+ f"{float_length=} not in {BIT_DICT=}"
401
+ )
402
+ bit_pix = int(float_length)
403
+ logger.info(f"Specified {float_length=}, corresponding to {bit_pix=}")
404
+ new_header["BITPIX"] = -abs(bit_pix)
405
+
368
406
  output_header = await create_cube_from_scratch_coro(
369
407
  output_file=out_cube, output_header=new_header, overwrite=overwrite
370
408
  )
@@ -619,6 +657,8 @@ async def process_channel(
619
657
  old_channel: int,
620
658
  is_missing: bool,
621
659
  file_list: list[Path],
660
+ bounding_box: BoundingBox | None = None,
661
+ invalidate_zeros: bool = False,
622
662
  ) -> None:
623
663
  msg = f"Processing channel {new_channel}"
624
664
  logger.info(msg)
@@ -631,6 +671,20 @@ async def process_channel(
631
671
  fits.getdata, file_list[old_channel], memmap=False
632
672
  )
633
673
 
674
+ if bounding_box is not None:
675
+ plane = plane[
676
+ ...,
677
+ bounding_box.xmin : bounding_box.xmax,
678
+ bounding_box.ymin : bounding_box.ymax,
679
+ ]
680
+ if invalidate_zeros:
681
+ plane[plane == 0.0] = np.nan
682
+
683
+ if "BITPIX" in new_header:
684
+ bit_pix = abs(new_header["BITPIX"])
685
+ float_type = FLOAT_TYPE[bit_pix]
686
+ plane = plane.astype(float_type)
687
+
634
688
  await write_channel_to_cube_coro(
635
689
  file_handle=file_handle,
636
690
  plane=plane,
@@ -650,6 +704,9 @@ async def combine_fits_coro(
650
704
  overwrite: bool = False,
651
705
  max_workers: int | None = None,
652
706
  time_domain_mode: bool = False,
707
+ bounding_box: bool = False,
708
+ invalidate_zeros: bool = False,
709
+ float_length: FLOAT_LENGTH | None = None,
653
710
  ) -> u.Quantity:
654
711
  """Combine FITS files into a cube.
655
712
  Can handle either frequency or time dimensions agnostically
@@ -660,6 +717,9 @@ async def combine_fits_coro(
660
717
  ignore_spec (bool, optional): Ignore frequency/time information. Defaults to False.
661
718
  create_blanks (bool, optional): Attempt to create even frequency spacing. Defaults to False.
662
719
  time_domain_mode (bool, optional): Work in time domain mode - make a time-cube. Default = False.
720
+ 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.
721
+ invalidate_zeros (bool, optionals): Set pixels whose values are exactly zero to NaNs. Defaults to False.
722
+ 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.
663
723
 
664
724
  Returns:
665
725
  tuple[fits.HDUList, u.Quantity]: The combined FITS cube and frequencies
@@ -704,6 +764,21 @@ async def combine_fits_coro(
704
764
  specs = specs[new_sort_idx]
705
765
  missing_chan_idx = missing_chan_idx[new_sort_idx]
706
766
 
767
+ # Get the bounding box, if requested
768
+ final_bounding_box = None
769
+ if bounding_box:
770
+ boxes_futures = [
771
+ get_bounding_box_for_fits_coro(
772
+ fits_path=fits_path, invalidate_zeros=invalidate_zeros
773
+ )
774
+ for fits_path in file_list
775
+ ]
776
+ boxes = await gather_with_limit(
777
+ max_workers, *boxes_futures, desc="Bounding boxes"
778
+ )
779
+ final_bounding_box = extract_common_bounding_box(bounding_boxes=boxes)
780
+ logger.info(f"The final bounding box is: {final_bounding_box=}")
781
+
707
782
  # Initialize the data cube
708
783
  new_header, _, _, _ = await create_output_cube_coro(
709
784
  old_name=file_list[0],
@@ -714,6 +789,8 @@ async def combine_fits_coro(
714
789
  single_beam=single_beam,
715
790
  overwrite=overwrite,
716
791
  time_domain_mode=time_domain_mode,
792
+ bounding_box=final_bounding_box,
793
+ float_length=float_length,
717
794
  )
718
795
 
719
796
  new_channels = np.arange(len(specs))
@@ -741,6 +818,8 @@ async def combine_fits_coro(
741
818
  old_channel=old_channel,
742
819
  is_missing=is_missing,
743
820
  file_list=file_list,
821
+ bounding_box=final_bounding_box,
822
+ invalidate_zeros=invalidate_zeros,
744
823
  )
745
824
  coros.append(coro)
746
825
 
@@ -823,6 +902,23 @@ def get_parser(
823
902
  default=None,
824
903
  help="Maximum number of workers to use for concurrent processing",
825
904
  )
905
+ parser.add_argument(
906
+ "--bounding-box",
907
+ action="store_true",
908
+ help="Attempt to consider padded images when creating the cube. Requires an extract read of the input data.",
909
+ )
910
+ parser.add_argument(
911
+ "--invalidate-zeros",
912
+ action="store_true",
913
+ help="Set pixels whose values are exactly zero to NaNs",
914
+ )
915
+ parser.add_argument(
916
+ "--floating",
917
+ type=int,
918
+ choices=(8, 16, 32, 64),
919
+ default=None,
920
+ help="The number of floating point bits to use in the out cube. If None the input data precision is used.",
921
+ )
826
922
 
827
923
  return parser
828
924
 
@@ -863,6 +959,9 @@ def cli(args: argparse.Namespace | None = None) -> None:
863
959
  overwrite=overwrite,
864
960
  max_workers=args.max_workers,
865
961
  time_domain_mode=time_domain_mode,
962
+ bounding_box=args.bounding_box,
963
+ invalidate_zeros=args.invalidate_zeros,
964
+ float_length=args.floating,
866
965
  )
867
966
 
868
967
  spequency = "times" if time_domain_mode else "frequencies"
@@ -38,6 +38,7 @@ dependencies = [
38
38
  [project.optional-dependencies]
39
39
  test = [
40
40
  "pytest >=6",
41
+ "pytest-asyncio",
41
42
  "pytest-cov >=3",
42
43
  ]
43
44
  dev = [
@@ -4,6 +4,7 @@ from pathlib import Path
4
4
  from shutil import unpack_archive
5
5
 
6
6
  import pytest
7
+ from astropy.io import fits
7
8
 
8
9
  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 "
9
10
 
@@ -65,3 +66,31 @@ def time_image_paths(tmpdir) -> list[Path]:
65
66
  image_paths.sort()
66
67
 
67
68
  return image_paths
69
+
70
+
71
+ @pytest.fixture
72
+ def time_image_paths_withzeroborder(tmpdir) -> list[Path]:
73
+ """Same as above but zero out border pixels in the data"""
74
+ tmp_dir = Path(tmpdir) / "time_images"
75
+ tmp_dir.mkdir(exist_ok=True, parents=True)
76
+ images_zip = Path(__file__).parent / "data" / "time_images.zip"
77
+
78
+ unpack_archive(images_zip, tmp_dir)
79
+ image_paths = list(tmp_dir.glob("*fits"))
80
+ image_paths.sort()
81
+
82
+ output_paths = []
83
+ for image in image_paths:
84
+ with fits.open(image, memmap=False, lazy_load_hdus=False) as a:
85
+ data = a[0].data
86
+ data[..., :5, :] = 0
87
+ data[..., -5:, :] = 0
88
+ data[..., :, :5] = 0
89
+ data[..., :, -5:] = 0
90
+
91
+ a[0].data = data
92
+ image_output = image.with_suffix(".zero.fits")
93
+ fits.writeto(image_output, data=data, header=a[0].header)
94
+ output_paths.append(image_output)
95
+
96
+ return output_paths
@@ -0,0 +1,97 @@
1
+ # Tests to ensure the bounding box functionality is working
2
+ from __future__ import annotations
3
+
4
+ import numpy as np
5
+ import pytest
6
+ from fitscube.bounding_box import (
7
+ BoundingBox,
8
+ create_bound_box_plane,
9
+ extract_common_bounding_box,
10
+ )
11
+
12
+
13
+ def test_bound_box_plane() -> None:
14
+ """See if we can make the bounding box correctly"""
15
+
16
+ image = np.zeros((100, 100))
17
+
18
+ bb = create_bound_box_plane(image_data=image)
19
+ assert isinstance(bb, BoundingBox)
20
+ assert bb.xmin == 0
21
+ assert bb.ymin == 0
22
+ assert bb.xmax == 100
23
+ assert bb.ymax == 100
24
+ assert bb.x_span == 100
25
+ assert bb.y_span == 100
26
+
27
+ image[:, :4] = np.nan
28
+ image[96:, :] = np.nan
29
+
30
+ bb = create_bound_box_plane(image_data=image)
31
+ assert isinstance(bb, BoundingBox)
32
+ assert bb.xmin == 0
33
+ assert bb.ymin == 4
34
+ assert bb.xmax == 96
35
+ assert bb.ymax == 100
36
+ assert bb.x_span == 96
37
+ assert bb.y_span == 96
38
+
39
+
40
+ def test_extract_common_bounding_box() -> None:
41
+ """Construct a set of bounding boxes to ensure a largest fits all
42
+ one can be created"""
43
+
44
+ bbs = []
45
+ for i in range(2, 8, 1):
46
+ image = np.zeros((100, 100))
47
+
48
+ print(i)
49
+
50
+ image[:i, :] = np.nan
51
+ image[:, -i:] = np.nan
52
+ assert np.sum(np.isnan(image)) > 1
53
+ bbs.append(create_bound_box_plane(image_data=image))
54
+
55
+ assert len(bbs) == 6
56
+
57
+ bb = extract_common_bounding_box(bounding_boxes=bbs)
58
+ assert bb.xmin == 2
59
+ assert bb.ymin == 0
60
+ assert bb.xmax == 100
61
+ assert bb.ymax == 98
62
+
63
+
64
+ def test_extract_common_bounding_box_error() -> None:
65
+ """See if the correct errors are raised"""
66
+
67
+ with pytest.raises(ValueError, match="No valid"):
68
+ extract_common_bounding_box(bounding_boxes=[None, None, None])
69
+
70
+ bbs = []
71
+ for i in range(2, 8, 1):
72
+ image = np.zeros((100 + i, 100))
73
+ bbs.append(create_bound_box_plane(image_data=image))
74
+
75
+ with pytest.raises(ValueError, match="Different shapes"):
76
+ extract_common_bounding_box(bounding_boxes=bbs)
77
+
78
+
79
+ # @pytest.mark.asyncio
80
+ # async def test_get_bounding_box_from_fits(time_image_paths) -> None:
81
+ # """!!! NOTE: Sometimes this test can raise the following error:
82
+
83
+ # > Exception ignored in: <socket.socket fd=-1, family=1, type=1, proto=0>
84
+
85
+ # Not sure why it is capable of such a thing.
86
+ # """
87
+ # futures = [
88
+ # await get_bounding_box_for_fits_coro(fits_path=fits_path)
89
+ # for fits_path in time_image_paths
90
+ # ]
91
+ # assert all(isinstance(bb, BoundingBox) for bb in futures)
92
+
93
+ # common_bb = extract_common_bounding_box(bounding_boxes=futures)
94
+ # assert common_bb.xmin == 0
95
+ # assert common_bb.ymin == 0
96
+ # assert common_bb.xmax == 100
97
+ # assert common_bb.ymax == 100
@@ -92,6 +92,14 @@ def test_even_combine(file_list: list[Path], even_specs: u.Quantity, output_file
92
92
  def test_uneven_combine(
93
93
  file_list: list[Path], even_specs: u.Quantity, output_file: Path
94
94
  ):
95
+ """!!!! NOTE: For some unknown reason thios test *SOMETIMES* raises the
96
+ following error:
97
+
98
+ > FAILED tests/test_times.py::test_uneven - ValueError: operands could not be broadcast together with shapes (5,) (6,)
99
+
100
+ Completely unclear to me why
101
+ """
102
+
95
103
  # uneven_specs = np.concatenate([even_specs[0:1], even_specs[3:]])
96
104
  file_array = np.array(file_list)
97
105
  uneven_files = np.concatenate([file_array[0:1], file_array[3:]]).tolist()
@@ -149,3 +157,113 @@ def test_wsclean_images_create_axis(time_image_paths, tmpdir) -> None:
149
157
  # The TIME axis will be appended as a new dimension
150
158
  cube_image_data = cube_data[i]
151
159
  assert np.allclose(image_data.squeeze(), cube_image_data.squeeze())
160
+
161
+
162
+ @pytest.mark.filterwarnings("ignore:'datfix' made the change")
163
+ def test_wsclean_images_create_axis_floating(time_image_paths, tmpdir) -> None:
164
+ """Ensure that the combined cube conforms to the input data"""
165
+
166
+ tmpdir = Path(tmpdir) / "time_cube_combine"
167
+ tmpdir.mkdir(parents=True, exist_ok=True)
168
+ out_cube = tmpdir / "time_cube_mate.fits"
169
+
170
+ for float_length, float_rep in ((32, ">f4"), (64, ">f8")):
171
+ combine_fits(
172
+ file_list=time_image_paths,
173
+ out_cube=out_cube,
174
+ overwrite=True,
175
+ time_domain_mode=True,
176
+ float_length=float_length, # type: ignore [arg-type]
177
+ )
178
+
179
+ cube_data = fits.getdata(out_cube)
180
+ assert cube_data.dtype == float_rep
181
+ for i, time_image_path in enumerate(time_image_paths):
182
+ image_data = fits.getdata(time_image_path)
183
+ # The TIME axis will be appended as a new dimension
184
+ cube_image_data = cube_data[i]
185
+ assert np.allclose(image_data.squeeze(), cube_image_data.squeeze())
186
+
187
+
188
+ @pytest.mark.filterwarnings("ignore:'datfix' made the change")
189
+ def test_wsclean_images_create_axis_withbb(time_image_paths, tmpdir) -> None:
190
+ """Ensure that the combined cube conforms to the input data. This will use the
191
+ bounding box option. This should led to no change."""
192
+
193
+ tmpdir = Path(tmpdir) / "time_cube_combine"
194
+ tmpdir.mkdir(parents=True, exist_ok=True)
195
+ out_cube = tmpdir / "time_cube_mate.fits"
196
+
197
+ combine_fits(
198
+ file_list=time_image_paths,
199
+ out_cube=out_cube,
200
+ overwrite=True,
201
+ time_domain_mode=True,
202
+ bounding_box=True,
203
+ )
204
+
205
+ cube_data = fits.getdata(out_cube)
206
+ for i, time_image_path in enumerate(time_image_paths):
207
+ image_data = fits.getdata(time_image_path)
208
+ # The TIME axis will be appended as a new dimension
209
+ cube_image_data = cube_data[i]
210
+ assert np.allclose(image_data.squeeze(), cube_image_data.squeeze())
211
+
212
+
213
+ @pytest.mark.filterwarnings("ignore:'datfix' made the change")
214
+ def test_wsclean_images_create_axis_withbb_noinvalidate0(
215
+ time_image_paths_withzeroborder, tmpdir
216
+ ) -> None:
217
+ """Ensure that the combined cube conforms to the input data. This will use the
218
+ bounding box option. This should led to no change."""
219
+
220
+ tmpdir = Path(tmpdir) / "time_cube_combine_zeros"
221
+ tmpdir.mkdir(parents=True, exist_ok=True)
222
+ out_cube = tmpdir / "time_cube_mate.fits"
223
+
224
+ combine_fits(
225
+ file_list=time_image_paths_withzeroborder,
226
+ out_cube=out_cube,
227
+ overwrite=True,
228
+ time_domain_mode=True,
229
+ bounding_box=True,
230
+ invalidate_zeros=False,
231
+ )
232
+
233
+ cube_data = fits.getdata(out_cube)
234
+ assert cube_data.squeeze().shape[-2:] == (100, 100)
235
+ for i, time_image_path in enumerate(time_image_paths_withzeroborder):
236
+ image_data = fits.getdata(time_image_path)
237
+ # The TIME axis will be appended as a new dimension
238
+ cube_image_data = cube_data[i]
239
+ assert np.allclose(image_data.squeeze(), cube_image_data.squeeze())
240
+
241
+
242
+ @pytest.mark.filterwarnings("ignore:'datfix' made the change")
243
+ def test_wsclean_images_create_axis_withbb_invalidatezeros(
244
+ time_image_paths_withzeroborder, tmpdir
245
+ ) -> None:
246
+ """Ensure that the combined cube conforms to the input data. This will use the
247
+ bounding box option. This should led to no change. Here we also get fitscube to
248
+ mark pixels that are exactly 0 to be nans"""
249
+
250
+ tmpdir = Path(tmpdir) / "time_cube_combine_zeros"
251
+ tmpdir.mkdir(parents=True, exist_ok=True)
252
+ out_cube = tmpdir / "time_cube_mate.fits"
253
+
254
+ combine_fits(
255
+ file_list=time_image_paths_withzeroborder,
256
+ out_cube=out_cube,
257
+ overwrite=True,
258
+ time_domain_mode=True,
259
+ bounding_box=True,
260
+ invalidate_zeros=True,
261
+ )
262
+
263
+ cube_data = fits.getdata(out_cube)
264
+ assert cube_data.squeeze().shape[-2:] == (90, 90)
265
+ for i, time_image_path in enumerate(time_image_paths_withzeroborder):
266
+ image_data = fits.getdata(time_image_path)
267
+ # The TIME axis will be appended as a new dimension
268
+ cube_image_data = cube_data[i]
269
+ assert np.allclose(image_data.squeeze()[5:-5, 5:-5], cube_image_data.squeeze())
@@ -1,21 +0,0 @@
1
- # file generated by setuptools-scm
2
- # don't change, don't track in version control
3
-
4
- __all__ = ["__version__", "__version_tuple__", "version", "version_tuple"]
5
-
6
- TYPE_CHECKING = False
7
- if TYPE_CHECKING:
8
- from typing import Tuple
9
- from typing import Union
10
-
11
- VERSION_TUPLE = Tuple[Union[int, str], ...]
12
- else:
13
- VERSION_TUPLE = object
14
-
15
- version: str
16
- __version__: str
17
- __version_tuple__: VERSION_TUPLE
18
- version_tuple: VERSION_TUPLE
19
-
20
- __version__ = version = '2.2.0'
21
- __version_tuple__ = version_tuple = (2, 2, 0)
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