fitscube 2.2.0__tar.gz → 2.3.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 (33) hide show
  1. {fitscube-2.2.0 → fitscube-2.3.0}/.github/workflows/cd.yml +2 -2
  2. {fitscube-2.2.0 → fitscube-2.3.0}/.github/workflows/ci.yml +3 -3
  3. {fitscube-2.2.0 → fitscube-2.3.0}/.pre-commit-config.yaml +4 -4
  4. {fitscube-2.2.0 → fitscube-2.3.0}/PKG-INFO +1 -1
  5. {fitscube-2.2.0 → fitscube-2.3.0}/fitscube/_version.py +16 -3
  6. fitscube-2.3.0/fitscube/bounding_box.py +149 -0
  7. {fitscube-2.2.0 → fitscube-2.3.0}/fitscube/combine_fits.py +60 -0
  8. {fitscube-2.2.0 → fitscube-2.3.0}/.github/CONTRIBUTING.md +0 -0
  9. {fitscube-2.2.0 → fitscube-2.3.0}/.github/dependabot.yml +0 -0
  10. {fitscube-2.2.0 → fitscube-2.3.0}/.github/release.yml +0 -0
  11. {fitscube-2.2.0 → fitscube-2.3.0}/.gitignore +0 -0
  12. {fitscube-2.2.0 → fitscube-2.3.0}/CHANGELOG.md +0 -0
  13. {fitscube-2.2.0 → fitscube-2.3.0}/LICENSE +0 -0
  14. {fitscube-2.2.0 → fitscube-2.3.0}/README.md +0 -0
  15. {fitscube-2.2.0 → fitscube-2.3.0}/fitscube/__init__.py +0 -0
  16. {fitscube-2.2.0 → fitscube-2.3.0}/fitscube/asyncio.py +0 -0
  17. {fitscube-2.2.0 → fitscube-2.3.0}/fitscube/cli.py +0 -0
  18. {fitscube-2.2.0 → fitscube-2.3.0}/fitscube/exceptions.py +0 -0
  19. {fitscube-2.2.0 → fitscube-2.3.0}/fitscube/extract.py +0 -0
  20. {fitscube-2.2.0 → fitscube-2.3.0}/fitscube/logging.py +0 -0
  21. {fitscube-2.2.0 → fitscube-2.3.0}/fitscube/version.pyi +0 -0
  22. {fitscube-2.2.0 → fitscube-2.3.0}/noxfile.py +0 -0
  23. {fitscube-2.2.0 → fitscube-2.3.0}/pyproject.toml +0 -0
  24. {fitscube-2.2.0 → fitscube-2.3.0}/tests/__init__.py +0 -0
  25. {fitscube-2.2.0 → fitscube-2.3.0}/tests/conftest.py +0 -0
  26. {fitscube-2.2.0 → fitscube-2.3.0}/tests/data/cube.zip +0 -0
  27. {fitscube-2.2.0 → fitscube-2.3.0}/tests/data/images.zip +0 -0
  28. {fitscube-2.2.0 → fitscube-2.3.0}/tests/data/time_images.zip +0 -0
  29. {fitscube-2.2.0 → fitscube-2.3.0}/tests/data/timecube.zip +0 -0
  30. {fitscube-2.2.0 → fitscube-2.3.0}/tests/test_extract.py +0 -0
  31. {fitscube-2.2.0 → fitscube-2.3.0}/tests/test_frequencies.py +0 -0
  32. {fitscube-2.2.0 → fitscube-2.3.0}/tests/test_package.py +0 -0
  33. {fitscube-2.2.0 → fitscube-2.3.0}/tests/test_times.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.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
@@ -1,7 +1,14 @@
1
1
  # file generated by setuptools-scm
2
2
  # don't change, don't track in version control
3
3
 
4
- __all__ = ["__version__", "__version_tuple__", "version", "version_tuple"]
4
+ __all__ = [
5
+ "__version__",
6
+ "__version_tuple__",
7
+ "version",
8
+ "version_tuple",
9
+ "__commit_id__",
10
+ "commit_id",
11
+ ]
5
12
 
6
13
  TYPE_CHECKING = False
7
14
  if TYPE_CHECKING:
@@ -9,13 +16,19 @@ if TYPE_CHECKING:
9
16
  from typing import Union
10
17
 
11
18
  VERSION_TUPLE = Tuple[Union[int, str], ...]
19
+ COMMIT_ID = Union[str, None]
12
20
  else:
13
21
  VERSION_TUPLE = object
22
+ COMMIT_ID = object
14
23
 
15
24
  version: str
16
25
  __version__: str
17
26
  __version_tuple__: VERSION_TUPLE
18
27
  version_tuple: VERSION_TUPLE
28
+ commit_id: COMMIT_ID
29
+ __commit_id__: COMMIT_ID
19
30
 
20
- __version__ = version = '2.2.0'
21
- __version_tuple__ = version_tuple = (2, 2, 0)
31
+ __version__ = version = '2.3.0'
32
+ __version_tuple__ = version_tuple = (2, 3, 0)
33
+
34
+ __commit_id__ = commit_id = None
@@ -0,0 +1,149 @@
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"""
19
+
20
+ xmin: int
21
+ """Minimum x pixel"""
22
+ xmax: int
23
+ """Maximum x pixel"""
24
+ ymin: int
25
+ """Minimum y pixel"""
26
+ ymax: int
27
+ """Maximum y pixel"""
28
+ original_shape: tuple[int, int]
29
+ """The original shape of the image. If constructed against a cube this is the shape of a single plane."""
30
+ y_span: int
31
+ """The span between ymax and ymin"""
32
+ x_span: int
33
+ """The span between xmax and xmin"""
34
+
35
+
36
+ def create_bound_box_plane(image_data: np.ndarray) -> BoundingBox | None:
37
+ """Create a bounding box around pixels in a 2D image. If all
38
+ pixels are not valid, then ``None`` is returned.
39
+
40
+ Args:
41
+ image_data (np.ndarray): The 2D image to construct a bounding box around
42
+
43
+ Returns:
44
+ Optional[BoundingBox]: None if no valid pixels, a bounding box with the (xmin,xmax,ymin,ymax) of valid pixels
45
+ """
46
+ assert len(image_data.shape) == 2, (
47
+ f"Only two-dimensional arrays supported, received {image_data.shape}"
48
+ )
49
+
50
+ # First convert to a boolean array
51
+ image_valid = np.isfinite(image_data)
52
+
53
+ if not any(image_valid.reshape(-1)):
54
+ logger.info("No pixels to creating bounding box for")
55
+ return None
56
+
57
+ # Then make them 1D arrays
58
+ x_valid = np.any(image_valid, axis=1)
59
+ y_valid = np.any(image_valid, axis=0)
60
+
61
+ # Now get the first and last index
62
+ xmin, xmax = np.where(x_valid)[0][[0, -1]]
63
+ ymin, ymax = np.where(y_valid)[0][[0, -1]]
64
+
65
+ y_span = ymax - ymin
66
+ x_span = xmax - xmin
67
+
68
+ return BoundingBox(
69
+ xmin=xmin,
70
+ xmax=xmax,
71
+ ymin=ymin,
72
+ ymax=ymax,
73
+ y_span=y_span,
74
+ x_span=x_span,
75
+ original_shape=image_data.shape[-2:],
76
+ )
77
+
78
+
79
+ def extract_common_bounding_box(
80
+ bounding_boxes: list[BoundingBox | None],
81
+ ) -> BoundingBox:
82
+ """Get the smallest bounding box that encompasses all bounding boxes
83
+
84
+ Args:
85
+ bounding_boxes (list[BoundingBox | None]): A list of bounding boxes. If None (returned for invalid images) skip it.
86
+
87
+ Raises:
88
+ ValueError: If all input bounding boxes are invalid
89
+ ValueError: If there is an `original_shape` mismatch
90
+
91
+ Returns:
92
+ BoundingBox: The smallest bounding box
93
+ """
94
+
95
+ # Step 1: filter out all Nones
96
+ valid_boxes: list[BoundingBox] = [bb for bb in bounding_boxes if bb is not None]
97
+
98
+ if len(valid_boxes) == 0:
99
+ msg = "No valid input boxes to consider"
100
+ raise ValueError(msg)
101
+
102
+ if not all(
103
+ valid_boxes[0].original_shape == bb.original_shape for bb in valid_boxes
104
+ ):
105
+ msg = "Different shapes, and not sure this is really supported or meaningful"
106
+ raise ValueError(msg)
107
+
108
+ xmin = int(np.min([bb.xmin for bb in valid_boxes]))
109
+ xmax = int(np.max([bb.xmax for bb in valid_boxes]))
110
+ ymin = int(np.min([bb.ymin for bb in valid_boxes]))
111
+ ymax = int(np.max([bb.ymax for bb in valid_boxes]))
112
+
113
+ y_span = ymax - ymin
114
+ x_span = xmax - xmin
115
+
116
+ return BoundingBox(
117
+ xmin=xmin,
118
+ xmax=xmax,
119
+ ymin=ymin,
120
+ ymax=ymax,
121
+ y_span=y_span,
122
+ x_span=x_span,
123
+ original_shape=valid_boxes[0].original_shape,
124
+ )
125
+
126
+
127
+ async def get_bounding_box_for_fits_coro(
128
+ fits_path: Path, invalidate_zeros: bool = False
129
+ ) -> BoundingBox | None:
130
+ """Create a bounding box for an image contained in a FITS file.
131
+
132
+ The assumption is that the FITS file contains an image, not a cube.
133
+ If the cube can bot be reshapped to an image without losing data
134
+ the underlying bounding box creation will fail.
135
+
136
+ Args:
137
+ fits_path (Path): The fits image to call
138
+ invalidate_zeros (bool, optional): Mark pixels that are exactly 0.0 as invalid (NaN them). Defaults to False.
139
+
140
+ Returns:
141
+ 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.
142
+ """
143
+ data = await asyncio.to_thread(fits.getdata, fits_path, memmap=False)
144
+ data = np.squeeze(data)
145
+
146
+ if invalidate_zeros:
147
+ data[data == 0.0] = np.nan
148
+
149
+ return await asyncio.to_thread(create_bound_box_plane, image_data=data)
@@ -34,6 +34,11 @@ from radio_beam.beam import NoBeamException
34
34
  from tqdm.asyncio import tqdm
35
35
 
36
36
  from fitscube.asyncio import gather_with_limit, sync_wrapper
37
+ from fitscube.bounding_box import (
38
+ BoundingBox,
39
+ extract_common_bounding_box,
40
+ get_bounding_box_for_fits_coro,
41
+ )
37
42
  from fitscube.logging import TQDM_OUT, logger, set_verbosity
38
43
 
39
44
  T = TypeVar("T")
@@ -170,6 +175,7 @@ async def create_cube_from_scratch_coro(
170
175
  output_shape = output_wcs.array_shape
171
176
  msg = f"Creating a new FITS file with shape {output_shape}"
172
177
  logger.info(msg)
178
+
173
179
  # If the output shape is less than 1801, we can create a blank array
174
180
  # in memory and write it to disk
175
181
  if np.prod(output_shape) < 1801:
@@ -240,6 +246,7 @@ async def create_output_cube_coro(
240
246
  single_beam: bool = False,
241
247
  overwrite: bool = False,
242
248
  time_domain_mode: bool = False,
249
+ bounding_box: BoundingBox | None = None,
243
250
  ) -> InitResult:
244
251
  """Initialize the data cube.
245
252
 
@@ -358,6 +365,14 @@ async def create_output_cube_coro(
358
365
  )
359
366
  del new_header["BMAJ"], new_header["BMIN"], new_header["BPA"]
360
367
 
368
+ if bounding_box:
369
+ logger.info("Updating CRPIX1 and CRPIX2 header values to reflect bounding box")
370
+ new_header["CRPIX1"] -= bounding_box.ymin
371
+ new_header["CRPIX2"] -= bounding_box.xmin
372
+ logger.info("Updating NAXIS1 and NAXIS2 ro reflect bounding box")
373
+ new_header["NAXIS1"] = bounding_box.y_span
374
+ new_header["NAXIS2"] = bounding_box.x_span
375
+
361
376
  plane_shape = list(old_data.shape)
362
377
  cube_shape = plane_shape.copy()
363
378
  if is_2d:
@@ -619,6 +634,8 @@ async def process_channel(
619
634
  old_channel: int,
620
635
  is_missing: bool,
621
636
  file_list: list[Path],
637
+ bounding_box: BoundingBox | None = None,
638
+ invalidate_zeros: bool = False,
622
639
  ) -> None:
623
640
  msg = f"Processing channel {new_channel}"
624
641
  logger.info(msg)
@@ -631,6 +648,15 @@ async def process_channel(
631
648
  fits.getdata, file_list[old_channel], memmap=False
632
649
  )
633
650
 
651
+ if bounding_box is not None:
652
+ plane = plane[
653
+ ...,
654
+ bounding_box.xmin : bounding_box.xmax,
655
+ bounding_box.ymin : bounding_box.ymax,
656
+ ]
657
+ if invalidate_zeros:
658
+ plane[plane == 0.0] = np.nan
659
+
634
660
  await write_channel_to_cube_coro(
635
661
  file_handle=file_handle,
636
662
  plane=plane,
@@ -650,6 +676,8 @@ async def combine_fits_coro(
650
676
  overwrite: bool = False,
651
677
  max_workers: int | None = None,
652
678
  time_domain_mode: bool = False,
679
+ bounding_box: bool = False,
680
+ invalidate_zeros: bool = False,
653
681
  ) -> u.Quantity:
654
682
  """Combine FITS files into a cube.
655
683
  Can handle either frequency or time dimensions agnostically
@@ -660,6 +688,8 @@ async def combine_fits_coro(
660
688
  ignore_spec (bool, optional): Ignore frequency/time information. Defaults to False.
661
689
  create_blanks (bool, optional): Attempt to create even frequency spacing. Defaults to False.
662
690
  time_domain_mode (bool, optional): Work in time domain mode - make a time-cube. Default = False.
691
+ 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.
692
+ invalidate_zeros (bool, optionals): Set pixels whose values are exactly zero to NaNs. Defaults to False.
663
693
 
664
694
  Returns:
665
695
  tuple[fits.HDUList, u.Quantity]: The combined FITS cube and frequencies
@@ -704,6 +734,21 @@ async def combine_fits_coro(
704
734
  specs = specs[new_sort_idx]
705
735
  missing_chan_idx = missing_chan_idx[new_sort_idx]
706
736
 
737
+ # Get the bounding box, if requested
738
+ final_bounding_box = None
739
+ if bounding_box:
740
+ boxes_futures = [
741
+ get_bounding_box_for_fits_coro(
742
+ fits_path=fits_path, invalidate_zeros=invalidate_zeros
743
+ )
744
+ for fits_path in file_list
745
+ ]
746
+ boxes = await gather_with_limit(
747
+ max_workers, *boxes_futures, desc="Bounding boxes"
748
+ )
749
+ final_bounding_box = extract_common_bounding_box(bounding_boxes=boxes)
750
+ logger.info(f"The final bounding box is: {final_bounding_box=}")
751
+
707
752
  # Initialize the data cube
708
753
  new_header, _, _, _ = await create_output_cube_coro(
709
754
  old_name=file_list[0],
@@ -714,6 +759,7 @@ async def combine_fits_coro(
714
759
  single_beam=single_beam,
715
760
  overwrite=overwrite,
716
761
  time_domain_mode=time_domain_mode,
762
+ bounding_box=final_bounding_box,
717
763
  )
718
764
 
719
765
  new_channels = np.arange(len(specs))
@@ -741,6 +787,8 @@ async def combine_fits_coro(
741
787
  old_channel=old_channel,
742
788
  is_missing=is_missing,
743
789
  file_list=file_list,
790
+ bounding_box=final_bounding_box,
791
+ invalidate_zeros=invalidate_zeros,
744
792
  )
745
793
  coros.append(coro)
746
794
 
@@ -823,6 +871,16 @@ def get_parser(
823
871
  default=None,
824
872
  help="Maximum number of workers to use for concurrent processing",
825
873
  )
874
+ parser.add_argument(
875
+ "--bounding-box",
876
+ action="store_true",
877
+ help="Attempt to consider padded images when creating the cube. Requires an extract read of the input data.",
878
+ )
879
+ parser.add_argument(
880
+ "--invalidate-zeros",
881
+ action="store_true",
882
+ help="Set pixels whose values are exactly zero to NaNs",
883
+ )
826
884
 
827
885
  return parser
828
886
 
@@ -863,6 +921,8 @@ def cli(args: argparse.Namespace | None = None) -> None:
863
921
  overwrite=overwrite,
864
922
  max_workers=args.max_workers,
865
923
  time_domain_mode=time_domain_mode,
924
+ bounding_box=args.bounding_box,
925
+ invalidate_zeros=args.invalidate_zeros,
866
926
  )
867
927
 
868
928
  spequency = "times" if time_domain_mode else "frequencies"
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
File without changes
File without changes