hebog 0.22.0__py3-none-any.whl

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 (95) hide show
  1. hebog/__init__.py +43 -0
  2. hebog/__main__.py +5 -0
  3. hebog/adapters/__init__.py +17 -0
  4. hebog/adapters/rapthor.py +445 -0
  5. hebog/adapters/rapthor_catalogue.py +265 -0
  6. hebog/adapters/rapthor_comparison.py +331 -0
  7. hebog/algorithms/__init__.py +1 -0
  8. hebog/algorithms/astrometry.py +930 -0
  9. hebog/algorithms/background.py +1341 -0
  10. hebog/algorithms/component_measurement.py +1771 -0
  11. hebog/algorithms/component_records.py +240 -0
  12. hebog/algorithms/component_topology.py +433 -0
  13. hebog/algorithms/deblending.py +544 -0
  14. hebog/algorithms/detection.py +146 -0
  15. hebog/algorithms/extended_measurement.py +1508 -0
  16. hebog/algorithms/feature_envelopes.py +550 -0
  17. hebog/algorithms/fft.py +50 -0
  18. hebog/algorithms/fitting.py +1952 -0
  19. hebog/algorithms/label_groups.py +211 -0
  20. hebog/algorithms/labelling.py +303 -0
  21. hebog/algorithms/measurement.py +358 -0
  22. hebog/algorithms/multiscale.py +1263 -0
  23. hebog/algorithms/multiscale_association.py +729 -0
  24. hebog/algorithms/multiscale_tiles.py +444 -0
  25. hebog/algorithms/owner_connectivity.py +642 -0
  26. hebog/algorithms/partitioning.py +21 -0
  27. hebog/algorithms/reconciliation.py +527 -0
  28. hebog/algorithms/source_association.py +1398 -0
  29. hebog/algorithms/source_count.py +109 -0
  30. hebog/cli.py +30 -0
  31. hebog/config.py +551 -0
  32. hebog/data_models/__init__.py +83 -0
  33. hebog/data_models/astrometry.py +91 -0
  34. hebog/data_models/catalogues.py +456 -0
  35. hebog/data_models/fitting.py +156 -0
  36. hebog/data_models/generations.py +145 -0
  37. hebog/data_models/images.py +132 -0
  38. hebog/data_models/measurement.py +132 -0
  39. hebog/data_models/measurement_diagnostics.py +147 -0
  40. hebog/data_models/multiscale.py +120 -0
  41. hebog/data_models/partitioning.py +406 -0
  42. hebog/data_models/products.py +55 -0
  43. hebog/data_models/source_association.py +453 -0
  44. hebog/data_models/source_finding.py +634 -0
  45. hebog/executors/__init__.py +38 -0
  46. hebog/executors/base.py +191 -0
  47. hebog/executors/dask.py +247 -0
  48. hebog/executors/serial.py +71 -0
  49. hebog/executors/threads.py +166 -0
  50. hebog/io/__init__.py +63 -0
  51. hebog/io/base.py +80 -0
  52. hebog/io/filesystem.py +44 -0
  53. hebog/io/fits.py +765 -0
  54. hebog/io/materialization.py +1307 -0
  55. hebog/io/pixel_validity.py +187 -0
  56. hebog/io/staging.py +240 -0
  57. hebog/io/zarr.py +854 -0
  58. hebog/pipeline.py +191 -0
  59. hebog/public_api.py +1554 -0
  60. hebog/public_science.py +83 -0
  61. hebog/py.typed +0 -0
  62. hebog/resources/__init__.py +1 -0
  63. hebog/resources/reviewed_continuum_profile.json +198 -0
  64. hebog/science/__init__.py +1 -0
  65. hebog/science/catalogue_rows.py +253 -0
  66. hebog/science/catalogues.py +636 -0
  67. hebog/science/configuration.py +74 -0
  68. hebog/science/continuum.py +81 -0
  69. hebog/science/detection_islands.py +254 -0
  70. hebog/science/models.py +130 -0
  71. hebog/science/profile.py +101 -0
  72. hebog/science/segment_rows.py +950 -0
  73. hebog/stages/__init__.py +1 -0
  74. hebog/stages/association.py +1487 -0
  75. hebog/stages/background.py +1686 -0
  76. hebog/stages/batching.py +208 -0
  77. hebog/stages/catalogue_rows.py +1189 -0
  78. hebog/stages/component_fits.py +1049 -0
  79. hebog/stages/component_topology.py +844 -0
  80. hebog/stages/composition.py +1344 -0
  81. hebog/stages/detection.py +539 -0
  82. hebog/stages/extended_groups.py +639 -0
  83. hebog/stages/fit_parents.py +812 -0
  84. hebog/stages/islands.py +888 -0
  85. hebog/stages/multiscale.py +1081 -0
  86. hebog/stages/object_extents.py +219 -0
  87. hebog/stages/publication.py +1421 -0
  88. hebog/stages/sources.py +1264 -0
  89. hebog/stages/support.py +621 -0
  90. hebog/stages/task_memory.py +77 -0
  91. hebog-0.22.0.dist-info/METADATA +149 -0
  92. hebog-0.22.0.dist-info/RECORD +95 -0
  93. hebog-0.22.0.dist-info/WHEEL +4 -0
  94. hebog-0.22.0.dist-info/entry_points.txt +3 -0
  95. hebog-0.22.0.dist-info/licenses/LICENSE +28 -0
hebog/__init__.py ADDED
@@ -0,0 +1,43 @@
1
+ """A scalable source finder for radio continuum images."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ from hebog.config import SourceFinderConfig
6
+ from hebog.data_models import (
7
+ SourceFinderRequest,
8
+ SourceFinderResult,
9
+ SuppliedImageMetadata,
10
+ )
11
+ from hebog.pipeline import (
12
+ InvalidSourceFinderInputError,
13
+ SourceFinderError,
14
+ SourceFinderImageTooLargeError,
15
+ SourceFinderOutputExistsError,
16
+ SourceFinderStagingWarning,
17
+ UnsupportedSourceFinderConfigurationError,
18
+ analysis_memory_bytes,
19
+ expected_source_count,
20
+ find_sources,
21
+ )
22
+
23
+ try:
24
+ __version__ = version("hebog")
25
+ except PackageNotFoundError:
26
+ __version__ = "0.22.0"
27
+
28
+ __all__ = [
29
+ "InvalidSourceFinderInputError",
30
+ "SourceFinderConfig",
31
+ "SourceFinderError",
32
+ "SourceFinderImageTooLargeError",
33
+ "SourceFinderOutputExistsError",
34
+ "SourceFinderRequest",
35
+ "SourceFinderResult",
36
+ "SourceFinderStagingWarning",
37
+ "SuppliedImageMetadata",
38
+ "UnsupportedSourceFinderConfigurationError",
39
+ "__version__",
40
+ "analysis_memory_bytes",
41
+ "expected_source_count",
42
+ "find_sources",
43
+ ]
hebog/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Run the Hebog command-line interface."""
2
+
3
+ from hebog.cli import main
4
+
5
+ raise SystemExit(main())
@@ -0,0 +1,17 @@
1
+ """Workflow compatibility boundaries built on Hebog's scientific API."""
2
+
3
+ from hebog.adapters.rapthor import (
4
+ RapthorBlankedImageError,
5
+ RapthorCompatibilityConfig,
6
+ RapthorSourceFindingRequest,
7
+ RapthorSourceFindingResult,
8
+ find_sector_sources,
9
+ )
10
+
11
+ __all__ = [
12
+ "RapthorBlankedImageError",
13
+ "RapthorCompatibilityConfig",
14
+ "RapthorSourceFindingRequest",
15
+ "RapthorSourceFindingResult",
16
+ "find_sector_sources",
17
+ ]
@@ -0,0 +1,445 @@
1
+ # pyright: reportAttributeAccessIssue=false
2
+ # pyright: reportMissingTypeStubs=false
3
+ # pyright: reportUnknownMemberType=false
4
+ # pyright: reportUnknownVariableType=false
5
+ # pyright: reportUnknownArgumentType=false
6
+ """Hebog as the source finder of Rapthor's ``filter_skymodel`` step.
7
+
8
+ Rapthor's step runs PyBDSF through LSMTool on one sector's two images, the
9
+ primary-beam-corrected true-sky image and the flat-noise image, then
10
+ filters the sector's sky models with LSMTool's ``filter_sources``.
11
+ :func:`find_sector_sources` runs Hebog in its place, in-process on the
12
+ executor Rapthor builds, and writes the files Rapthor and ``filter_sources``
13
+ read, under the names Rapthor's PyBDSF step gives them. Rapthor calls it
14
+ and then ``filter_sources``; this module imports no Rapthor, Prefect,
15
+ LSMTool or scheduler objects.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import shutil
21
+ from dataclasses import dataclass, replace
22
+ from math import isfinite
23
+ from pathlib import Path
24
+ from time import monotonic
25
+ from typing import Literal
26
+ from uuid import uuid4
27
+
28
+ import numpy as np
29
+ from astropy.io import fits
30
+ from astropy.wcs.utils import proj_plane_pixel_scales
31
+
32
+ from hebog.adapters.rapthor_catalogue import write_rapthor_catalogue_fits
33
+ from hebog.config import SourceFinderConfig
34
+ from hebog.data_models import SourceFinderRequest, SourceFinderResult
35
+ from hebog.data_models.images import ImageMetadata
36
+ from hebog.executors import Executor
37
+ from hebog.io import (
38
+ FitsImageSource,
39
+ celestial_wcs_from_metadata,
40
+ read_catalogue_fits_product,
41
+ )
42
+ from hebog.pipeline import InvalidSourceFinderInputError, find_sources
43
+
44
+ _SCHEMA_VERSION = 2
45
+ # PyBDSF's beam area in pixels is 1.1331 times the beam's major axis over
46
+ # the pixel width times its minor axis over the pixel height
47
+ # (``readimage.pixel_beamarea``); its default minimum island is a third of
48
+ # that, truncated, and never fewer than six pixels (``islands.py``).
49
+ _PYBDSF_BEAM_AREA_FACTOR = 1.1331
50
+ _PYBDSF_MINIMUM_ISLAND_PIXELS = 6
51
+ _BLANKED_MESSAGE = "All pixels in the image are blanked."
52
+ # Rows written at once: 128 rows of a 20,000-pixel plane hold 10 MB.
53
+ _BLOCK_ROWS = 128
54
+
55
+
56
+ class RapthorBlankedImageError(RuntimeError):
57
+ """A sector image has no pixel to measure, as PyBDSF reports it.
58
+
59
+ PyBDSF raises ``RuntimeError("All pixels in the image are blanked.")``
60
+ when every pixel is NaN, and Rapthor's step recognises that message and
61
+ writes its empty products, so this error carries the same message and
62
+ type. Hebog also counts as blank a pixel inside a 3x3 square of one
63
+ value, as imagers fill blanked regions, so an image of one value is
64
+ blank too.
65
+ """
66
+
67
+ def __init__(self) -> None:
68
+ """Carry PyBDSF's message."""
69
+ super().__init__(_BLANKED_MESSAGE)
70
+
71
+
72
+ @dataclass(frozen=True, slots=True)
73
+ class RapthorCompatibilityConfig:
74
+ """Rapthor's source-finding settings, under Rapthor's names.
75
+
76
+ ``threshpix`` and ``threshisl`` are the detection and island thresholds
77
+ in sigma that Rapthor's imaging strategy passes (5 and 3, or 5 and 4 in
78
+ early self-calibration cycles). The minimum island size is not a
79
+ setting: :func:`find_sector_sources` applies PyBDSF's rule to the
80
+ sector's beam. ``profile`` and ``background`` choose Hebog's science
81
+ profile and background. Rapthor's are ``compact`` and ``zero``, PyBDSF's
82
+ zero mean map: of the four, they make Rapthor keep the sky-model
83
+ components pinned PyBDSF ``master`` keeps most often, though below the
84
+ 99.5% the plan targets (plan task 21). The other options LSMTool passes
85
+ to PyBDSF (the RMS boxes, the bright-source threshold and the wavelet
86
+ scales) are fixed by Hebog's reviewed science, and LSMTool's mask filter
87
+ is LSMTool's own; the Rapthor source-finding contract maps each to what
88
+ replaces it.
89
+ """
90
+
91
+ threshpix: float
92
+ threshisl: float
93
+ profile: Literal["continuum", "compact"] = "compact"
94
+ background: Literal["estimated", "zero"] = "zero"
95
+
96
+ def __post_init__(self) -> None:
97
+ """Fail where the settings are made, not after the images load."""
98
+ self.source_finder_config(_PYBDSF_MINIMUM_ISLAND_PIXELS)
99
+
100
+ def source_finder_config(
101
+ self, minimum_island_pixels: int
102
+ ) -> SourceFinderConfig:
103
+ """Return the finder's configuration for one minimum island size."""
104
+ return SourceFinderConfig(
105
+ detection_threshold_sigma=self.threshpix,
106
+ island_threshold_sigma=self.threshisl,
107
+ minimum_island_pixels=minimum_island_pixels,
108
+ profile=self.profile,
109
+ background=self.background,
110
+ )
111
+
112
+
113
+ @dataclass(frozen=True, slots=True)
114
+ class RapthorSourceFindingRequest:
115
+ """One Rapthor sector's two images, and the root its products are named by.
116
+
117
+ ``output_root`` is Rapthor's ``<working directory>/<image name>``. The
118
+ products are ``<root>.source_catalog.fits``, ``<root>.true_sky_rms.fits``
119
+ and ``<root>.flat_noise_rms.fits``, the island mask is
120
+ ``<true-sky image name>.mask.fits`` in the root's directory, and Hebog's
121
+ own product bundle is the directory ``<root>.hebog``. ``run_id`` names
122
+ the sector's analysis; a retried task passes the same one.
123
+ """
124
+
125
+ true_sky_image_path: Path
126
+ flat_noise_image_path: Path
127
+ output_root: Path
128
+ run_id: str
129
+ schema_version: Literal[2] = _SCHEMA_VERSION
130
+
131
+ def __post_init__(self) -> None:
132
+ """Reject unsupported versions and empty run identifiers."""
133
+ if self.schema_version != _SCHEMA_VERSION:
134
+ raise ValueError("unsupported Rapthor request schema version")
135
+ if not self.run_id:
136
+ raise ValueError("run_id must not be empty")
137
+
138
+
139
+ @dataclass(frozen=True, slots=True)
140
+ class RapthorSourceFindingResult:
141
+ """The files one sector's analysis wrote for Rapthor, and its counts.
142
+
143
+ ``mask_path`` is the island mask LSMTool's ``filter_sources`` clips to
144
+ the sector polygon, in place, and groups the sky model by. With no
145
+ island, as with PyBDSF's ``nisl`` of zero, LSMTool writes a dummy sky
146
+ model instead. ``products_directory`` is Hebog's own bundle, whose
147
+ diagnostics bind the run to its inputs, configuration and science.
148
+ """
149
+
150
+ source_catalog_path: Path
151
+ true_sky_rms_path: Path
152
+ flat_noise_rms_path: Path
153
+ mask_path: Path
154
+ products_directory: Path
155
+ source_count: int
156
+ island_count: int
157
+ wall_seconds: float
158
+ schema_version: Literal[2] = _SCHEMA_VERSION
159
+
160
+ def __post_init__(self) -> None:
161
+ """Validate the version and scalar result metadata."""
162
+ if self.schema_version != _SCHEMA_VERSION:
163
+ raise ValueError("unsupported Rapthor result schema version")
164
+ if self.source_count < 0:
165
+ raise ValueError("source_count cannot be negative")
166
+ if self.island_count < 0:
167
+ raise ValueError("island_count cannot be negative")
168
+ if not isfinite(self.wall_seconds) or self.wall_seconds < 0:
169
+ raise ValueError("wall_seconds must be finite and non-negative")
170
+
171
+
172
+ @dataclass(frozen=True, slots=True)
173
+ class _ProductNames:
174
+ """Where one sector's products go, as Rapthor's PyBDSF step names them."""
175
+
176
+ source_catalog: Path
177
+ true_sky_rms: Path
178
+ flat_noise_rms: Path
179
+ mask: Path
180
+ products_directory: Path
181
+
182
+ @classmethod
183
+ def of(cls, request: RapthorSourceFindingRequest) -> _ProductNames:
184
+ """Name the products of one request whose paths are absolute."""
185
+ directory = request.output_root.parent
186
+ stem = request.output_root.name
187
+ return cls(
188
+ source_catalog=directory / f"{stem}.source_catalog.fits",
189
+ true_sky_rms=directory / f"{stem}.true_sky_rms.fits",
190
+ flat_noise_rms=directory / f"{stem}.flat_noise_rms.fits",
191
+ mask=directory / f"{request.true_sky_image_path.name}.mask.fits",
192
+ products_directory=directory / f"{stem}.hebog",
193
+ )
194
+
195
+ def rapthor_files(self) -> tuple[Path, ...]:
196
+ """Return the four files Rapthor's step reads."""
197
+ return (
198
+ self.source_catalog,
199
+ self.true_sky_rms,
200
+ self.flat_noise_rms,
201
+ self.mask,
202
+ )
203
+
204
+
205
+ def pybdsf_minimum_island_pixels(metadata: ImageMetadata) -> int:
206
+ """Return the minimum island size PyBDSF would use, in pixels.
207
+
208
+ PyBDSF's ``minpix_isl`` defaults to ``max(6, int(beam area / 3))``, with
209
+ the beam area in pixels taken as 1.1331 times the major axis over the
210
+ pixel width times the minor axis over the pixel height. The widths are
211
+ the celestial WCS's pixel scales, ``|CDELT1|`` and ``|CDELT2|`` for the
212
+ north-up images Rapthor writes, as PyBDSF reads them.
213
+ """
214
+ scale_x, scale_y = (
215
+ float(scale)
216
+ for scale in proj_plane_pixel_scales(
217
+ celestial_wcs_from_metadata(metadata)
218
+ )
219
+ )
220
+ beam_area_pixels = (
221
+ _PYBDSF_BEAM_AREA_FACTOR
222
+ * (metadata.beam.major_fwhm_degrees / scale_x)
223
+ * (metadata.beam.minor_fwhm_degrees / scale_y)
224
+ )
225
+ return max(_PYBDSF_MINIMUM_ISLAND_PIXELS, int(beam_area_pixels / 3.0))
226
+
227
+
228
+ def find_sector_sources(
229
+ request: RapthorSourceFindingRequest,
230
+ config: RapthorCompatibilityConfig,
231
+ executor: Executor,
232
+ ) -> RapthorSourceFindingResult:
233
+ """Find one sector's sources and write the files Rapthor's step reads.
234
+
235
+ One :func:`hebog.find_sources` run analyses the true-sky image and
236
+ estimates the flat-noise image's RMS, on the executor the caller built,
237
+ with Rapthor's thresholds and PyBDSF's minimum island for the sector's
238
+ beam. It publishes Hebog's bundle in ``<root>.hebog``, from which this
239
+ function writes the eight-column catalogue, the two RMS maps and the
240
+ island mask in PyBDSF's four-axis layout, each under Rapthor's name.
241
+
242
+ Files an earlier run left under those four names are removed before the
243
+ analysis starts, and a failure removes those this call wrote, so after
244
+ a failed call none exists. A retry of a finished request reuses the
245
+ bundle without analysing the images again; a bundle of another request
246
+ is refused and kept.
247
+
248
+ Raises:
249
+ RapthorBlankedImageError: If either image has no pixel Hebog
250
+ measures, so its RMS is unavailable. Rapthor's step handles it
251
+ as it handles PyBDSF's error.
252
+ hebog.pipeline.SourceFinderError: If Hebog refuses the sector, or
253
+ ``<root>.hebog`` holds another run's products. Rapthor may fall
254
+ back to PyBDSF on these typed failures.
255
+ """
256
+ started = monotonic()
257
+ request = replace(
258
+ request,
259
+ true_sky_image_path=request.true_sky_image_path.absolute(),
260
+ flat_noise_image_path=request.flat_noise_image_path.absolute(),
261
+ output_root=request.output_root.absolute(),
262
+ )
263
+ names = _ProductNames.of(request)
264
+ _remove(names.rapthor_files())
265
+ true_sky = _image_metadata(request.true_sky_image_path)
266
+ minimum_island_pixels = _shared_minimum_island_pixels(
267
+ true_sky,
268
+ _image_metadata(request.flat_noise_image_path),
269
+ request.flat_noise_image_path,
270
+ )
271
+ result = find_sources(
272
+ SourceFinderRequest(
273
+ image_path=request.true_sky_image_path,
274
+ output_directory=names.products_directory,
275
+ run_id=request.run_id,
276
+ flat_noise_image_path=request.flat_noise_image_path,
277
+ ),
278
+ config.source_finder_config(minimum_island_pixels),
279
+ executor,
280
+ )
281
+ flat_noise_rms = _require_unblanked(result)
282
+ try:
283
+ write_rapthor_catalogue_fits(
284
+ names.source_catalog, read_catalogue_fits_product(result.catalogue)
285
+ )
286
+ _copy(result.rms.path, names.true_sky_rms)
287
+ _copy(flat_noise_rms, names.flat_noise_rms)
288
+ _write_island_mask(result.mask.path, true_sky, names.mask)
289
+ except BaseException:
290
+ _remove(names.rapthor_files())
291
+ raise
292
+ return RapthorSourceFindingResult(
293
+ source_catalog_path=names.source_catalog,
294
+ true_sky_rms_path=names.true_sky_rms,
295
+ flat_noise_rms_path=names.flat_noise_rms,
296
+ mask_path=names.mask,
297
+ products_directory=names.products_directory,
298
+ source_count=result.source_count,
299
+ island_count=result.island_count,
300
+ wall_seconds=monotonic() - started,
301
+ )
302
+
303
+
304
+ def _remove(paths: tuple[Path, ...]) -> None:
305
+ for path in paths:
306
+ path.unlink(missing_ok=True)
307
+
308
+
309
+ def _image_metadata(path: Path) -> ImageMetadata:
310
+ """Read one image's metadata, failing as the finder would."""
311
+ source = FitsImageSource(path)
312
+ try:
313
+ return source.metadata()
314
+ except (OSError, ValueError) as error:
315
+ raise InvalidSourceFinderInputError(
316
+ f"invalid FITS source-finder input: {error}"
317
+ ) from error
318
+ finally:
319
+ source.close()
320
+
321
+
322
+ def _shared_minimum_island_pixels(
323
+ true_sky: ImageMetadata,
324
+ flat_noise: ImageMetadata,
325
+ flat_noise_path: Path,
326
+ ) -> int:
327
+ """Return the one minimum island both images' beams give.
328
+
329
+ Hebog applies one configuration to both of a sector's images, while
330
+ PyBDSF derives its minimum from each image's beam. WSClean writes both
331
+ from one restoring beam, so the two agree; images whose beams disagree
332
+ are refused rather than analysed under the wrong minimum.
333
+ """
334
+ minimum = pybdsf_minimum_island_pixels(true_sky)
335
+ flat_noise_minimum = pybdsf_minimum_island_pixels(flat_noise)
336
+ if flat_noise_minimum != minimum:
337
+ raise InvalidSourceFinderInputError(
338
+ "the true-sky and flat-noise images' beams give PyBDSF minimum "
339
+ f"island sizes of {minimum} and {flat_noise_minimum} pixels, "
340
+ f"and Hebog applies one to both: {flat_noise_path}"
341
+ )
342
+ return minimum
343
+
344
+
345
+ def _require_unblanked(result: SourceFinderResult) -> Path:
346
+ """Raise PyBDSF's error for an image with no pixel to measure.
347
+
348
+ An image's RMS is unavailable when no pixel is valid, finite and outside
349
+ any 3x3 square of one value, with a positive noise estimate; even a 3x3
350
+ patch of noise has one. Returns the flat-noise RMS product's path.
351
+ """
352
+ flat_noise_rms = result.flat_noise_rms
353
+ if flat_noise_rms is None: # pragma: no cover - the request names one
354
+ raise AssertionError("the run published no flat-noise RMS")
355
+ if "unavailable" in (
356
+ result.rms.scientific_status,
357
+ flat_noise_rms.scientific_status,
358
+ ):
359
+ raise RapthorBlankedImageError
360
+ return flat_noise_rms.path
361
+
362
+
363
+ def _temporary(path: Path) -> Path:
364
+ return path.with_name(f".{path.name}.{uuid4().hex}.tmp")
365
+
366
+
367
+ def _copy(source: Path, destination: Path) -> None:
368
+ """Copy one published product to Rapthor's name in one rename."""
369
+ temporary = _temporary(destination)
370
+ try:
371
+ shutil.copyfile(source, temporary)
372
+ temporary.replace(destination)
373
+ finally:
374
+ temporary.unlink(missing_ok=True)
375
+
376
+
377
+ def _island_mask_header(metadata: ImageMetadata) -> fits.Header:
378
+ """Return PyBDSF's island-mask header for one image.
379
+
380
+ PyBDSF writes the two celestial axes, then a Stokes axis and a frequency
381
+ axis of one pixel each (``functions.make_fits_image``). Its frequency
382
+ unit, ``HZ``, is written as the standard ``Hz``.
383
+ """
384
+ height, width = metadata.shape_yx
385
+ header = fits.Header()
386
+ header["SIMPLE"] = True
387
+ header["BITPIX"] = -32
388
+ header["NAXIS"] = 4
389
+ header["NAXIS1"] = width
390
+ header["NAXIS2"] = height
391
+ header["NAXIS3"] = 1
392
+ header["NAXIS4"] = 1
393
+ celestial = fits.Header.fromstring(
394
+ metadata.celestial_wcs.fits_header, sep="\n"
395
+ )
396
+ for card in celestial.cards:
397
+ if card.keyword not in {"", "COMMENT", "HISTORY", "END", "WCSAXES"}:
398
+ header[card.keyword] = (card.value, card.comment)
399
+ frequency_hz = metadata.reference_frequency_hz
400
+ header["CTYPE3"] = "STOKES"
401
+ header["CRVAL3"] = 1.0
402
+ header["CDELT3"] = 1.0
403
+ header["CRPIX3"] = 1.0
404
+ header["CUNIT3"] = ""
405
+ header["CTYPE4"] = "FREQ"
406
+ header["CRVAL4"] = frequency_hz
407
+ header["CDELT4"] = 3e8
408
+ header["CRPIX4"] = 1.0
409
+ header["CUNIT4"] = "Hz"
410
+ header["RESTFRQ"] = frequency_hz
411
+ header["SPECSYS"] = "TOPOCENT"
412
+ return header
413
+
414
+
415
+ def _write_island_mask(
416
+ published_mask: Path, metadata: ImageMetadata, destination: Path
417
+ ) -> None:
418
+ """Write the published mask as PyBDSF exports its island mask.
419
+
420
+ PyBDSF writes ``float32`` ones inside islands and zeros elsewhere on
421
+ four axes, and LSMTool reads that layout: ``filter_sources`` clips
422
+ ``data[0, 0]`` to the sector polygon in place and groups the sky model
423
+ through casacore, which reads the first plane of the first two axes.
424
+ The plane is streamed by row blocks, so no image-sized array is held.
425
+ """
426
+ temporary = _temporary(destination)
427
+ try:
428
+ with fits.open(published_mask, memmap=True) as published:
429
+ plane = published[0].data
430
+ stream = fits.StreamingHDU(
431
+ temporary, _island_mask_header(metadata)
432
+ )
433
+ try:
434
+ for start in range(0, metadata.shape_yx[0], _BLOCK_ROWS):
435
+ stream.write(
436
+ np.asarray(
437
+ plane[start : start + _BLOCK_ROWS],
438
+ dtype=np.float32,
439
+ )
440
+ )
441
+ finally:
442
+ stream.close()
443
+ temporary.replace(destination)
444
+ finally:
445
+ temporary.unlink(missing_ok=True)