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.
- hebog/__init__.py +43 -0
- hebog/__main__.py +5 -0
- hebog/adapters/__init__.py +17 -0
- hebog/adapters/rapthor.py +445 -0
- hebog/adapters/rapthor_catalogue.py +265 -0
- hebog/adapters/rapthor_comparison.py +331 -0
- hebog/algorithms/__init__.py +1 -0
- hebog/algorithms/astrometry.py +930 -0
- hebog/algorithms/background.py +1341 -0
- hebog/algorithms/component_measurement.py +1771 -0
- hebog/algorithms/component_records.py +240 -0
- hebog/algorithms/component_topology.py +433 -0
- hebog/algorithms/deblending.py +544 -0
- hebog/algorithms/detection.py +146 -0
- hebog/algorithms/extended_measurement.py +1508 -0
- hebog/algorithms/feature_envelopes.py +550 -0
- hebog/algorithms/fft.py +50 -0
- hebog/algorithms/fitting.py +1952 -0
- hebog/algorithms/label_groups.py +211 -0
- hebog/algorithms/labelling.py +303 -0
- hebog/algorithms/measurement.py +358 -0
- hebog/algorithms/multiscale.py +1263 -0
- hebog/algorithms/multiscale_association.py +729 -0
- hebog/algorithms/multiscale_tiles.py +444 -0
- hebog/algorithms/owner_connectivity.py +642 -0
- hebog/algorithms/partitioning.py +21 -0
- hebog/algorithms/reconciliation.py +527 -0
- hebog/algorithms/source_association.py +1398 -0
- hebog/algorithms/source_count.py +109 -0
- hebog/cli.py +30 -0
- hebog/config.py +551 -0
- hebog/data_models/__init__.py +83 -0
- hebog/data_models/astrometry.py +91 -0
- hebog/data_models/catalogues.py +456 -0
- hebog/data_models/fitting.py +156 -0
- hebog/data_models/generations.py +145 -0
- hebog/data_models/images.py +132 -0
- hebog/data_models/measurement.py +132 -0
- hebog/data_models/measurement_diagnostics.py +147 -0
- hebog/data_models/multiscale.py +120 -0
- hebog/data_models/partitioning.py +406 -0
- hebog/data_models/products.py +55 -0
- hebog/data_models/source_association.py +453 -0
- hebog/data_models/source_finding.py +634 -0
- hebog/executors/__init__.py +38 -0
- hebog/executors/base.py +191 -0
- hebog/executors/dask.py +247 -0
- hebog/executors/serial.py +71 -0
- hebog/executors/threads.py +166 -0
- hebog/io/__init__.py +63 -0
- hebog/io/base.py +80 -0
- hebog/io/filesystem.py +44 -0
- hebog/io/fits.py +765 -0
- hebog/io/materialization.py +1307 -0
- hebog/io/pixel_validity.py +187 -0
- hebog/io/staging.py +240 -0
- hebog/io/zarr.py +854 -0
- hebog/pipeline.py +191 -0
- hebog/public_api.py +1554 -0
- hebog/public_science.py +83 -0
- hebog/py.typed +0 -0
- hebog/resources/__init__.py +1 -0
- hebog/resources/reviewed_continuum_profile.json +198 -0
- hebog/science/__init__.py +1 -0
- hebog/science/catalogue_rows.py +253 -0
- hebog/science/catalogues.py +636 -0
- hebog/science/configuration.py +74 -0
- hebog/science/continuum.py +81 -0
- hebog/science/detection_islands.py +254 -0
- hebog/science/models.py +130 -0
- hebog/science/profile.py +101 -0
- hebog/science/segment_rows.py +950 -0
- hebog/stages/__init__.py +1 -0
- hebog/stages/association.py +1487 -0
- hebog/stages/background.py +1686 -0
- hebog/stages/batching.py +208 -0
- hebog/stages/catalogue_rows.py +1189 -0
- hebog/stages/component_fits.py +1049 -0
- hebog/stages/component_topology.py +844 -0
- hebog/stages/composition.py +1344 -0
- hebog/stages/detection.py +539 -0
- hebog/stages/extended_groups.py +639 -0
- hebog/stages/fit_parents.py +812 -0
- hebog/stages/islands.py +888 -0
- hebog/stages/multiscale.py +1081 -0
- hebog/stages/object_extents.py +219 -0
- hebog/stages/publication.py +1421 -0
- hebog/stages/sources.py +1264 -0
- hebog/stages/support.py +621 -0
- hebog/stages/task_memory.py +77 -0
- hebog-0.22.0.dist-info/METADATA +149 -0
- hebog-0.22.0.dist-info/RECORD +95 -0
- hebog-0.22.0.dist-info/WHEEL +4 -0
- hebog-0.22.0.dist-info/entry_points.txt +3 -0
- 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,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)
|