fitscube 2.6.0__tar.gz → 2.7.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.6.0 → fitscube-2.7.0}/.github/workflows/cd.yml +1 -1
- {fitscube-2.6.0 → fitscube-2.7.0}/PKG-INFO +1 -1
- {fitscube-2.6.0 → fitscube-2.7.0}/fitscube/_version.py +2 -2
- {fitscube-2.6.0 → fitscube-2.7.0}/fitscube/extract.py +149 -1
- {fitscube-2.6.0 → fitscube-2.7.0}/tests/test_extract.py +92 -1
- {fitscube-2.6.0 → fitscube-2.7.0}/.github/CONTRIBUTING.md +0 -0
- {fitscube-2.6.0 → fitscube-2.7.0}/.github/dependabot.yml +0 -0
- {fitscube-2.6.0 → fitscube-2.7.0}/.github/release.yml +0 -0
- {fitscube-2.6.0 → fitscube-2.7.0}/.github/workflows/ci.yml +0 -0
- {fitscube-2.6.0 → fitscube-2.7.0}/.gitignore +0 -0
- {fitscube-2.6.0 → fitscube-2.7.0}/.pre-commit-config.yaml +0 -0
- {fitscube-2.6.0 → fitscube-2.7.0}/CHANGELOG.md +0 -0
- {fitscube-2.6.0 → fitscube-2.7.0}/LICENSE +0 -0
- {fitscube-2.6.0 → fitscube-2.7.0}/README.md +0 -0
- {fitscube-2.6.0 → fitscube-2.7.0}/fitscube/__init__.py +0 -0
- {fitscube-2.6.0 → fitscube-2.7.0}/fitscube/asyncio.py +0 -0
- {fitscube-2.6.0 → fitscube-2.7.0}/fitscube/bounding_box.py +0 -0
- {fitscube-2.6.0 → fitscube-2.7.0}/fitscube/cli.py +0 -0
- {fitscube-2.6.0 → fitscube-2.7.0}/fitscube/combine_fits.py +0 -0
- {fitscube-2.6.0 → fitscube-2.7.0}/fitscube/exceptions.py +0 -0
- {fitscube-2.6.0 → fitscube-2.7.0}/fitscube/logging.py +0 -0
- {fitscube-2.6.0 → fitscube-2.7.0}/fitscube/version.pyi +0 -0
- {fitscube-2.6.0 → fitscube-2.7.0}/noxfile.py +0 -0
- {fitscube-2.6.0 → fitscube-2.7.0}/pyproject.toml +0 -0
- {fitscube-2.6.0 → fitscube-2.7.0}/tests/__init__.py +0 -0
- {fitscube-2.6.0 → fitscube-2.7.0}/tests/conftest.py +0 -0
- {fitscube-2.6.0 → fitscube-2.7.0}/tests/data/cube.zip +0 -0
- {fitscube-2.6.0 → fitscube-2.7.0}/tests/data/images.zip +0 -0
- {fitscube-2.6.0 → fitscube-2.7.0}/tests/data/time_images.zip +0 -0
- {fitscube-2.6.0 → fitscube-2.7.0}/tests/data/timecube.zip +0 -0
- {fitscube-2.6.0 → fitscube-2.7.0}/tests/test_bb.py +0 -0
- {fitscube-2.6.0 → fitscube-2.7.0}/tests/test_combine.py +0 -0
- {fitscube-2.6.0 → fitscube-2.7.0}/tests/test_combine_guards.py +0 -0
- {fitscube-2.6.0 → fitscube-2.7.0}/tests/test_compress.py +0 -0
- {fitscube-2.6.0 → fitscube-2.7.0}/tests/test_frequencies.py +0 -0
- {fitscube-2.6.0 → fitscube-2.7.0}/tests/test_package.py +0 -0
- {fitscube-2.6.0 → fitscube-2.7.0}/tests/test_times.py +0 -0
- {fitscube-2.6.0 → fitscube-2.7.0}/tests/test_zero_beams.py +0 -0
- {fitscube-2.6.0 → fitscube-2.7.0}/uv.lock +0 -0
|
@@ -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.7.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (2, 7, 0)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import asyncio
|
|
5
6
|
from argparse import ArgumentParser, Namespace
|
|
6
7
|
from dataclasses import dataclass
|
|
7
8
|
from pathlib import Path
|
|
@@ -11,7 +12,12 @@ import numpy as np
|
|
|
11
12
|
from astropy.io import fits
|
|
12
13
|
from radio_beam import Beam, Beams
|
|
13
14
|
|
|
14
|
-
from fitscube.
|
|
15
|
+
from fitscube.asyncio import gather_with_limit, sync_wrapper
|
|
16
|
+
from fitscube.exceptions import (
|
|
17
|
+
ChannelMissingException,
|
|
18
|
+
ShapeMismatchException,
|
|
19
|
+
TargetAxisMissingException,
|
|
20
|
+
)
|
|
15
21
|
from fitscube.logging import logger, set_verbosity
|
|
16
22
|
|
|
17
23
|
|
|
@@ -31,6 +37,20 @@ class ExtractOptions:
|
|
|
31
37
|
"""The output path of the new file. If None it is generated from the name of base fits cube."""
|
|
32
38
|
|
|
33
39
|
|
|
40
|
+
@dataclass
|
|
41
|
+
class ExtractPlanesOptions:
|
|
42
|
+
"""Options to extract every plane of a cube at once"""
|
|
43
|
+
|
|
44
|
+
hdu_index: int = 0
|
|
45
|
+
"""The HDU in the fits cube to access (e.g. for header and data)"""
|
|
46
|
+
overwrite: bool = False
|
|
47
|
+
"""overwrite the output files, if they exist"""
|
|
48
|
+
output_paths: list[Path] | None = None
|
|
49
|
+
"""The output path of each plane, in channel order. If None they are generated from the name of the base fits cube."""
|
|
50
|
+
max_workers: int | None = 4
|
|
51
|
+
"""The number of planes written at once. None places no limit."""
|
|
52
|
+
|
|
53
|
+
|
|
34
54
|
@dataclass
|
|
35
55
|
class TargetWCS:
|
|
36
56
|
"""Extract of target information in the WCS taken straight from
|
|
@@ -379,6 +399,134 @@ def extract_plane_from_cube(fits_cube: Path, extract_options: ExtractOptions) ->
|
|
|
379
399
|
return output_path
|
|
380
400
|
|
|
381
401
|
|
|
402
|
+
def count_cube_planes(header: fits.header.Header) -> int:
|
|
403
|
+
"""The number of channels along the frequency axis of a cube"""
|
|
404
|
+
return int(header[f"NAXIS{find_target_axis(header=header).axis}"])
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def read_beam_table(open_fits: fits.HDUList, hdu_index: int = 0) -> Beams | None:
|
|
408
|
+
"""The per-channel beams of an open cube, if it carries a beam table.
|
|
409
|
+
|
|
410
|
+
Args:
|
|
411
|
+
open_fits (fits.HDUList): The already open cube
|
|
412
|
+
hdu_index (int, optional): The HDU whose header names the beam table. Defaults to 0.
|
|
413
|
+
|
|
414
|
+
Returns:
|
|
415
|
+
Beams | None: One beam per channel, or None when there is no table to read
|
|
416
|
+
"""
|
|
417
|
+
if not fits_file_contains_beam_table(header=open_fits[hdu_index].header):
|
|
418
|
+
return None
|
|
419
|
+
|
|
420
|
+
if "BEAMS" not in open_fits:
|
|
421
|
+
logger.warning("Header sets CASAMBM but the file carries no BEAMS table")
|
|
422
|
+
return None
|
|
423
|
+
|
|
424
|
+
return Beams.from_fits_bintable(open_fits["BEAMS"])
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _plane_header(
|
|
428
|
+
header: fits.header.Header,
|
|
429
|
+
target_wcs: TargetWCS,
|
|
430
|
+
channel_index: int,
|
|
431
|
+
beams: Beams | None,
|
|
432
|
+
) -> fits.header.Header:
|
|
433
|
+
"""The header of a single extracted channel, beam included"""
|
|
434
|
+
out_header = update_header_for_target_axis(
|
|
435
|
+
header=header,
|
|
436
|
+
target_wcs=target_wcs,
|
|
437
|
+
target_index=create_target_index(channel_index=channel_index),
|
|
438
|
+
)
|
|
439
|
+
if beams is not None:
|
|
440
|
+
beam = beams[channel_index]
|
|
441
|
+
out_header["BMAJ"] = beam.major.to(u.deg).value
|
|
442
|
+
out_header["BMIN"] = beam.minor.to(u.deg).value
|
|
443
|
+
out_header["BPA"] = beam.pa.to(u.deg).value
|
|
444
|
+
|
|
445
|
+
# A plane holds the one channel, so its beam is the BMAJ/BMIN/BPA above
|
|
446
|
+
out_header.pop("CASAMBM", None)
|
|
447
|
+
|
|
448
|
+
return out_header
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
async def extract_planes_from_cube_coro(
|
|
452
|
+
fits_cube: Path, extract_planes_options: ExtractPlanesOptions | None = None
|
|
453
|
+
) -> list[Path]:
|
|
454
|
+
"""Extract every channel of a cube into its own file.
|
|
455
|
+
|
|
456
|
+
Calling ``extract_plane_from_cube`` once per channel reopens the cube and
|
|
457
|
+
re-reads its whole beam table each time, which grows with the square of the
|
|
458
|
+
channel count. Here the cube is opened and the beam table read once.
|
|
459
|
+
|
|
460
|
+
Args:
|
|
461
|
+
fits_cube (Path): The base fits cube to draw from
|
|
462
|
+
extract_planes_options (ExtractPlanesOptions | None, optional): Options to drive the extraction. Defaults to ExtractPlanesOptions().
|
|
463
|
+
|
|
464
|
+
Raises:
|
|
465
|
+
ShapeMismatchException: If the supplied output paths do not match the channel count
|
|
466
|
+
|
|
467
|
+
Returns:
|
|
468
|
+
list[Path]: The output file of each channel, in channel order
|
|
469
|
+
"""
|
|
470
|
+
options = extract_planes_options or ExtractPlanesOptions()
|
|
471
|
+
|
|
472
|
+
logger.info(f"Opening {fits_cube=}")
|
|
473
|
+
with fits.open(
|
|
474
|
+
name=fits_cube, mode="readonly", memmap=True, lazy_load_hdus=True
|
|
475
|
+
) as open_fits:
|
|
476
|
+
header = open_fits[options.hdu_index].header
|
|
477
|
+
data = open_fits[options.hdu_index].data
|
|
478
|
+
target_wcs = find_target_axis(header=header)
|
|
479
|
+
channels = count_cube_planes(header=header)
|
|
480
|
+
# The FITS axis order is reversed against the numpy one
|
|
481
|
+
cube_axis = data.ndim - target_wcs.axis
|
|
482
|
+
beams = read_beam_table(open_fits=open_fits, hdu_index=options.hdu_index)
|
|
483
|
+
|
|
484
|
+
if options.output_paths is None:
|
|
485
|
+
output_paths = [
|
|
486
|
+
get_output_path(
|
|
487
|
+
input_path=fits_cube,
|
|
488
|
+
target_index=create_target_index(channel_index=channel),
|
|
489
|
+
)
|
|
490
|
+
for channel in range(channels)
|
|
491
|
+
]
|
|
492
|
+
elif len(options.output_paths) != channels:
|
|
493
|
+
msg = (
|
|
494
|
+
f"Have {len(options.output_paths)} output paths for a cube of "
|
|
495
|
+
f"{channels} channels"
|
|
496
|
+
)
|
|
497
|
+
raise ShapeMismatchException(msg)
|
|
498
|
+
else:
|
|
499
|
+
output_paths = options.output_paths
|
|
500
|
+
|
|
501
|
+
def _write_plane(channel: int) -> None:
|
|
502
|
+
plane = np.expand_dims(
|
|
503
|
+
np.take(data, channel, axis=cube_axis), axis=cube_axis
|
|
504
|
+
)
|
|
505
|
+
fits.writeto(
|
|
506
|
+
output_paths[channel],
|
|
507
|
+
data=plane,
|
|
508
|
+
header=_plane_header(
|
|
509
|
+
header=header,
|
|
510
|
+
target_wcs=target_wcs,
|
|
511
|
+
channel_index=channel,
|
|
512
|
+
beams=beams,
|
|
513
|
+
),
|
|
514
|
+
overwrite=options.overwrite,
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
logger.info(f"Extracting {channels} planes from {fits_cube}")
|
|
518
|
+
await gather_with_limit(
|
|
519
|
+
options.max_workers,
|
|
520
|
+
*(asyncio.to_thread(_write_plane, channel) for channel in range(channels)),
|
|
521
|
+
desc="Extracting planes",
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
return output_paths
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
extract_planes_from_cube = sync_wrapper(extract_planes_from_cube_coro)
|
|
528
|
+
|
|
529
|
+
|
|
382
530
|
def get_parser(parser: ArgumentParser | None = None) -> ArgumentParser:
|
|
383
531
|
parser = (
|
|
384
532
|
parser
|
|
@@ -7,17 +7,25 @@ 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
|
|
10
|
+
from fitscube.exceptions import (
|
|
11
|
+
ChannelMissingException,
|
|
12
|
+
ShapeMismatchException,
|
|
13
|
+
TargetAxisMissingException,
|
|
14
|
+
)
|
|
11
15
|
from fitscube.extract import (
|
|
12
16
|
ExtractOptions,
|
|
17
|
+
ExtractPlanesOptions,
|
|
13
18
|
TargetIndex,
|
|
14
19
|
_check_extract_mode,
|
|
20
|
+
count_cube_planes,
|
|
15
21
|
create_plane_target_wcs,
|
|
16
22
|
create_target_index,
|
|
17
23
|
extract_plane_from_cube,
|
|
24
|
+
extract_planes_from_cube,
|
|
18
25
|
find_target_axis,
|
|
19
26
|
fits_file_contains_beam_table,
|
|
20
27
|
get_output_path,
|
|
28
|
+
read_beam_table,
|
|
21
29
|
update_header_for_target_axis,
|
|
22
30
|
)
|
|
23
31
|
|
|
@@ -248,3 +256,86 @@ def test_extract_time_cube(timecube_path, tmpdir) -> None:
|
|
|
248
256
|
fits_cube=timecube_path, extract_options=extract_options
|
|
249
257
|
)
|
|
250
258
|
assert timecube.exists()
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def test_count_cube_planes(cube_path) -> None:
|
|
262
|
+
"""The channel count comes off the frequency axis"""
|
|
263
|
+
header = fits.getheader(cube_path)
|
|
264
|
+
assert count_cube_planes(header=header) == header["NAXIS3"]
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def test_read_beam_table(cube_path, tmpdir) -> None:
|
|
268
|
+
"""A cube with a beam table hands back one beam per channel"""
|
|
269
|
+
with fits.open(cube_path) as open_fits:
|
|
270
|
+
beams = read_beam_table(open_fits=open_fits)
|
|
271
|
+
assert beams is not None
|
|
272
|
+
assert len(beams.major) == open_fits[0].header["NAXIS3"]
|
|
273
|
+
|
|
274
|
+
# A cube that does not claim a beam table has none to read
|
|
275
|
+
no_table = Path(tmpdir) / "no_table.fits"
|
|
276
|
+
with fits.open(cube_path) as open_fits:
|
|
277
|
+
header = open_fits[0].header.copy()
|
|
278
|
+
del header["CASAMBM"]
|
|
279
|
+
fits.writeto(no_table, data=open_fits[0].data, header=header)
|
|
280
|
+
|
|
281
|
+
with fits.open(no_table) as open_fits:
|
|
282
|
+
assert read_beam_table(open_fits=open_fits) is None
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def test_extract_planes_matches_single_plane(cube_path, tmpdir) -> None:
|
|
286
|
+
"""Extracting every plane at once must give exactly what extracting them
|
|
287
|
+
one at a time does, beam table and all"""
|
|
288
|
+
tmp_path = Path(tmpdir)
|
|
289
|
+
channels = count_cube_planes(header=fits.getheader(cube_path))
|
|
290
|
+
|
|
291
|
+
one_at_a_time = [
|
|
292
|
+
extract_plane_from_cube(
|
|
293
|
+
fits_cube=cube_path,
|
|
294
|
+
extract_options=ExtractOptions(
|
|
295
|
+
channel_index=channel,
|
|
296
|
+
output_path=tmp_path / f"single.{channel}.fits",
|
|
297
|
+
overwrite=True,
|
|
298
|
+
),
|
|
299
|
+
)
|
|
300
|
+
for channel in range(channels)
|
|
301
|
+
]
|
|
302
|
+
|
|
303
|
+
all_at_once = extract_planes_from_cube(
|
|
304
|
+
cube_path,
|
|
305
|
+
ExtractPlanesOptions(
|
|
306
|
+
overwrite=True,
|
|
307
|
+
output_paths=[
|
|
308
|
+
tmp_path / f"batch.{channel}.fits" for channel in range(channels)
|
|
309
|
+
],
|
|
310
|
+
),
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
assert len(all_at_once) == channels
|
|
314
|
+
for single, batch in zip(one_at_a_time, all_at_once, strict=True):
|
|
315
|
+
assert np.array_equal(fits.getdata(single), fits.getdata(batch))
|
|
316
|
+
single_header, batch_header = fits.getheader(single), fits.getheader(batch)
|
|
317
|
+
assert set(single_header) == set(batch_header)
|
|
318
|
+
for key in single_header:
|
|
319
|
+
assert single_header[key] == batch_header[key], f"{key} differs"
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def test_extract_planes_default_paths(cube_path) -> None:
|
|
323
|
+
"""Without output paths the planes are named after the cube"""
|
|
324
|
+
channels = count_cube_planes(header=fits.getheader(cube_path))
|
|
325
|
+
|
|
326
|
+
planes = extract_planes_from_cube(cube_path, ExtractPlanesOptions(overwrite=True))
|
|
327
|
+
|
|
328
|
+
assert len(planes) == channels
|
|
329
|
+
assert all(plane.exists() for plane in planes)
|
|
330
|
+
assert planes[0] == get_output_path(
|
|
331
|
+
input_path=cube_path, target_index=create_target_index(channel_index=0)
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def test_extract_planes_output_path_count(cube_path, tmpdir) -> None:
|
|
336
|
+
"""One output path per channel, or none at all"""
|
|
337
|
+
with pytest.raises(ShapeMismatchException):
|
|
338
|
+
extract_planes_from_cube(
|
|
339
|
+
cube_path,
|
|
340
|
+
ExtractPlanesOptions(output_paths=[Path(tmpdir) / "only_one.fits"]),
|
|
341
|
+
)
|
|
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
|
|
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
|