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.
Files changed (39) hide show
  1. {fitscube-2.6.0 → fitscube-2.7.0}/.github/workflows/cd.yml +1 -1
  2. {fitscube-2.6.0 → fitscube-2.7.0}/PKG-INFO +1 -1
  3. {fitscube-2.6.0 → fitscube-2.7.0}/fitscube/_version.py +2 -2
  4. {fitscube-2.6.0 → fitscube-2.7.0}/fitscube/extract.py +149 -1
  5. {fitscube-2.6.0 → fitscube-2.7.0}/tests/test_extract.py +92 -1
  6. {fitscube-2.6.0 → fitscube-2.7.0}/.github/CONTRIBUTING.md +0 -0
  7. {fitscube-2.6.0 → fitscube-2.7.0}/.github/dependabot.yml +0 -0
  8. {fitscube-2.6.0 → fitscube-2.7.0}/.github/release.yml +0 -0
  9. {fitscube-2.6.0 → fitscube-2.7.0}/.github/workflows/ci.yml +0 -0
  10. {fitscube-2.6.0 → fitscube-2.7.0}/.gitignore +0 -0
  11. {fitscube-2.6.0 → fitscube-2.7.0}/.pre-commit-config.yaml +0 -0
  12. {fitscube-2.6.0 → fitscube-2.7.0}/CHANGELOG.md +0 -0
  13. {fitscube-2.6.0 → fitscube-2.7.0}/LICENSE +0 -0
  14. {fitscube-2.6.0 → fitscube-2.7.0}/README.md +0 -0
  15. {fitscube-2.6.0 → fitscube-2.7.0}/fitscube/__init__.py +0 -0
  16. {fitscube-2.6.0 → fitscube-2.7.0}/fitscube/asyncio.py +0 -0
  17. {fitscube-2.6.0 → fitscube-2.7.0}/fitscube/bounding_box.py +0 -0
  18. {fitscube-2.6.0 → fitscube-2.7.0}/fitscube/cli.py +0 -0
  19. {fitscube-2.6.0 → fitscube-2.7.0}/fitscube/combine_fits.py +0 -0
  20. {fitscube-2.6.0 → fitscube-2.7.0}/fitscube/exceptions.py +0 -0
  21. {fitscube-2.6.0 → fitscube-2.7.0}/fitscube/logging.py +0 -0
  22. {fitscube-2.6.0 → fitscube-2.7.0}/fitscube/version.pyi +0 -0
  23. {fitscube-2.6.0 → fitscube-2.7.0}/noxfile.py +0 -0
  24. {fitscube-2.6.0 → fitscube-2.7.0}/pyproject.toml +0 -0
  25. {fitscube-2.6.0 → fitscube-2.7.0}/tests/__init__.py +0 -0
  26. {fitscube-2.6.0 → fitscube-2.7.0}/tests/conftest.py +0 -0
  27. {fitscube-2.6.0 → fitscube-2.7.0}/tests/data/cube.zip +0 -0
  28. {fitscube-2.6.0 → fitscube-2.7.0}/tests/data/images.zip +0 -0
  29. {fitscube-2.6.0 → fitscube-2.7.0}/tests/data/time_images.zip +0 -0
  30. {fitscube-2.6.0 → fitscube-2.7.0}/tests/data/timecube.zip +0 -0
  31. {fitscube-2.6.0 → fitscube-2.7.0}/tests/test_bb.py +0 -0
  32. {fitscube-2.6.0 → fitscube-2.7.0}/tests/test_combine.py +0 -0
  33. {fitscube-2.6.0 → fitscube-2.7.0}/tests/test_combine_guards.py +0 -0
  34. {fitscube-2.6.0 → fitscube-2.7.0}/tests/test_compress.py +0 -0
  35. {fitscube-2.6.0 → fitscube-2.7.0}/tests/test_frequencies.py +0 -0
  36. {fitscube-2.6.0 → fitscube-2.7.0}/tests/test_package.py +0 -0
  37. {fitscube-2.6.0 → fitscube-2.7.0}/tests/test_times.py +0 -0
  38. {fitscube-2.6.0 → fitscube-2.7.0}/tests/test_zero_beams.py +0 -0
  39. {fitscube-2.6.0 → fitscube-2.7.0}/uv.lock +0 -0
@@ -49,7 +49,7 @@ jobs:
49
49
  path: dist
50
50
 
51
51
  - name: Generate artifact attestation for sdist and wheel
52
- uses: actions/attest-build-provenance@v4.1.1
52
+ uses: actions/attest-build-provenance@v4.2.2
53
53
  with:
54
54
  subject-path: "dist/*"
55
55
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: fitscube
3
- Version: 2.6.0
3
+ Version: 2.7.0
4
4
  Summary: A package to produce produce FITS cubes.
5
5
  Project-URL: Homepage, https://github.com/AlecThomson/fitscube
6
6
  Project-URL: Bug Tracker, https://github.com/AlecThomson/fitscube/issues
@@ -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.6.0'
22
- __version_tuple__ = version_tuple = (2, 6, 0)
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.exceptions import ChannelMissingException, TargetAxisMissingException
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 ChannelMissingException, TargetAxisMissingException
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