fitscube 2.1.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.1.0 → fitscube-2.3.0}/.github/workflows/cd.yml +2 -2
  2. {fitscube-2.1.0 → fitscube-2.3.0}/.github/workflows/ci.yml +3 -3
  3. {fitscube-2.1.0 → fitscube-2.3.0}/.pre-commit-config.yaml +5 -5
  4. {fitscube-2.1.0 → fitscube-2.3.0}/PKG-INFO +1 -1
  5. {fitscube-2.1.0 → fitscube-2.3.0}/fitscube/_version.py +16 -3
  6. fitscube-2.3.0/fitscube/bounding_box.py +149 -0
  7. {fitscube-2.1.0 → fitscube-2.3.0}/fitscube/combine_fits.py +73 -3
  8. {fitscube-2.1.0 → fitscube-2.3.0}/.github/CONTRIBUTING.md +0 -0
  9. {fitscube-2.1.0 → fitscube-2.3.0}/.github/dependabot.yml +0 -0
  10. {fitscube-2.1.0 → fitscube-2.3.0}/.github/release.yml +0 -0
  11. {fitscube-2.1.0 → fitscube-2.3.0}/.gitignore +0 -0
  12. {fitscube-2.1.0 → fitscube-2.3.0}/CHANGELOG.md +0 -0
  13. {fitscube-2.1.0 → fitscube-2.3.0}/LICENSE +0 -0
  14. {fitscube-2.1.0 → fitscube-2.3.0}/README.md +0 -0
  15. {fitscube-2.1.0 → fitscube-2.3.0}/fitscube/__init__.py +0 -0
  16. {fitscube-2.1.0 → fitscube-2.3.0}/fitscube/asyncio.py +0 -0
  17. {fitscube-2.1.0 → fitscube-2.3.0}/fitscube/cli.py +0 -0
  18. {fitscube-2.1.0 → fitscube-2.3.0}/fitscube/exceptions.py +0 -0
  19. {fitscube-2.1.0 → fitscube-2.3.0}/fitscube/extract.py +0 -0
  20. {fitscube-2.1.0 → fitscube-2.3.0}/fitscube/logging.py +0 -0
  21. {fitscube-2.1.0 → fitscube-2.3.0}/fitscube/version.pyi +0 -0
  22. {fitscube-2.1.0 → fitscube-2.3.0}/noxfile.py +0 -0
  23. {fitscube-2.1.0 → fitscube-2.3.0}/pyproject.toml +0 -0
  24. {fitscube-2.1.0 → fitscube-2.3.0}/tests/__init__.py +0 -0
  25. {fitscube-2.1.0 → fitscube-2.3.0}/tests/conftest.py +0 -0
  26. {fitscube-2.1.0 → fitscube-2.3.0}/tests/data/cube.zip +0 -0
  27. {fitscube-2.1.0 → fitscube-2.3.0}/tests/data/images.zip +0 -0
  28. {fitscube-2.1.0 → fitscube-2.3.0}/tests/data/time_images.zip +0 -0
  29. {fitscube-2.1.0 → fitscube-2.3.0}/tests/data/timecube.zip +0 -0
  30. {fitscube-2.1.0 → fitscube-2.3.0}/tests/test_extract.py +0 -0
  31. {fitscube-2.1.0 → fitscube-2.3.0}/tests/test_frequencies.py +0 -0
  32. {fitscube-2.1.0 → fitscube-2.3.0}/tests/test_package.py +0 -0
  33. {fitscube-2.1.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,14 +33,14 @@ repos:
33
33
  - id: rst-inline-touching-normal
34
34
 
35
35
  - repo: https://github.com/astral-sh/ruff-pre-commit
36
- rev: "v0.12.5"
36
+ rev: "v0.12.11"
37
37
  hooks:
38
38
  - id: ruff
39
39
  args: ["--fix", "--show-fixes"]
40
40
  - id: ruff-format
41
41
 
42
42
  - repo: https://github.com/pre-commit/mirrors-mypy
43
- rev: "v1.17.0"
43
+ rev: "v1.17.1"
44
44
  hooks:
45
45
  - id: mypy
46
46
  files: src|tests
@@ -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.1.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.1.0'
21
- __version_tuple__ = version_tuple = (2, 1, 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")
@@ -160,10 +165,17 @@ async def create_cube_from_scratch_coro(
160
165
  if output_file.exists() and overwrite:
161
166
  output_file.unlink()
162
167
 
163
- output_wcs = WCS(output_header)
168
+ try:
169
+ output_wcs = WCS(output_header)
170
+ except Exception as e:
171
+ logger.error("Error creating new header")
172
+ for k in output_header:
173
+ logger.error(f"{k} = {output_header[k]}")
174
+ raise e
164
175
  output_shape = output_wcs.array_shape
165
176
  msg = f"Creating a new FITS file with shape {output_shape}"
166
177
  logger.info(msg)
178
+
167
179
  # If the output shape is less than 1801, we can create a blank array
168
180
  # in memory and write it to disk
169
181
  if np.prod(output_shape) < 1801:
@@ -234,6 +246,7 @@ async def create_output_cube_coro(
234
246
  single_beam: bool = False,
235
247
  overwrite: bool = False,
236
248
  time_domain_mode: bool = False,
249
+ bounding_box: BoundingBox | None = None,
237
250
  ) -> InitResult:
238
251
  """Initialize the data cube.
239
252
 
@@ -324,6 +337,13 @@ async def create_output_cube_coro(
324
337
  key = f"{k}{fits_idx}"
325
338
  logger.debug(f"{key}={new_header[key]}")
326
339
 
340
+ # Add extra transform fields for consistency
341
+ if ("CD1_1" in new_header or "PC1_1" in new_header) and fits_idx != 1:
342
+ transform_type = "CD" if "CD1_1" in new_header else "PC"
343
+ pv1 = f"{transform_type}{fits_idx}_{fits_idx}"
344
+ logger.info(f"Adding {pv1} to header")
345
+ new_header[pv1] = 1.0
346
+
327
347
  if ignore_spec or not even_spec:
328
348
  logger.info(
329
349
  f"Ignore the specrency information, {ignore_spec=} or {not even_spec=}"
@@ -345,8 +365,13 @@ async def create_output_cube_coro(
345
365
  )
346
366
  del new_header["BMAJ"], new_header["BMIN"], new_header["BPA"]
347
367
 
348
- if time_domain_mode:
349
- new_header["COMMENT"] = "The frequency/chan axis in this cube represents TIME."
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
350
375
 
351
376
  plane_shape = list(old_data.shape)
352
377
  cube_shape = plane_shape.copy()
@@ -609,6 +634,8 @@ async def process_channel(
609
634
  old_channel: int,
610
635
  is_missing: bool,
611
636
  file_list: list[Path],
637
+ bounding_box: BoundingBox | None = None,
638
+ invalidate_zeros: bool = False,
612
639
  ) -> None:
613
640
  msg = f"Processing channel {new_channel}"
614
641
  logger.info(msg)
@@ -621,6 +648,15 @@ async def process_channel(
621
648
  fits.getdata, file_list[old_channel], memmap=False
622
649
  )
623
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
+
624
660
  await write_channel_to_cube_coro(
625
661
  file_handle=file_handle,
626
662
  plane=plane,
@@ -640,6 +676,8 @@ async def combine_fits_coro(
640
676
  overwrite: bool = False,
641
677
  max_workers: int | None = None,
642
678
  time_domain_mode: bool = False,
679
+ bounding_box: bool = False,
680
+ invalidate_zeros: bool = False,
643
681
  ) -> u.Quantity:
644
682
  """Combine FITS files into a cube.
645
683
  Can handle either frequency or time dimensions agnostically
@@ -650,6 +688,8 @@ async def combine_fits_coro(
650
688
  ignore_spec (bool, optional): Ignore frequency/time information. Defaults to False.
651
689
  create_blanks (bool, optional): Attempt to create even frequency spacing. Defaults to False.
652
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.
653
693
 
654
694
  Returns:
655
695
  tuple[fits.HDUList, u.Quantity]: The combined FITS cube and frequencies
@@ -694,6 +734,21 @@ async def combine_fits_coro(
694
734
  specs = specs[new_sort_idx]
695
735
  missing_chan_idx = missing_chan_idx[new_sort_idx]
696
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
+
697
752
  # Initialize the data cube
698
753
  new_header, _, _, _ = await create_output_cube_coro(
699
754
  old_name=file_list[0],
@@ -704,6 +759,7 @@ async def combine_fits_coro(
704
759
  single_beam=single_beam,
705
760
  overwrite=overwrite,
706
761
  time_domain_mode=time_domain_mode,
762
+ bounding_box=final_bounding_box,
707
763
  )
708
764
 
709
765
  new_channels = np.arange(len(specs))
@@ -731,6 +787,8 @@ async def combine_fits_coro(
731
787
  old_channel=old_channel,
732
788
  is_missing=is_missing,
733
789
  file_list=file_list,
790
+ bounding_box=final_bounding_box,
791
+ invalidate_zeros=invalidate_zeros,
734
792
  )
735
793
  coros.append(coro)
736
794
 
@@ -813,6 +871,16 @@ def get_parser(
813
871
  default=None,
814
872
  help="Maximum number of workers to use for concurrent processing",
815
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
+ )
816
884
 
817
885
  return parser
818
886
 
@@ -853,6 +921,8 @@ def cli(args: argparse.Namespace | None = None) -> None:
853
921
  overwrite=overwrite,
854
922
  max_workers=args.max_workers,
855
923
  time_domain_mode=time_domain_mode,
924
+ bounding_box=args.bounding_box,
925
+ invalidate_zeros=args.invalidate_zeros,
856
926
  )
857
927
 
858
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