fitscube 2.3.0__tar.gz → 2.3.2__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.3.0 → fitscube-2.3.2}/PKG-INFO +2 -1
  2. fitscube-2.3.2/fitscube/_version.py +24 -0
  3. {fitscube-2.3.0 → fitscube-2.3.2}/fitscube/bounding_box.py +7 -3
  4. {fitscube-2.3.0 → fitscube-2.3.2}/fitscube/combine_fits.py +89 -15
  5. {fitscube-2.3.0 → fitscube-2.3.2}/pyproject.toml +2 -0
  6. {fitscube-2.3.0 → fitscube-2.3.2}/tests/conftest.py +29 -0
  7. fitscube-2.3.2/tests/test_bb.py +97 -0
  8. {fitscube-2.3.0 → fitscube-2.3.2}/tests/test_frequencies.py +14 -1
  9. {fitscube-2.3.0 → fitscube-2.3.2}/tests/test_times.py +118 -0
  10. fitscube-2.3.0/fitscube/_version.py +0 -34
  11. {fitscube-2.3.0 → fitscube-2.3.2}/.github/CONTRIBUTING.md +0 -0
  12. {fitscube-2.3.0 → fitscube-2.3.2}/.github/dependabot.yml +0 -0
  13. {fitscube-2.3.0 → fitscube-2.3.2}/.github/release.yml +0 -0
  14. {fitscube-2.3.0 → fitscube-2.3.2}/.github/workflows/cd.yml +0 -0
  15. {fitscube-2.3.0 → fitscube-2.3.2}/.github/workflows/ci.yml +0 -0
  16. {fitscube-2.3.0 → fitscube-2.3.2}/.gitignore +0 -0
  17. {fitscube-2.3.0 → fitscube-2.3.2}/.pre-commit-config.yaml +0 -0
  18. {fitscube-2.3.0 → fitscube-2.3.2}/CHANGELOG.md +0 -0
  19. {fitscube-2.3.0 → fitscube-2.3.2}/LICENSE +0 -0
  20. {fitscube-2.3.0 → fitscube-2.3.2}/README.md +0 -0
  21. {fitscube-2.3.0 → fitscube-2.3.2}/fitscube/__init__.py +0 -0
  22. {fitscube-2.3.0 → fitscube-2.3.2}/fitscube/asyncio.py +0 -0
  23. {fitscube-2.3.0 → fitscube-2.3.2}/fitscube/cli.py +0 -0
  24. {fitscube-2.3.0 → fitscube-2.3.2}/fitscube/exceptions.py +0 -0
  25. {fitscube-2.3.0 → fitscube-2.3.2}/fitscube/extract.py +0 -0
  26. {fitscube-2.3.0 → fitscube-2.3.2}/fitscube/logging.py +0 -0
  27. {fitscube-2.3.0 → fitscube-2.3.2}/fitscube/version.pyi +0 -0
  28. {fitscube-2.3.0 → fitscube-2.3.2}/noxfile.py +0 -0
  29. {fitscube-2.3.0 → fitscube-2.3.2}/tests/__init__.py +0 -0
  30. {fitscube-2.3.0 → fitscube-2.3.2}/tests/data/cube.zip +0 -0
  31. {fitscube-2.3.0 → fitscube-2.3.2}/tests/data/images.zip +0 -0
  32. {fitscube-2.3.0 → fitscube-2.3.2}/tests/data/time_images.zip +0 -0
  33. {fitscube-2.3.0 → fitscube-2.3.2}/tests/data/timecube.zip +0 -0
  34. {fitscube-2.3.0 → fitscube-2.3.2}/tests/test_extract.py +0 -0
  35. {fitscube-2.3.0 → fitscube-2.3.2}/tests/test_package.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fitscube
3
- Version: 2.3.0
3
+ Version: 2.3.2
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.2'
22
+ __version_tuple__ = version_tuple = (2, 3, 2)
23
+
24
+ __commit_id__ = commit_id = None
@@ -15,16 +15,17 @@ from fitscube.logging import logger
15
15
 
16
16
  @dataclass(frozen=True)
17
17
  class BoundingBox:
18
- """Simple container to represent a bounding box"""
18
+ """Simple container to represent a bounding box. Maximum values can be
19
+ used as is when slicing."""
19
20
 
20
21
  xmin: int
21
22
  """Minimum x pixel"""
22
23
  xmax: int
23
24
  """Maximum x pixel"""
24
25
  ymin: int
25
- """Minimum y pixel"""
26
+ """Minimum y pixel. Can be used as is in slice (e.g. is exclusive). """
26
27
  ymax: int
27
- """Maximum y pixel"""
28
+ """Maximum y pixel Can be used as is in slice (e.g. is exclusive)."""
28
29
  original_shape: tuple[int, int]
29
30
  """The original shape of the image. If constructed against a cube this is the shape of a single plane."""
30
31
  y_span: int
@@ -62,6 +63,9 @@ def create_bound_box_plane(image_data: np.ndarray) -> BoundingBox | None:
62
63
  xmin, xmax = np.where(x_valid)[0][[0, -1]]
63
64
  ymin, ymax = np.where(y_valid)[0][[0, -1]]
64
65
 
66
+ xmax += 1
67
+ ymax += 1
68
+
65
69
  y_span = ymax - ymin
66
70
  x_span = xmax - xmin
67
71
 
@@ -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
@@ -50,6 +47,8 @@ BIT_DICT = {
50
47
  16: 2,
51
48
  8: 1,
52
49
  }
50
+ FLOAT_LENGTH = Literal[16, 32, 64]
51
+ FLOAT_TYPE = {64: ">f8", 32: ">f4"}
53
52
 
54
53
  warnings.filterwarnings("ignore", category=UserWarning, module="astropy.io.fits")
55
54
  warnings.filterwarnings("ignore", category=VerifyWarning)
@@ -133,6 +132,41 @@ def isin_close(
133
132
  return np.isclose(element[:, None], test_element, atol, rtol).any(1)
134
133
 
135
134
 
135
+ def grid_step(diffs: ArrayLike) -> float:
136
+ """Recover the fundamental channel step from the observed diffs.
137
+
138
+ On a regular grid with missing channels every diff is an integer multiple
139
+ of the channel width, so the gcd of the diffs recovers that width even when
140
+ no two surviving channels are adjacent -- the case where ``min(diffs)``
141
+ overestimates the step (e.g. present channels 0, 3, 5 give ``min == 2`` but
142
+ the true step is 1). Falls back to ``min(diffs)`` if the gcd is numerically
143
+ unstable or does not divide every diff, so the common case is unchanged (any
144
+ surviving adjacent pair makes the gcd equal the minimum diff exactly).
145
+
146
+ Args:
147
+ diffs (ArrayLike): Differences between consecutive spec values.
148
+
149
+ Returns:
150
+ float: Estimated regular grid step.
151
+ """
152
+ diffs = np.abs(np.asarray(diffs, dtype=np.longdouble))
153
+ min_diff = np.min(diffs)
154
+ tol = np.max(diffs) * 1e-6
155
+
156
+ g = diffs[0]
157
+ for d in diffs[1:]:
158
+ a, b = (g, d) if g >= d else (d, g)
159
+ # Tolerant Euclid: stop once the remainder is within the noise floor.
160
+ while b > tol:
161
+ a, b = b, a % b
162
+ g = a
163
+
164
+ divides_all = np.all(np.abs(np.round(diffs / g) - diffs / g) <= 1e-3)
165
+ if g <= tol or not divides_all:
166
+ return float(min_diff)
167
+ return float(g)
168
+
169
+
136
170
  def even_spacing(specs: u.Quantity, time_domain_mode: bool = False) -> SpequencyInfo:
137
171
  """Make the frequencies or times evenly spaced.
138
172
 
@@ -144,9 +178,9 @@ def even_spacing(specs: u.Quantity, time_domain_mode: bool = False) -> Spequency
144
178
  """
145
179
  specs_arr = specs.value.astype(np.longdouble)
146
180
  diffs = np.diff(specs_arr)
147
- min_diff: float = np.min(diffs)
148
- # Create a new array with the minimum difference
149
- new_specs = np_arange_fix(specs_arr[0], specs_arr[-1], min_diff)
181
+ step = grid_step(diffs)
182
+ # Create a new array with the fundamental grid step
183
+ new_specs = np_arange_fix(specs_arr[0], specs_arr[-1], step)
150
184
  missing_chan_idx = np.logical_not(
151
185
  isin_close(new_specs, specs_arr, time_domain_mode)
152
186
  )
@@ -247,19 +281,26 @@ async def create_output_cube_coro(
247
281
  overwrite: bool = False,
248
282
  time_domain_mode: bool = False,
249
283
  bounding_box: BoundingBox | None = None,
284
+ float_length: FLOAT_LENGTH | None = None,
250
285
  ) -> InitResult:
251
- """Initialize the data cube.
286
+ """Generate the output header and write a dummy cube to disk based on properties of the
287
+ inpute data. The output cube written here has a correctly formed header and a pre-zerod
288
+ data cube written as output.
252
289
 
253
290
  Args:
254
- old_name (str): Old FITS file name
255
- n_chan (int): Number of channels
256
-
257
- Raises:
258
- KeyError: If 2D and REFFREQ is not in header
259
- ValueError: If not 2D and FREQ is not in header
291
+ old_name (Path): The path to a representative image to draw the base fits header from
292
+ out_cube (Path): Path of the output cube to create
293
+ specs (u.Quantity): Specification of the unit that denotes the 'cube' axis
294
+ ignore_spec (bool, optional): Whether the provided `specs` axis should be ignored. If True dummy placeholder fields added. Defaults to False.
295
+ has_beams (bool, optional): Indicates whether a CASA Beam table will also be generated and added to the output cube. Defaults to False.
296
+ 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.
297
+ overwrite (bool, optional): If True the out the output cube will overwrite any existing file. Defaults to False.
298
+ 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.
299
+ 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.
300
+ 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.
260
301
 
261
302
  Returns:
262
- InitResult: header, spec_idx, spec_fits_idx, is_2d
303
+ InitResult: Details of the output cube, including the output header
263
304
  """
264
305
 
265
306
  # define units if in time or freq domain
@@ -380,6 +421,23 @@ async def create_output_cube_coro(
380
421
  else:
381
422
  cube_shape[idx] = n_chan
382
423
 
424
+ logger.critical(f"{float_length=} {new_header['BITPIX']=}")
425
+ if float_length is not None:
426
+ # Per astropy docs
427
+ # bITPIX numpy data type
428
+ # 8 numpy.uint8 (note it is UNsigned integer)
429
+ # 16 numpy.int16
430
+ # 32 numpy.int32
431
+ # 64 numpy.int64
432
+ # -32 numpy.float32
433
+ # -64 numpy.float64
434
+ assert float_length in list(BIT_DICT.keys()), (
435
+ f"{float_length=} not in {BIT_DICT=}"
436
+ )
437
+ bit_pix = int(float_length)
438
+ logger.info(f"Specified {float_length=}, corresponding to {bit_pix=}")
439
+ new_header["BITPIX"] = -abs(bit_pix)
440
+
383
441
  output_header = await create_cube_from_scratch_coro(
384
442
  output_file=out_cube, output_header=new_header, overwrite=overwrite
385
443
  )
@@ -657,6 +715,11 @@ async def process_channel(
657
715
  if invalidate_zeros:
658
716
  plane[plane == 0.0] = np.nan
659
717
 
718
+ if "BITPIX" in new_header:
719
+ bit_pix = abs(new_header["BITPIX"])
720
+ float_type = FLOAT_TYPE[bit_pix]
721
+ plane = plane.astype(float_type)
722
+
660
723
  await write_channel_to_cube_coro(
661
724
  file_handle=file_handle,
662
725
  plane=plane,
@@ -678,6 +741,7 @@ async def combine_fits_coro(
678
741
  time_domain_mode: bool = False,
679
742
  bounding_box: bool = False,
680
743
  invalidate_zeros: bool = False,
744
+ float_length: FLOAT_LENGTH | None = None,
681
745
  ) -> u.Quantity:
682
746
  """Combine FITS files into a cube.
683
747
  Can handle either frequency or time dimensions agnostically
@@ -690,6 +754,7 @@ async def combine_fits_coro(
690
754
  time_domain_mode (bool, optional): Work in time domain mode - make a time-cube. Default = False.
691
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.
692
756
  invalidate_zeros (bool, optionals): Set pixels whose values are exactly zero to NaNs. Defaults to False.
757
+ 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.
693
758
 
694
759
  Returns:
695
760
  tuple[fits.HDUList, u.Quantity]: The combined FITS cube and frequencies
@@ -760,6 +825,7 @@ async def combine_fits_coro(
760
825
  overwrite=overwrite,
761
826
  time_domain_mode=time_domain_mode,
762
827
  bounding_box=final_bounding_box,
828
+ float_length=float_length,
763
829
  )
764
830
 
765
831
  new_channels = np.arange(len(specs))
@@ -881,6 +947,13 @@ def get_parser(
881
947
  action="store_true",
882
948
  help="Set pixels whose values are exactly zero to NaNs",
883
949
  )
950
+ parser.add_argument(
951
+ "--floating",
952
+ type=int,
953
+ choices=(8, 16, 32, 64),
954
+ default=None,
955
+ help="The number of floating point bits to use in the out cube. If None the input data precision is used.",
956
+ )
884
957
 
885
958
  return parser
886
959
 
@@ -923,6 +996,7 @@ def cli(args: argparse.Namespace | None = None) -> None:
923
996
  time_domain_mode=time_domain_mode,
924
997
  bounding_box=args.bounding_box,
925
998
  invalidate_zeros=args.invalidate_zeros,
999
+ float_length=args.floating,
926
1000
  )
927
1001
 
928
1002
  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 = [
@@ -82,6 +83,7 @@ addopts = ["-ra", "--showlocals", "--strict-markers", "--strict-config"]
82
83
  xfail_strict = true
83
84
  filterwarnings = [
84
85
  "error",
86
+ "ignore:The TestRunner",
85
87
  ]
86
88
  log_cli_level = "INFO"
87
89
  testpaths = [
@@ -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
@@ -6,7 +6,7 @@ import astropy.units as u
6
6
  import numpy as np
7
7
  import pytest
8
8
  from astropy.io import fits
9
- from fitscube.combine_fits import combine_fits, parse_specs
9
+ from fitscube.combine_fits import combine_fits, even_spacing, parse_specs
10
10
 
11
11
 
12
12
  @pytest.fixture
@@ -108,3 +108,16 @@ def test_uneven_combine(
108
108
  assert chan in (1, 2)
109
109
  continue
110
110
  assert np.allclose(plane, image)
111
+
112
+
113
+ def test_even_spacing_non_adjacent_gaps():
114
+ # Present channels 0, 3, 5 GHz on a 1 GHz grid (1, 2, 4 missing). No two
115
+ # surviving channels are adjacent, so min(diffs) == 2 GHz would mis-grid the
116
+ # axis to [0, 2, 4] and drop the real channels. The gcd of the diffs
117
+ # [3, 2] GHz recovers the true 1 GHz step.
118
+ specs = np.array([0.0, 3.0, 5.0]) * u.GHz
119
+ new_specs, missing_chan_idx = even_spacing(specs)
120
+ assert len(new_specs) == 6, "should rebuild the full 0..5 GHz grid"
121
+ assert missing_chan_idx.sum() == 3
122
+ expected_missing = np.array([False, True, True, False, True, False])
123
+ assert np.array_equal(missing_chan_idx, expected_missing)
@@ -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,34 +0,0 @@
1
- # file generated by setuptools-scm
2
- # don't change, don't track in version control
3
-
4
- __all__ = [
5
- "__version__",
6
- "__version_tuple__",
7
- "version",
8
- "version_tuple",
9
- "__commit_id__",
10
- "commit_id",
11
- ]
12
-
13
- TYPE_CHECKING = False
14
- if TYPE_CHECKING:
15
- from typing import Tuple
16
- from typing import Union
17
-
18
- VERSION_TUPLE = Tuple[Union[int, str], ...]
19
- COMMIT_ID = Union[str, None]
20
- else:
21
- VERSION_TUPLE = object
22
- COMMIT_ID = object
23
-
24
- version: str
25
- __version__: str
26
- __version_tuple__: VERSION_TUPLE
27
- version_tuple: VERSION_TUPLE
28
- commit_id: COMMIT_ID
29
- __commit_id__: COMMIT_ID
30
-
31
- __version__ = version = '2.3.0'
32
- __version_tuple__ = version_tuple = (2, 3, 0)
33
-
34
- __commit_id__ = commit_id = None
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