fitscube 2.0.0__tar.gz → 2.2.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.0.0 → fitscube-2.2.0}/.github/workflows/cd.yml +1 -1
- {fitscube-2.0.0 → fitscube-2.2.0}/.github/workflows/ci.yml +2 -2
- {fitscube-2.0.0 → fitscube-2.2.0}/.pre-commit-config.yaml +2 -2
- {fitscube-2.0.0 → fitscube-2.2.0}/PKG-INFO +1 -1
- {fitscube-2.0.0 → fitscube-2.2.0}/fitscube/_version.py +2 -2
- {fitscube-2.0.0 → fitscube-2.2.0}/fitscube/combine_fits.py +62 -11
- {fitscube-2.0.0 → fitscube-2.2.0}/fitscube/exceptions.py +2 -2
- {fitscube-2.0.0 → fitscube-2.2.0}/fitscube/extract.py +158 -47
- {fitscube-2.0.0 → fitscube-2.2.0}/tests/conftest.py +23 -0
- fitscube-2.2.0/tests/data/time_images.zip +0 -0
- fitscube-2.2.0/tests/data/timecube.zip +0 -0
- {fitscube-2.0.0 → fitscube-2.2.0}/tests/test_extract.py +95 -14
- {fitscube-2.0.0 → fitscube-2.2.0}/tests/test_times.py +23 -0
- {fitscube-2.0.0 → fitscube-2.2.0}/.github/CONTRIBUTING.md +0 -0
- {fitscube-2.0.0 → fitscube-2.2.0}/.github/dependabot.yml +0 -0
- {fitscube-2.0.0 → fitscube-2.2.0}/.github/release.yml +0 -0
- {fitscube-2.0.0 → fitscube-2.2.0}/.gitignore +0 -0
- {fitscube-2.0.0 → fitscube-2.2.0}/CHANGELOG.md +0 -0
- {fitscube-2.0.0 → fitscube-2.2.0}/LICENSE +0 -0
- {fitscube-2.0.0 → fitscube-2.2.0}/README.md +0 -0
- {fitscube-2.0.0 → fitscube-2.2.0}/fitscube/__init__.py +0 -0
- {fitscube-2.0.0 → fitscube-2.2.0}/fitscube/asyncio.py +0 -0
- {fitscube-2.0.0 → fitscube-2.2.0}/fitscube/cli.py +0 -0
- {fitscube-2.0.0 → fitscube-2.2.0}/fitscube/logging.py +0 -0
- {fitscube-2.0.0 → fitscube-2.2.0}/fitscube/version.pyi +0 -0
- {fitscube-2.0.0 → fitscube-2.2.0}/noxfile.py +0 -0
- {fitscube-2.0.0 → fitscube-2.2.0}/pyproject.toml +0 -0
- {fitscube-2.0.0 → fitscube-2.2.0}/tests/__init__.py +0 -0
- {fitscube-2.0.0 → fitscube-2.2.0}/tests/data/cube.zip +0 -0
- {fitscube-2.0.0 → fitscube-2.2.0}/tests/data/images.zip +0 -0
- {fitscube-2.0.0 → fitscube-2.2.0}/tests/test_frequencies.py +0 -0
- {fitscube-2.0.0 → fitscube-2.2.0}/tests/test_package.py +0 -0
|
@@ -39,7 +39,7 @@ jobs:
|
|
|
39
39
|
fail-fast: false
|
|
40
40
|
matrix:
|
|
41
41
|
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
|
42
|
-
runs-on: [ubuntu-latest
|
|
42
|
+
runs-on: [ubuntu-latest]
|
|
43
43
|
|
|
44
44
|
steps:
|
|
45
45
|
- uses: actions/checkout@v4
|
|
@@ -62,6 +62,6 @@ jobs:
|
|
|
62
62
|
--durations=20
|
|
63
63
|
|
|
64
64
|
- name: Upload coverage report
|
|
65
|
-
uses: codecov/codecov-action@v5.4.
|
|
65
|
+
uses: codecov/codecov-action@v5.4.3
|
|
66
66
|
with:
|
|
67
67
|
token: ${{ secrets.CODECOV_TOKEN }}
|
|
@@ -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.
|
|
36
|
+
rev: "v0.12.7"
|
|
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.
|
|
43
|
+
rev: "v1.17.1"
|
|
44
44
|
hooks:
|
|
45
45
|
- id: mypy
|
|
46
46
|
files: src|tests
|
|
@@ -160,7 +160,13 @@ async def create_cube_from_scratch_coro(
|
|
|
160
160
|
if output_file.exists() and overwrite:
|
|
161
161
|
output_file.unlink()
|
|
162
162
|
|
|
163
|
-
|
|
163
|
+
try:
|
|
164
|
+
output_wcs = WCS(output_header)
|
|
165
|
+
except Exception as e:
|
|
166
|
+
logger.error("Error creating new header")
|
|
167
|
+
for k in output_header:
|
|
168
|
+
logger.error(f"{k} = {output_header[k]}")
|
|
169
|
+
raise e
|
|
164
170
|
output_shape = output_wcs.array_shape
|
|
165
171
|
msg = f"Creating a new FITS file with shape {output_shape}"
|
|
166
172
|
logger.info(msg)
|
|
@@ -191,6 +197,7 @@ async def create_cube_from_scratch_coro(
|
|
|
191
197
|
|
|
192
198
|
for key, value in output_header.items():
|
|
193
199
|
header[key] = value
|
|
200
|
+
logger.debug(f"{key}={value}")
|
|
194
201
|
|
|
195
202
|
header.tofile(output_file, overwrite=overwrite)
|
|
196
203
|
|
|
@@ -253,11 +260,39 @@ async def create_output_cube_coro(
|
|
|
253
260
|
ctype = "TIME" if time_domain_mode else "FREQ"
|
|
254
261
|
|
|
255
262
|
old_data, old_header = fits.getdata(old_name, header=True, memmap=True)
|
|
256
|
-
|
|
263
|
+
sorted_specs = np.sort(specs)
|
|
264
|
+
if time_domain_mode:
|
|
265
|
+
logger.info("Computing time-differences")
|
|
266
|
+
|
|
267
|
+
# This attempts to constrain the deviation away from 'asbolute' time
|
|
268
|
+
# as far as it can be. If some time steps are not regularly space
|
|
269
|
+
# (differencce between the time of adjacent scans) can be positive or negative,
|
|
270
|
+
# but so long as the assumulated total is close to 0 then we can say
|
|
271
|
+
# it is close. This approach is catering to some strangeness in
|
|
272
|
+
# ASKAP data. If the accumulated error is small enough we can assume
|
|
273
|
+
# the FITS header can encoude the times as regular steps.
|
|
274
|
+
diff_time = np.diff(sorted_specs)
|
|
275
|
+
diff_diff_time = np.diff(diff_time)
|
|
276
|
+
running_deviation_from_zero = np.abs(np.cumsum(diff_diff_time))
|
|
277
|
+
even_spec = np.max(running_deviation_from_zero) < (np.mean(diff_time) * 0.02)
|
|
278
|
+
|
|
279
|
+
# This is a simpler way where no attempt is made to ensure the total
|
|
280
|
+
# error on the irregular steps accumulates and violates the regular
|
|
281
|
+
# spacing we can encode in the fits header. I am less trustworthy of this,
|
|
282
|
+
# if all deviations are negative they would accumulate. Individually
|
|
283
|
+
# they may not fail but across the whole TIME dimension, as encoded by the
|
|
284
|
+
# C-type header fields that define a regularly spaced interval, may.
|
|
285
|
+
# even_spec = np.all(np.abs(np.diff(np.diff(sorted_specs))) < (0.15*u.s))
|
|
286
|
+
else:
|
|
287
|
+
even_spec = np.diff(sorted_specs).std() < (1e-4 * unit)
|
|
288
|
+
|
|
289
|
+
logger.debug(f"{np.diff(sorted_specs).std()=}")
|
|
257
290
|
if not even_spec:
|
|
258
291
|
spequency = "Times" if time_domain_mode else "Frequencies"
|
|
259
292
|
msg = f"{spequency} are not evenly spaced"
|
|
260
293
|
logger.warning(msg)
|
|
294
|
+
logger.debug(f"{np.max(np.diff(sorted_specs))=}")
|
|
295
|
+
logger.debug(f"{np.min(np.diff(sorted_specs))=}")
|
|
261
296
|
|
|
262
297
|
n_chan = len(specs)
|
|
263
298
|
|
|
@@ -271,15 +306,15 @@ async def create_output_cube_coro(
|
|
|
271
306
|
try:
|
|
272
307
|
idx = wcs.axis_type_names[::-1].index(ctype)
|
|
273
308
|
|
|
274
|
-
|
|
275
|
-
|
|
309
|
+
fits_idx = wcs.axis_type_names.index(ctype) + 1
|
|
310
|
+
logger.info(f"{ctype} axis found at index {idx} (NAXIS{fits_idx})")
|
|
276
311
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
312
|
+
except ValueError:
|
|
313
|
+
msg = f"No {ctype} axis not found in WCS."
|
|
314
|
+
logger.info(msg)
|
|
315
|
+
fits_idx = len(old_data.shape) + 1
|
|
280
316
|
|
|
281
317
|
new_header = old_header.copy()
|
|
282
|
-
new_header["NAXIS"] = 3 if is_2d else len(old_data.shape)
|
|
283
318
|
new_header[f"NAXIS{fits_idx}"] = n_chan
|
|
284
319
|
new_header[f"CRPIX{fits_idx}"] = 1
|
|
285
320
|
new_header[f"CRVAL{fits_idx}"] = specs[0].value
|
|
@@ -287,7 +322,25 @@ async def create_output_cube_coro(
|
|
|
287
322
|
new_header[f"CUNIT{fits_idx}"] = f"{unit:fits}"
|
|
288
323
|
new_header[f"CTYPE{fits_idx}"] = ctype
|
|
289
324
|
|
|
325
|
+
# Figure out the correct number of dimensions to use
|
|
326
|
+
_no_of_naxis = [k for k in new_header if k.startswith("NAXIS") and k != "NAXIS"]
|
|
327
|
+
new_header["NAXIS"] = len(_no_of_naxis)
|
|
328
|
+
|
|
329
|
+
for k in ["CTYPE", "CUNIT", "CDELT"]:
|
|
330
|
+
key = f"{k}{fits_idx}"
|
|
331
|
+
logger.debug(f"{key}={new_header[key]}")
|
|
332
|
+
|
|
333
|
+
# Add extra transform fields for consistency
|
|
334
|
+
if ("CD1_1" in new_header or "PC1_1" in new_header) and fits_idx != 1:
|
|
335
|
+
transform_type = "CD" if "CD1_1" in new_header else "PC"
|
|
336
|
+
pv1 = f"{transform_type}{fits_idx}_{fits_idx}"
|
|
337
|
+
logger.info(f"Adding {pv1} to header")
|
|
338
|
+
new_header[pv1] = 1.0
|
|
339
|
+
|
|
290
340
|
if ignore_spec or not even_spec:
|
|
341
|
+
logger.info(
|
|
342
|
+
f"Ignore the specrency information, {ignore_spec=} or {not even_spec=}"
|
|
343
|
+
)
|
|
291
344
|
new_header[f"CDELT{fits_idx}"] = 1
|
|
292
345
|
del new_header[f"CUNIT{fits_idx}"]
|
|
293
346
|
new_header[f"CTYPE{fits_idx}"] = "CHAN"
|
|
@@ -305,9 +358,6 @@ async def create_output_cube_coro(
|
|
|
305
358
|
)
|
|
306
359
|
del new_header["BMAJ"], new_header["BMIN"], new_header["BPA"]
|
|
307
360
|
|
|
308
|
-
if time_domain_mode:
|
|
309
|
-
new_header["COMMENT"] = "The frequency/chan axis in this cube represents TIME."
|
|
310
|
-
|
|
311
361
|
plane_shape = list(old_data.shape)
|
|
312
362
|
cube_shape = plane_shape.copy()
|
|
313
363
|
if is_2d:
|
|
@@ -393,6 +443,7 @@ async def parse_specs_coro(
|
|
|
393
443
|
spec_file (str | None, optional): File containing frequencies/times. Defaults to None.
|
|
394
444
|
spec_list (list[float] | None, optional): List of frequencies/times. Defaults to None.
|
|
395
445
|
ignore_spec (bool | None, optional): Ignore frequency/time information. Defaults to False.
|
|
446
|
+
time_domain_mode (bool, optional): Whether these cubes dhould be formed over the time axis. Defaults to False.
|
|
396
447
|
|
|
397
448
|
Raises:
|
|
398
449
|
ValueError: If both spec_file and spec_list are specified
|
|
@@ -5,8 +5,8 @@ class FITSCubeException(Exception):
|
|
|
5
5
|
"""Base container for FITSCube exceptions"""
|
|
6
6
|
|
|
7
7
|
|
|
8
|
-
class
|
|
9
|
-
"""
|
|
8
|
+
class TargetAxisMissingException(FITSCubeException):
|
|
9
|
+
"""The target axis is missing in fits cube"""
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
class ChannelMissingException(FITSCubeException):
|
|
@@ -11,7 +11,7 @@ import numpy as np
|
|
|
11
11
|
from astropy.io import fits
|
|
12
12
|
from radio_beam import Beam, Beams
|
|
13
13
|
|
|
14
|
-
from fitscube.exceptions import ChannelMissingException,
|
|
14
|
+
from fitscube.exceptions import ChannelMissingException, TargetAxisMissingException
|
|
15
15
|
from fitscube.logging import logger, set_verbosity
|
|
16
16
|
|
|
17
17
|
|
|
@@ -21,8 +21,10 @@ class ExtractOptions:
|
|
|
21
21
|
|
|
22
22
|
hdu_index: int = 0
|
|
23
23
|
"""The HDU in the fits cube to access (e.g. for header and data)"""
|
|
24
|
-
channel_index: int =
|
|
25
|
-
"""The channel of the cube to extract"""
|
|
24
|
+
channel_index: int | None = None
|
|
25
|
+
"""The channel of the cube to extract. Defaults to None"""
|
|
26
|
+
time_index: int | None = None
|
|
27
|
+
"""The timestep of the cube to extract. Defaults to None"""
|
|
26
28
|
overwrite: bool = False
|
|
27
29
|
"""overwrite the output file, if it exists"""
|
|
28
30
|
output_path: Path | None = None
|
|
@@ -30,8 +32,8 @@ class ExtractOptions:
|
|
|
30
32
|
|
|
31
33
|
|
|
32
34
|
@dataclass
|
|
33
|
-
class
|
|
34
|
-
"""
|
|
35
|
+
class TargetWCS:
|
|
36
|
+
"""Extract of target information in the WCS taken straight from
|
|
35
37
|
the fits header."""
|
|
36
38
|
|
|
37
39
|
axis: int
|
|
@@ -45,21 +47,90 @@ class FreqWCS:
|
|
|
45
47
|
cdelt: float
|
|
46
48
|
"""The step between planes"""
|
|
47
49
|
cunit: str
|
|
48
|
-
"""the unit of the
|
|
50
|
+
"""the unit of the target axis"""
|
|
49
51
|
|
|
50
52
|
|
|
51
|
-
|
|
53
|
+
@dataclass
|
|
54
|
+
class TargetIndex:
|
|
55
|
+
"""Simple container to capture the mode to extract."""
|
|
56
|
+
|
|
57
|
+
axis_name: str
|
|
58
|
+
"""The name of the axis to search for (e.g. TIME or FREQ)"""
|
|
59
|
+
axis_index: int
|
|
60
|
+
"""The index (e.g. channel or timestep) to extract from the cube"""
|
|
61
|
+
output_name: str
|
|
62
|
+
"""The output name to put in output files"""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def create_target_index(
|
|
66
|
+
channel_index: int | None = None, time_index: int | None = None
|
|
67
|
+
) -> TargetIndex:
|
|
68
|
+
"""Define the properties of the target axis to subset based on the provided
|
|
69
|
+
index.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
channel_index (int | None, optional): If not None, the frequency channel to extract. Defaults to None.
|
|
73
|
+
time_index (int | None, optional): If not None, the timestep to extract. Defaults to None.
|
|
74
|
+
|
|
75
|
+
Raises:
|
|
76
|
+
ValueError: If both channel and item indices are supplied
|
|
77
|
+
ValueError: If neither channel nor time index are set
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
TargetIndex: Specified properties of target axis to subset
|
|
81
|
+
"""
|
|
82
|
+
if time_index is not None and channel_index is not None:
|
|
83
|
+
msg = "Both time and channel index are set. Not allowed."
|
|
84
|
+
raise ValueError(msg)
|
|
85
|
+
|
|
86
|
+
if isinstance(time_index, int):
|
|
87
|
+
return TargetIndex(
|
|
88
|
+
axis_name="TIME", axis_index=time_index, output_name="timestep"
|
|
89
|
+
)
|
|
90
|
+
if isinstance(channel_index, int):
|
|
91
|
+
return TargetIndex(
|
|
92
|
+
axis_name="FREQ", axis_index=channel_index, output_name="channel"
|
|
93
|
+
)
|
|
94
|
+
msg = f"Something went wrong, target index could not be formed, {channel_index=} {time_index=}"
|
|
95
|
+
raise ValueError(msg)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _check_extract_mode(extract_options: ExtractOptions) -> None:
|
|
99
|
+
"""Verify the operation of the extract options axis.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
extract_options (ExtractOptions): The settings providedd to extract fitscube
|
|
103
|
+
|
|
104
|
+
Raises:
|
|
105
|
+
ValueError: Raised if both channel_index and time_index are unset
|
|
106
|
+
ValueError: Raise if both channel index and time index are set
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
if extract_options.channel_index is None and extract_options.time_index is None:
|
|
110
|
+
msg = "Both channel index and time index are None. One needs to be set."
|
|
111
|
+
raise ValueError(msg)
|
|
112
|
+
if (
|
|
113
|
+
extract_options.channel_index is not None
|
|
114
|
+
and extract_options.time_index is not None
|
|
115
|
+
):
|
|
116
|
+
msg = "Both channel index and time index are set. Only one may be set. "
|
|
117
|
+
raise ValueError(msg)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def get_output_path(input_path: Path, target_index: TargetIndex) -> Path:
|
|
52
121
|
"""Create the output path to write the plane to
|
|
53
122
|
|
|
54
123
|
Args:
|
|
55
124
|
input_path (Path): The base input path name
|
|
56
|
-
|
|
125
|
+
target_index (TargetIndex): The target index to extract
|
|
57
126
|
|
|
58
127
|
Returns:
|
|
59
128
|
Path: New output path for the plane-only fits image
|
|
60
129
|
"""
|
|
130
|
+
index_suffix = f"{target_index.output_name.lower()}-{target_index.axis_index}"
|
|
131
|
+
|
|
61
132
|
# The input_path suffix returns a string with a period
|
|
62
|
-
channel_suffix = f".
|
|
133
|
+
channel_suffix = f".{index_suffix}{input_path.suffix}"
|
|
63
134
|
output_path = input_path.with_suffix(channel_suffix)
|
|
64
135
|
|
|
65
136
|
logger.debug(f"The formed {output_path=}")
|
|
@@ -86,14 +157,14 @@ def fits_file_contains_beam_table(header: fits.header.Header | Path) -> bool:
|
|
|
86
157
|
return bool(loaded_header["CASAMBM"])
|
|
87
158
|
|
|
88
159
|
|
|
89
|
-
def extract_beam_from_beam_table(fits_path: Path,
|
|
160
|
+
def extract_beam_from_beam_table(fits_path: Path, index: int) -> Beam:
|
|
90
161
|
"""Extract the beam that corresponds to the channel requested. The beam
|
|
91
162
|
is drawn from a beam table that is inserted into the FITS cube. It is
|
|
92
163
|
expected that the beam table exists.
|
|
93
164
|
|
|
94
165
|
Args:
|
|
95
166
|
fits_path (Path): The fits table to inspect for a beam table, and return the channel beam
|
|
96
|
-
|
|
167
|
+
index (int): The channel to extract the beam for
|
|
97
168
|
|
|
98
169
|
Raises:
|
|
99
170
|
ValueError: Raised when a beam table can not be found
|
|
@@ -116,26 +187,38 @@ def extract_beam_from_beam_table(fits_path: Path, channel_index: int) -> Beam:
|
|
|
116
187
|
|
|
117
188
|
assert beams is not None, "beams is empty, which should not happen"
|
|
118
189
|
|
|
119
|
-
return beams[
|
|
190
|
+
return beams[index]
|
|
120
191
|
|
|
121
192
|
|
|
122
|
-
def
|
|
123
|
-
|
|
124
|
-
|
|
193
|
+
def find_target_axis(
|
|
194
|
+
header: fits.header.Header, target_index: TargetIndex | str = "FREQ"
|
|
195
|
+
) -> TargetWCS:
|
|
196
|
+
"""Attempt to find the axies of the target dimension in the data
|
|
197
|
+
cube that corresponds to the target type (e.g. time or frequency).
|
|
125
198
|
|
|
126
199
|
Args:
|
|
127
200
|
header (fits.header.Header): The header from the fits cube
|
|
201
|
+
target_index (TargetIndex | str): The name of axus to search for in the FITS header representing the axis to extract from. Defaults to FREQ.
|
|
128
202
|
|
|
129
203
|
Returns:
|
|
130
|
-
|
|
204
|
+
TargetWCS: The information in the FITS header describing the target dimension of the cube
|
|
131
205
|
"""
|
|
132
206
|
|
|
207
|
+
axis_name = "FREQ"
|
|
208
|
+
if isinstance(target_index, str):
|
|
209
|
+
axis_name = target_index
|
|
210
|
+
elif isinstance(target_index, TargetIndex):
|
|
211
|
+
axis_name = target_index.axis_name
|
|
212
|
+
|
|
213
|
+
logger.info(f"Searching for {axis_name=} in header")
|
|
214
|
+
|
|
133
215
|
naxis = header["NAXIS"]
|
|
134
|
-
# Remember that range upper limit is exclusive
|
|
216
|
+
# Remember that range upper limit is exclusive, and
|
|
217
|
+
# we start counting from 1
|
|
135
218
|
for axis in range(1, naxis + 1):
|
|
136
|
-
if
|
|
137
|
-
logger.info(f"Found
|
|
138
|
-
return
|
|
219
|
+
if axis_name in header[f"CTYPE{axis}"]:
|
|
220
|
+
logger.info(f"Found {axis_name} at {axis=}")
|
|
221
|
+
return TargetWCS(
|
|
139
222
|
axis=axis,
|
|
140
223
|
ctype=header[f"CTYPE{axis}"],
|
|
141
224
|
crpix=header[f"CRPIX{axis}"],
|
|
@@ -145,41 +228,56 @@ def find_freq_axis(header: fits.header.Header) -> FreqWCS:
|
|
|
145
228
|
)
|
|
146
229
|
|
|
147
230
|
msg = "Did not find the frequency axis"
|
|
148
|
-
raise
|
|
231
|
+
raise TargetAxisMissingException(msg)
|
|
149
232
|
|
|
150
233
|
|
|
151
|
-
def
|
|
152
|
-
|
|
234
|
+
def create_plane_target_wcs(
|
|
235
|
+
original_freq_wcs: TargetWCS, target_index: int | TargetIndex
|
|
236
|
+
) -> TargetWCS:
|
|
237
|
+
"""Create the target fields appropriate for a extracted channel/time index
|
|
153
238
|
|
|
154
239
|
Args:
|
|
155
|
-
original_freq_wcs (
|
|
156
|
-
|
|
240
|
+
original_freq_wcs (TargetWCS): The frequency information describing the spectral axis
|
|
241
|
+
target_index (int | TargetIndex): The index to extract from the cube
|
|
157
242
|
|
|
158
243
|
Returns:
|
|
159
|
-
|
|
244
|
+
TargetWCS: The frequency information for a channel
|
|
160
245
|
"""
|
|
161
|
-
|
|
162
|
-
|
|
246
|
+
index = target_index if isinstance(target_index, int) else target_index.axis_index
|
|
247
|
+
update_index = original_freq_wcs.crval + (index * original_freq_wcs.cdelt)
|
|
248
|
+
return TargetWCS(
|
|
163
249
|
axis=original_freq_wcs.axis,
|
|
164
250
|
ctype=original_freq_wcs.ctype,
|
|
165
251
|
crpix=1,
|
|
166
|
-
crval=
|
|
252
|
+
crval=update_index,
|
|
167
253
|
cdelt=original_freq_wcs.cdelt,
|
|
168
254
|
cunit=original_freq_wcs.cunit,
|
|
169
255
|
)
|
|
170
256
|
|
|
171
257
|
|
|
172
|
-
def
|
|
258
|
+
def update_header_for_target_axis(
|
|
173
259
|
header: fits.header.Header,
|
|
174
|
-
|
|
175
|
-
|
|
260
|
+
target_wcs: TargetWCS,
|
|
261
|
+
target_index: TargetIndex,
|
|
176
262
|
extract_beam_from_file: Path | None = None,
|
|
177
263
|
) -> fits.header.Header:
|
|
264
|
+
"""Update the base header to indicate the new extracted characteristics
|
|
265
|
+
of the extracted plane.
|
|
266
|
+
|
|
267
|
+
Args:
|
|
268
|
+
header (fits.header.Header): The base header to examine and update
|
|
269
|
+
target_wcs (TargetWCS): The characteristics of the new extracted plane
|
|
270
|
+
channel_index (int): The extracted index
|
|
271
|
+
extract_beam_from_file (Path | None, optional): If not None, attempt to extract the beam table to update the stored beam inforionat. Defaults to None.
|
|
272
|
+
|
|
273
|
+
Returns:
|
|
274
|
+
fits.header.Header: The updated header
|
|
275
|
+
"""
|
|
178
276
|
# Get the new wcs items for the channels
|
|
179
|
-
plane_freq_wcs =
|
|
180
|
-
original_freq_wcs=
|
|
277
|
+
plane_freq_wcs = create_plane_target_wcs(
|
|
278
|
+
original_freq_wcs=target_wcs, target_index=target_index.axis_index
|
|
181
279
|
)
|
|
182
|
-
_idx =
|
|
280
|
+
_idx = target_wcs.axis
|
|
183
281
|
out_header = header.copy()
|
|
184
282
|
out_header[f"CTYPE{_idx}"] = plane_freq_wcs.ctype
|
|
185
283
|
out_header[f"CRPIX{_idx}"] = plane_freq_wcs.crpix
|
|
@@ -192,7 +290,7 @@ def update_header_for_frequency(
|
|
|
192
290
|
):
|
|
193
291
|
try:
|
|
194
292
|
channel_beam: Beam = extract_beam_from_beam_table(
|
|
195
|
-
fits_path=extract_beam_from_file,
|
|
293
|
+
fits_path=extract_beam_from_file, index=target_index.axis_index
|
|
196
294
|
)
|
|
197
295
|
|
|
198
296
|
out_header["BMAJ"] = channel_beam.major.to(u.deg).value
|
|
@@ -220,11 +318,20 @@ def extract_plane_from_cube(fits_cube: Path, extract_options: ExtractOptions) ->
|
|
|
220
318
|
Returns:
|
|
221
319
|
Path: The output file
|
|
222
320
|
"""
|
|
321
|
+
# Initial sanity check of the axis to extract
|
|
322
|
+
_check_extract_mode(extract_options=extract_options)
|
|
323
|
+
|
|
324
|
+
target_index = create_target_index(
|
|
325
|
+
channel_index=extract_options.channel_index,
|
|
326
|
+
time_index=extract_options.time_index,
|
|
327
|
+
)
|
|
328
|
+
|
|
223
329
|
output_path: Path = (
|
|
224
330
|
extract_options.output_path
|
|
225
331
|
if extract_options.output_path
|
|
226
332
|
else get_output_path(
|
|
227
|
-
input_path=fits_cube,
|
|
333
|
+
input_path=fits_cube,
|
|
334
|
+
target_index=target_index,
|
|
228
335
|
)
|
|
229
336
|
)
|
|
230
337
|
|
|
@@ -241,21 +348,21 @@ def extract_plane_from_cube(fits_cube: Path, extract_options: ExtractOptions) ->
|
|
|
241
348
|
logger.info("Extracted header and data")
|
|
242
349
|
|
|
243
350
|
logger.info(f"Data shape: {data.shape}")
|
|
244
|
-
|
|
245
|
-
|
|
351
|
+
target_axis_wcs = find_target_axis(header=header, target_index=target_index)
|
|
352
|
+
target_cube_index = len(data.shape) - target_axis_wcs.axis
|
|
246
353
|
|
|
247
|
-
if
|
|
248
|
-
msg = f"{extract_options.channel_index=} outside of channel cube {data.shape=}"
|
|
354
|
+
if target_index.axis_index > data.shape[target_cube_index]:
|
|
355
|
+
msg = f"{extract_options.channel_index=} outside of channel cube {data.shape=}, axis shape of {data.shape[target_axis_wcs.axis - 1]}"
|
|
249
356
|
raise ChannelMissingException(msg)
|
|
250
357
|
|
|
251
358
|
# Get the channel index requested
|
|
252
|
-
freq_plane_data = np.take(data,
|
|
359
|
+
freq_plane_data = np.take(data, target_index.axis_index, axis=target_cube_index)
|
|
253
360
|
# and pad it back so dimensions match
|
|
254
|
-
freq_plane_data = np.expand_dims(freq_plane_data, axis=
|
|
255
|
-
freq_plane_header =
|
|
361
|
+
freq_plane_data = np.expand_dims(freq_plane_data, axis=target_cube_index)
|
|
362
|
+
freq_plane_header = update_header_for_target_axis(
|
|
256
363
|
header=header,
|
|
257
|
-
|
|
258
|
-
|
|
364
|
+
target_wcs=target_axis_wcs,
|
|
365
|
+
target_index=target_index,
|
|
259
366
|
extract_beam_from_file=fits_cube
|
|
260
367
|
if fits_file_contains_beam_table(header) # replace with "BEAMS" in opened fits?
|
|
261
368
|
else None,
|
|
@@ -281,7 +388,10 @@ def get_parser(parser: ArgumentParser | None = None) -> ArgumentParser:
|
|
|
281
388
|
|
|
282
389
|
parser.add_argument("fits_cube", type=Path, help="The cube to extract a plane from")
|
|
283
390
|
parser.add_argument(
|
|
284
|
-
"--channel-index", type=int, default=
|
|
391
|
+
"--channel-index", type=int, default=None, help="The channel to extract"
|
|
392
|
+
)
|
|
393
|
+
parser.add_argument(
|
|
394
|
+
"--time-index", type=int, default=None, help="The channel to extract"
|
|
285
395
|
)
|
|
286
396
|
parser.add_argument(
|
|
287
397
|
"--hdu-index",
|
|
@@ -315,6 +425,7 @@ def cli(args: Namespace | None = None) -> None:
|
|
|
315
425
|
extract_options = ExtractOptions(
|
|
316
426
|
hdu_index=args.hdu_index,
|
|
317
427
|
channel_index=args.channel_index,
|
|
428
|
+
time_index=args.time_index,
|
|
318
429
|
overwrite=args.overwrite,
|
|
319
430
|
output_path=args.output_path,
|
|
320
431
|
)
|
|
@@ -21,6 +21,16 @@ def headers() -> dict[str, str]:
|
|
|
21
21
|
return {"base": EXAMPLE_HEADER, "beams": EXAMPLE_HEADER_WITH_BM}
|
|
22
22
|
|
|
23
23
|
|
|
24
|
+
@pytest.fixture
|
|
25
|
+
def timecube_path(tmpdir) -> Path:
|
|
26
|
+
tmp_dir = Path(tmpdir) / "timecube"
|
|
27
|
+
tmp_dir.mkdir(exist_ok=True, parents=True)
|
|
28
|
+
cube_zip = Path(__file__).parent / "data" / "timecube.zip"
|
|
29
|
+
|
|
30
|
+
unpack_archive(cube_zip, tmp_dir)
|
|
31
|
+
return tmp_dir / "test_timecube.fits"
|
|
32
|
+
|
|
33
|
+
|
|
24
34
|
@pytest.fixture
|
|
25
35
|
def cube_path(tmpdir) -> Path:
|
|
26
36
|
tmp_dir = Path(tmpdir) / "cube"
|
|
@@ -42,3 +52,16 @@ def image_paths(tmpdir) -> list[Path]:
|
|
|
42
52
|
image_paths.sort()
|
|
43
53
|
|
|
44
54
|
return image_paths
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@pytest.fixture
|
|
58
|
+
def time_image_paths(tmpdir) -> list[Path]:
|
|
59
|
+
tmp_dir = Path(tmpdir) / "time_images"
|
|
60
|
+
tmp_dir.mkdir(exist_ok=True, parents=True)
|
|
61
|
+
images_zip = Path(__file__).parent / "data" / "time_images.zip"
|
|
62
|
+
|
|
63
|
+
unpack_archive(images_zip, tmp_dir)
|
|
64
|
+
image_paths = list(tmp_dir.glob("*fits"))
|
|
65
|
+
image_paths.sort()
|
|
66
|
+
|
|
67
|
+
return image_paths
|
|
Binary file
|
|
Binary file
|
|
@@ -7,27 +7,74 @@ from pathlib import Path
|
|
|
7
7
|
import numpy as np
|
|
8
8
|
import pytest
|
|
9
9
|
from astropy.io import fits
|
|
10
|
-
from fitscube.exceptions import ChannelMissingException
|
|
10
|
+
from fitscube.exceptions import ChannelMissingException, TargetAxisMissingException
|
|
11
11
|
from fitscube.extract import (
|
|
12
12
|
ExtractOptions,
|
|
13
|
-
|
|
13
|
+
TargetIndex,
|
|
14
|
+
_check_extract_mode,
|
|
15
|
+
create_plane_target_wcs,
|
|
16
|
+
create_target_index,
|
|
14
17
|
extract_plane_from_cube,
|
|
15
|
-
|
|
18
|
+
find_target_axis,
|
|
16
19
|
fits_file_contains_beam_table,
|
|
17
20
|
get_output_path,
|
|
18
|
-
|
|
21
|
+
update_header_for_target_axis,
|
|
19
22
|
)
|
|
20
23
|
|
|
21
24
|
|
|
25
|
+
def test_target_index() -> None:
|
|
26
|
+
"""Simple test of the target container"""
|
|
27
|
+
target_index = create_target_index(channel_index=2)
|
|
28
|
+
assert isinstance(target_index, TargetIndex)
|
|
29
|
+
assert target_index.axis_name == "FREQ"
|
|
30
|
+
assert target_index.axis_index == 2
|
|
31
|
+
|
|
32
|
+
target_index = create_target_index(time_index=20)
|
|
33
|
+
assert isinstance(target_index, TargetIndex)
|
|
34
|
+
assert target_index.axis_name == "TIME"
|
|
35
|
+
assert target_index.axis_index == 20
|
|
36
|
+
|
|
37
|
+
# int of 0 is False
|
|
38
|
+
target_index = create_target_index(time_index=0)
|
|
39
|
+
assert isinstance(target_index, TargetIndex)
|
|
40
|
+
assert target_index.axis_name == "TIME"
|
|
41
|
+
assert target_index.axis_index == 0
|
|
42
|
+
|
|
43
|
+
with pytest.raises(ValueError, match="index"):
|
|
44
|
+
create_target_index()
|
|
45
|
+
|
|
46
|
+
with pytest.raises(ValueError, match="index"):
|
|
47
|
+
create_target_index(channel_index=2, time_index=4)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_check_mode_for_consistency() -> None:
|
|
51
|
+
"""See if the basic consistency checks for the extraction mode makes sense"""
|
|
52
|
+
extract_options = ExtractOptions()
|
|
53
|
+
with pytest.raises(ValueError, match="index"):
|
|
54
|
+
_check_extract_mode(extract_options=extract_options)
|
|
55
|
+
|
|
56
|
+
extract_options = ExtractOptions(channel_index=3, time_index=4)
|
|
57
|
+
with pytest.raises(ValueError, match="index"):
|
|
58
|
+
_check_extract_mode(extract_options=extract_options)
|
|
59
|
+
|
|
60
|
+
|
|
22
61
|
def test_get_output_path() -> None:
|
|
23
62
|
"""Make sure the output path generated is correct"""
|
|
24
63
|
|
|
64
|
+
target_index = create_target_index(channel_index=10)
|
|
25
65
|
in_fits = Path("some.example.cube.fits")
|
|
26
|
-
channel_index = 10
|
|
27
66
|
expected_fits = Path("some.example.cube.channel-10.fits")
|
|
28
67
|
|
|
29
68
|
assert expected_fits == get_output_path(
|
|
30
|
-
input_path=in_fits,
|
|
69
|
+
input_path=in_fits, target_index=target_index
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
target_index = create_target_index(time_index=10)
|
|
73
|
+
in_fits = Path("some.example.cube.fits")
|
|
74
|
+
expected_fits = Path("some.example.cube.timestep-10.fits")
|
|
75
|
+
|
|
76
|
+
assert expected_fits == get_output_path(
|
|
77
|
+
input_path=in_fits, target_index=target_index
|
|
31
78
|
)
|
|
32
79
|
|
|
33
80
|
|
|
@@ -43,7 +90,7 @@ def test_find_freq_axis(example_header) -> None:
|
|
|
43
90
|
"""Find the components associated with frequency from the header"""
|
|
44
91
|
header = fits.header.Header.fromstring(example_header)
|
|
45
92
|
|
|
46
|
-
freq_wcs =
|
|
93
|
+
freq_wcs = find_target_axis(header=header)
|
|
47
94
|
assert freq_wcs.axis == 4
|
|
48
95
|
assert freq_wcs.crpix == 1
|
|
49
96
|
assert freq_wcs.crval == 801490740.740741
|
|
@@ -54,15 +101,15 @@ def test_create_plane_freq_wcs(example_header) -> None:
|
|
|
54
101
|
"""Update the freq wcs to indicate a plane"""
|
|
55
102
|
header = fits.header.Header.fromstring(example_header)
|
|
56
103
|
|
|
57
|
-
freq_wcs =
|
|
58
|
-
plane_wcs =
|
|
104
|
+
freq_wcs = find_target_axis(header=header)
|
|
105
|
+
plane_wcs = create_plane_target_wcs(original_freq_wcs=freq_wcs, target_index=1)
|
|
59
106
|
|
|
60
107
|
assert plane_wcs.axis == freq_wcs.axis
|
|
61
108
|
assert plane_wcs.crpix == 1
|
|
62
109
|
assert plane_wcs.crval == 805490740.740741
|
|
63
110
|
assert plane_wcs.cdelt == freq_wcs.cdelt
|
|
64
111
|
|
|
65
|
-
plane_wcs =
|
|
112
|
+
plane_wcs = create_plane_target_wcs(original_freq_wcs=freq_wcs, target_index=0)
|
|
66
113
|
|
|
67
114
|
assert plane_wcs.axis == freq_wcs.axis
|
|
68
115
|
assert plane_wcs.crpix == 1
|
|
@@ -76,10 +123,10 @@ def test_update_header_for_frequency(example_header) -> None:
|
|
|
76
123
|
|
|
77
124
|
header = fits.header.Header.fromstring(example_header)
|
|
78
125
|
|
|
79
|
-
freq_wcs =
|
|
80
|
-
|
|
81
|
-
new_header =
|
|
82
|
-
header=header,
|
|
126
|
+
freq_wcs = find_target_axis(header=header)
|
|
127
|
+
target_index = create_target_index(channel_index=1)
|
|
128
|
+
new_header = update_header_for_target_axis(
|
|
129
|
+
header=header, target_wcs=freq_wcs, target_index=target_index
|
|
83
130
|
)
|
|
84
131
|
assert new_header["CRPIX4"] == 1
|
|
85
132
|
assert new_header["CRVAL4"] == 805490740.740741
|
|
@@ -167,3 +214,37 @@ def test_compare_extracted_to_image_bad_channel(cube_path, tmpdir) -> None:
|
|
|
167
214
|
)
|
|
168
215
|
with pytest.raises(ChannelMissingException):
|
|
169
216
|
extract_plane_from_cube(fits_cube=cube_path, extract_options=extract_options)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def test_extract_time_from_freq(cube_path, tmpdir) -> None:
|
|
220
|
+
"""Attempt to extract a timestep from a cube without TIME. Should
|
|
221
|
+
raise an error
|
|
222
|
+
"""
|
|
223
|
+
output_file = Path(tmpdir) / "extract" / "test.fits"
|
|
224
|
+
output_file.parent.mkdir(parents=True, exist_ok=True)
|
|
225
|
+
timestep = 0
|
|
226
|
+
|
|
227
|
+
extract_options = ExtractOptions(
|
|
228
|
+
hdu_index=0, time_index=timestep, output_path=output_file
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
with pytest.raises(TargetAxisMissingException):
|
|
232
|
+
extract_plane_from_cube(fits_cube=cube_path, extract_options=extract_options)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def test_extract_time_cube(timecube_path, tmpdir) -> None:
|
|
236
|
+
"""Attempt to extract a timestep from a cube without TIME. Should
|
|
237
|
+
raise an error
|
|
238
|
+
"""
|
|
239
|
+
output_file = Path(tmpdir) / "extract" / "test.fits"
|
|
240
|
+
output_file.parent.mkdir(parents=True, exist_ok=True)
|
|
241
|
+
timestep = 200
|
|
242
|
+
|
|
243
|
+
extract_options = ExtractOptions(
|
|
244
|
+
hdu_index=0, time_index=timestep, output_path=output_file
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
timecube = extract_plane_from_cube(
|
|
248
|
+
fits_cube=timecube_path, extract_options=extract_options
|
|
249
|
+
)
|
|
250
|
+
assert timecube.exists()
|
|
@@ -126,3 +126,26 @@ def test_uneven_combine(
|
|
|
126
126
|
assert chan in (1, 2)
|
|
127
127
|
continue
|
|
128
128
|
assert np.allclose(plane, image)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@pytest.mark.filterwarnings("ignore:'datfix' made the change")
|
|
132
|
+
def test_wsclean_images_create_axis(time_image_paths, tmpdir) -> None:
|
|
133
|
+
"""Ensure that the combined cube conforms to the input data"""
|
|
134
|
+
|
|
135
|
+
tmpdir = Path(tmpdir) / "time_cube_combine"
|
|
136
|
+
tmpdir.mkdir(parents=True, exist_ok=True)
|
|
137
|
+
out_cube = tmpdir / "time_cube_mate.fits"
|
|
138
|
+
|
|
139
|
+
combine_fits(
|
|
140
|
+
file_list=time_image_paths,
|
|
141
|
+
out_cube=out_cube,
|
|
142
|
+
overwrite=True,
|
|
143
|
+
time_domain_mode=True,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
cube_data = fits.getdata(out_cube)
|
|
147
|
+
for i, time_image_path in enumerate(time_image_paths):
|
|
148
|
+
image_data = fits.getdata(time_image_path)
|
|
149
|
+
# The TIME axis will be appended as a new dimension
|
|
150
|
+
cube_image_data = cube_data[i]
|
|
151
|
+
assert np.allclose(image_data.squeeze(), cube_image_data.squeeze())
|
|
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
|