diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 8c7f2bf..4e9c19e 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -29,7 +29,7 @@ jobs: with: fetch-depth: 0 - - uses: hynek/build-and-inspect-python-package@v2 + - uses: hynek/build-and-inspect-python-package@v3.0.1 publish: needs: [dist] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eca34c1..855c910 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,7 +27,9 @@ jobs: fetch-depth: 0 - uses: actions/setup-python@v6 with: - python-version: "3.x" + python-version: | + 3.11 + 3.x - uses: astral-sh/setup-uv@v7 @@ -42,7 +44,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.10", "3.12", "3.13", "3.14"] + python-version: ["3.11", "3.12", "3.13", "3.14"] runs-on: [ubuntu-latest] steps: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1871e17..06a9e5d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -55,6 +55,7 @@ repos: - id: mypy files: eye_patch|tests args: ["--ignore-missing-imports"] + language_version: python3.11 additional_dependencies: - numpy - capn-crunch diff --git a/eye_patch/masking.py b/eye_patch/masking.py index 536246b..7da19f8 100644 --- a/eye_patch/masking.py +++ b/eye_patch/masking.py @@ -301,7 +301,9 @@ def beam_shape_erode( scales (list[int] | tuple[int, ...] | None, optional): Defines the scales that are being used during multi-scale clean. Perform the beam erosion at each of these scales. Defaults to None. Returns: - MaskLike: The eroded beam shape. If a no/single scale provide it is a bool return, otherwise int32. + MaskLike: The eroded beam shape. For no/single scale the dtype of ``mask`` is + preserved; for multiple scales a float64 bitmask is returned, where the + n'th scale is stored as the n'th bit. """ if not all(key in fits_header for key in ["BMAJ", "BMIN", "BPA"]): @@ -899,14 +901,14 @@ def create_snr_mask_from_fits( mask_data = reverse_negative_flood_fill( base_image=np.squeeze(signal_data), masking_options=masking_options, - ) + ).astype(np.float32) mask_data = mask_data.reshape(signal_data.shape) else: logger.info(f"Clipping using a {masking_options.base_snr_clip=}") mask_data = (signal_data > masking_options.base_snr_clip).astype(float) if masking_options.beam_shape_erode: - mask_data = beam_shape_erode( # type: ignore[assignment] + mask_data = beam_shape_erode( mask=mask_data, fits_header=fits_header, minimum_response=masking_options.beam_shape_erode_minimum_response, diff --git a/pyproject.toml b/pyproject.toml index 1131f81..483ef05 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,6 @@ classifiers = [ "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", @@ -98,7 +97,7 @@ report.exclude_also = ['\.\.\.', 'if typing.TYPE_CHECKING:'] [tool.mypy] files = ["eye_patch", "tests"] -python_version = "3.10" +python_version = "3.11" warn_unused_configs = true strict = true enable_error_code = ["ignore-without-code", "redundant-expr", "truthy-bool"] diff --git a/tests/test_masking.py b/tests/test_masking.py index e74bf99..ca24903 100644 --- a/tests/test_masking.py +++ b/tests/test_masking.py @@ -491,6 +491,58 @@ def test_fits_masking(fits_dir): assert valid == np.prod(SHAPE) +@pytest.fixture +def beam_fits_dir(tmp_path: Path, beam_fits_header: fits.Header) -> Path: + """As ``fits_dir``, but the image carries the beam keywords that + ``beam_shape_erode`` needs to build its kernel.""" + beam_fits_dir = tmp_path / "beam_fits" + beam_fits_dir.mkdir() + + fits.writeto(beam_fits_dir / "image.fits", np.ones(SHAPE), header=beam_fits_header) + fits.writeto(beam_fits_dir / "rms.fits", np.ones(SHAPE) * 0.1) + fits.writeto(beam_fits_dir / "bkg.fits", np.ones(SHAPE) * 0.5) + + return beam_fits_dir + + +def test_fits_masking_flood_fill_with_beam_erode(beam_fits_dir: Path) -> None: + """The flood filled mask reaches ``beam_shape_erode``, which types its input + as floats. This is the path the bool mask was travelling down.""" + masking_options = MaskingOptions(flood_fill=True, beam_shape_erode=True) + names = create_snr_mask_from_fits( + fits_image_path=beam_fits_dir / "image.fits", + fits_rms_path=beam_fits_dir / "rms.fits", + fits_bkg_path=beam_fits_dir / "bkg.fits", + masking_options=masking_options, + ) + + assert isinstance(names, FITSMaskNames) + assert names.mask_fits.exists() + + mask_data = fits.getdata(names.mask_fits) + assert mask_data.dtype.kind == "f", "A bool mask cannot be written to FITS" + + +def test_fits_masking_flood_fill_writes_float_mask(fits_dir: Path) -> None: + """The flood fill path builds its mask as a bool array, which ``fits.writeto`` + rejects. Reported by Beth via #14, and not caught earlier because every other + mask test runs with ``flood_fill=False``.""" + masking_options = MaskingOptions(flood_fill=True) + names = create_snr_mask_from_fits( + fits_image_path=fits_dir / "image.fits", + fits_rms_path=fits_dir / "rms.fits", + fits_bkg_path=fits_dir / "bkg.fits", + masking_options=masking_options, + ) + + assert isinstance(names, FITSMaskNames) + assert names.mask_fits.exists() + + mask_data = fits.getdata(names.mask_fits) + assert mask_data.dtype.kind == "f", "A bool mask cannot be written to FITS" + assert np.sum(mask_data) == np.prod(SHAPE) + + def test_fits_masking_with_signal(fits_dir): masking_options = MaskingOptions(flood_fill=False) names = create_snr_mask_from_fits(