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