diff --git a/.github/workflows/roman_ci.yml b/.github/workflows/roman_ci.yml index f01b7f25a..b3cb2486d 100644 --- a/.github/workflows/roman_ci.yml +++ b/.github/workflows/roman_ci.yml @@ -20,14 +20,21 @@ concurrency: cancel-in-progress: true jobs: - crds: - name: retrieve current CRDS context + data: + name: retrieve current CRDS context, and WebbPSF data runs-on: ubuntu-latest env: OBSERVATORY: roman CRDS_SERVER_URL: https://roman-crds-test.stsci.edu - CRDS_PATH: /tmp/crds_cache + CRDS_PATH: /tmp/data + outputs: + context: ${{ steps.context.outputs.pmap }} + path: ${{ steps.path.outputs.path }} + server: ${{ steps.server.outputs.url }} + hash: ${{ steps.data_hash.outputs.hash }} + webbpsf_path: ${{ steps.webbpsf_path.outputs.path }} steps: + # crds: - id: context run: > echo "pmap=$( @@ -40,10 +47,27 @@ jobs: run: echo "path=${{ env.CRDS_PATH }}" >> $GITHUB_OUTPUT - id: server run: echo "url=${{ env.CRDS_SERVER_URL }}" >> $GITHUB_OUTPUT - outputs: - context: ${{ steps.context.outputs.pmap }} - path: ${{ steps.path.outputs.path }} - server: ${{ steps.server.outputs.url }} + # webbpsf: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + - id: data + run: | + echo "webbpsf_url=https://stsci.box.com/shared/static/n1fealx9q0m6sdnass6wnyfikvxtc0zz.gz" >> $GITHUB_OUTPUT + echo "path=/tmp/data" >> $GITHUB_OUTPUT + - run: | + mkdir -p ${{ steps.data.outputs.path }} + wget ${{ steps.data.outputs.webbpsf_url }} -O ${{ steps.data.outputs.path }}/minimal-webbpsf-data.tar.gz + cd ${{ steps.data.outputs.path }} + tar -xzvf minimal-webbpsf-data.tar.gz + - id: data_hash + run: echo "hash=${{ steps.data.outputs.hash }}" >> $GITHUB_OUTPUT + - uses: actions/cache@v3 + with: + path: ${{ steps.data.outputs.path }} + key: data-${{ steps.data_hash.outputs.hash }} + - id: webbpsf_path + run: echo "path=${{ steps.data.outputs.path }}/webbpsf-data" >> $GITHUB_OUTPUT check: uses: OpenAstronomy/github-actions-workflows/.github/workflows/tox.yml@v1 with: @@ -53,7 +77,7 @@ jobs: - linux: build-dist test: uses: OpenAstronomy/github-actions-workflows/.github/workflows/tox.yml@main - needs: [ crds ] + needs: [ data ] with: setenv: | CRDS_PATH: ${{ needs.crds.outputs.path }} @@ -65,8 +89,9 @@ jobs: DD_GIT_REPOSITORY_URL: ${{ github.repositoryUrl }} DD_GIT_COMMIT_SHA: ${{ github.sha }} DD_GIT_BRANCH: ${{ github.ref_name }} - cache-path: /tmp/crds_cache - cache-key: crds-${{ needs.crds_context.outputs.pmap }} + WEBBPSF_PATH: ${{ needs.data.outputs.webbpsf_path }} + cache-path: ${{ needs.data.outputs.path }} + cache-key: data-${{ needs.data.outputs.hash }} envs: | - linux: py39-oldestdeps-cov pytest-results-summary: true diff --git a/.github/workflows/roman_ci_cron.yaml b/.github/workflows/roman_ci_cron.yaml index c03cc66ad..7b2b2ef7f 100644 --- a/.github/workflows/roman_ci_cron.yaml +++ b/.github/workflows/roman_ci_cron.yaml @@ -22,7 +22,7 @@ jobs: runs-on: ubuntu-latest env: OBSERVATORY: roman - CRDS_PATH: /tmp/crds_cache + CRDS_PATH: /tmp/data CRDS_SERVER_URL: https://roman-crds-test.stsci.edu steps: - id: context diff --git a/CHANGES.rst b/CHANGES.rst index e822c394f..cd7771199 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -96,6 +96,8 @@ general - Add ``dev`` install option. [#835] +- Add PSF photometry methods [#794] + 0.11.0 (2023-05-31) =================== diff --git a/pyproject.toml b/pyproject.toml index 375168866..9949ee996 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,7 @@ dependencies = [ 'gwcs >=0.18.1', 'jsonschema >=4.8', 'numpy >=1.22', - 'photutils >=1.6.0', + 'photutils @ git+https://github.com/astropy/photutils.git', 'pyparsing >=2.4.7', 'requests >=2.22', 'rad >= 0.17.1', @@ -33,6 +33,7 @@ dependencies = [ 'tweakwcs >=0.8.0', 'spherical-geometry >= 1.2.22', 'stsci.imagestats >= 1.6.3', + 'webbpsf == 1.1.1', ] dynamic = ['version'] diff --git a/requirements-dev.txt b/requirements-dev.txt index 6429d2b81..a40ea0fb0 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -23,3 +23,4 @@ git+https://github.com/spacetelescope/crds git+https://github.com/spacetelescope/stcal git+https://github.com/spacetelescope/tweakwcs git+https://github.com/spacetelescope/metrics_logger +git+https://github.com/astropy/photutils.git diff --git a/romancal/lib/psf.py b/romancal/lib/psf.py new file mode 100644 index 000000000..bd2ca74b3 --- /dev/null +++ b/romancal/lib/psf.py @@ -0,0 +1,397 @@ +""" +Utilities for fitting model PSFs to rate images. +""" + +import logging +import os + +import astropy.units as u +import numpy as np +import webbpsf +from astropy.modeling.fitting import LevMarLSQFitter +from astropy.nddata import CCDData, bitmask +from astropy.table import Table +from photutils.background import LocalBackground +from photutils.detection import DAOStarFinder +from photutils.psf import ( + GriddedPSFModel, + IterativePSFPhotometry, + PSFPhotometry, + SourceGrouper, +) +from roman_datamodels.datamodels import ImageModel +from webbpsf import conf, gridded_library, restart_logging + +from romancal.lib.dqflags import pixel as roman_dq_flag_map + +# set loggers to debug level by default: +log = logging.getLogger(__name__) +log.setLevel(logging.DEBUG) + +# Phase C central wavelengths [micron], released by Goddard (Jan 2023): +# https://roman.ipac.caltech.edu/sims/Param_db.html#wfi_filters +filter_central_wavelengths = { + "WFI_Filter_F062_Center": 0.620, + "WFI_Filter_F087_Center": 0.869, + "WFI_Filter_F106_Center": 1.060, + "WFI_Filter_F129_Center": 1.293, + "WFI_Filter_F146_Center": 1.464, + "WFI_Filter_F158_Center": 1.577, + "WFI_Filter_F184_Center": 1.842, + "WFI_Filter_F213_Center": 2.125, +} + +default_finder = DAOStarFinder( + # these defaults extracted from the + # romancal SourceDetectionStep + fwhm=2.0, + threshold=2.0, + sharplo=0.0, + sharphi=1.0, + roundlo=-1.0, + roundhi=1.0, + peakmax=1000.0, +) + + +def create_gridded_psf_model( + path_prefix, + filt, + detector, + oversample=12, + fov_pixels=12, + sqrt_n_psfs=4, + overwrite=False, + buffer_pixels=100, + instrument_options=None, + logging_level=None, +): + """ + Compute a gridded PSF model for one SCA via + `webbpsf.gridded_library.CreatePSFLibrary`. + + Parameters + ---------- + path_prefix : str or Path-like + Prefix to the output file path for the gridded PSF model + FITS file. A suffix denoting the detector name will + be appended. + filt : str + Filter name, starting with "F". For example: `"F184"`. + detector : str + Computed gridded PSF model for this SCA. + Examples include: `"SCA01"` or `"SCA18"`. + oversample : int, optional + Oversample factor, default is 12. See WebbPSF docs for details [1]_. + fov_pixels : int, optional + Field of view width [pixels]. Default is 12. + See WebbPSF docs for details [1]_. + sqrt_n_psfs : int, optional + Square root of the number of PSFs to calculate, distributed uniformly + across the detector. Default is 4. + overwrite : bool, optional + Overwrite output file if one already exists. Default is False. + buffer_pixels : int, optional + Calculate a grid of PSFs distributed uniformly across the detector + at least ``buffer_pixels`` away from the detector edges. Default is 100. + instrument_options : dict, optional + Instrument configuration options passed to WebbPSF. + For example, WebbPSF assumes Roman pointing jitter consistent with + mission specs by default, but this can be turned off with: + ``{'jitter': None, 'jitter_sigma': 0}``. + logging_level : str, optional + Set logging level by name if not `None`, otherwise inherit from + the romancal logger. + + Returns + ------- + gridmodel : `photutils.psf.GriddedPSFModel` + Gridded PSF model evaluated at several locations on one SCA. + model_psf_centroids : list of tuples + Pixel locations of the PSF models calculated for ``gridmodel``. + + References + ---------- + .. [1] `WebbPSF documentation for `webbpsf.JWInstrument.calc_psf` + `_ + + """ + if int(sqrt_n_psfs) != sqrt_n_psfs: + raise ValueError(f"`sqrt_n_psfs` must be an integer, got {sqrt_n_psfs}.") + n_psfs = int(sqrt_n_psfs) ** 2 + + # webbpsf appends "_sca??.fits" to the requested path: + expected_output_path = f"{path_prefix}_{detector.lower()}.fits" + + # Choose pixel boundaries for the grid of PSFs: + start_pix = 0 + stop_pix = 4096 + + # Choose locations on detector for each PSF: + pixel_range = np.linspace( + start_pix + buffer_pixels, stop_pix - buffer_pixels, int(sqrt_n_psfs) + ) + + # generate PSFs over a grid of detector positions [pix] + model_psf_centroids = [(int(x), int(y)) for y in pixel_range for x in pixel_range] + + if not os.path.exists(expected_output_path) or overwrite: + if logging_level is None: + # pass along logging level from __name__'s logger to WebbPSF: + logging_level = logging.getLevelName(log.level) + + # set the WebbPSF logging level (similar to webbpsf.utils.setup_logging): + conf.logging_level = logging_level + restart_logging(verbose=False) + + wfi = webbpsf.roman.WFI() + wfi.filter = filt + + if instrument_options is not None: + wfi.options.update(instrument_options) + + central_wavelength_meters = ( + filter_central_wavelengths[f"WFI_Filter_{filt}_Center"] * 1e-6 * u.m + ) + + # Initialize the PSF library + inst = gridded_library.CreatePSFLibrary( + instrument=wfi, + filter_name=filt, + detectors=detector.upper(), + num_psfs=n_psfs, + monochromatic=central_wavelength_meters, + oversample=oversample, + fov_pixels=fov_pixels, + add_distortion=False, + crop_psf=False, + save=True, + filename=path_prefix, + overwrite=overwrite, + verbose=False, + ) + + inst.location_list = model_psf_centroids + + # Create the PSF grid: + gridmodel = inst.create_grid() + + elif os.path.exists(expected_output_path): + logging.log( + logging.INFO, + f"Loading existing gridded PSF model from {expected_output_path}", + ) + psf_model = CCDData.read(expected_output_path, unit=u.electron / u.s, ext=0) + # the FITS file saved by webbpsf gets loaded with pixel + # axes flipped in both dimensions, so flip them back after loading: + psf_model.data = psf_model.data[::-1, ::-1] + psf_model.meta = dict(psf_model.meta) + psf_model.meta["oversampling"] = oversample + psf_model.meta["grid_xypos"] = np.array( + [list(tup)[::-1] for tup in model_psf_centroids] + ) + gridmodel = GriddedPSFModel(psf_model) + + return gridmodel, model_psf_centroids + + +def fit_psf_to_image_model( + image_model=None, + data=None, + error=None, + dq=None, + photometry_cls=PSFPhotometry, + psf_model=None, + grouper=None, + fitter=None, + localbkg_estimator=None, + finder=None, + x_init=None, + y_init=None, + progress_bar=False, + error_lower_limit=None, + fit_shape=(15, 15), + exclude_out_of_bounds=True, +): + """ + Fit PSF models to an ImageModel. + + Parameters + ---------- + image_model : `roman_datamodels.datamodels.ImageModel` + Image datamodel. If ``image_model`` is supplied, + ``data,error,dq`` should be `None`. + data : `astropy.units.Quantity` + Fit a PSF model to the rate image ``data``. + If ``data,error,dq`` are supplied, ``image_model`` should be `None`. + error : `astropy.units.Quantity` + Uncertainties on fluxes in ``data``. Should be `None` if + ``image_model`` is supplied. + dq : `numpy.ndarray` + Data quality bitmask for ``data``. Should be `None` if + ``image_model`` is supplied. + photometry_cls : {`photutils.psf.PSFPhotometry`, + `photutils.psf.IterativePSFPhotometry`} + Choose a photutils PSF photometry technique (default or iterative). + psf_model : `astropy.modeling.Fittable2DModel` + The 2D PSF model to fit to the rate image. Usually this model is an instance + of `photutils.psf.GriddedPSFModel`. + grouper : `photutils.psf.SourceGrouper` + Specifies rules for attempting joint fits of multiple PSFs when + there are nearby sources at small separations. + fitter : `astropy.modeling.fitting.Fitter`, optional + Modeling class which optimizes the PSF fit. + Default is `astropy.modeling.fitting.LevMarLSQFitter(calc_uncertainties=True)`. + localbkg_estimator : `photutils.background.LocalBackground`, optional + Specifies inner and outer radii for computing flux background near + a source. Default has ``inner_radius=10, outer_radius=30``. + finder : subclass of `photutils.detection.StarFinderBase`, optional + When ``photutils_cls`` is `photutils.psf.IterativePSFPhotometry`, the + ``finder`` is called to determine if sources remain in the rate image + after one PSF model is fit to the observations and removed. + Default was extracted from the `DAOStarFinder` call in the + Source Detection step. + x_init : `numpy.ndarray`, optional + Initial guesses for the ``x`` pixel coordinates of each source to fit. + y_init : `numpy.ndarray`, optional + Initial guesses for the ``y`` pixel coordinates of each source to fit. + progress_bar : bool, optional + Render a progress bar via photutils. Default is False. + error_lower_limit : `astropy.units.Quantity`, optional + Since some synthetic images may have bright sources with very + small statistical uncertainties, the ``error`` can be clipped at + ``error_lower_limit`` to prevent over-confident fits. + fit_shape : int, or tuple of length 2, optional + Rectangular shape around the center of a star that will + be used to define the PSF-fitting data. See docs for + `photutils.psf.PSFPhotometry` for details. Default is ``(16, 16)``. + exclude_out_of_bounds : bool, optional + If `True`, do not attempt to fit stars which have initial centroids + that fall outside the pixel limits of the SCA. Default is False. + + Returns + ------- + results_table : `astropy.table.QTable` + PSF photometry results. + photometry : instance of class ``photutils_cls`` + PSF photometry instance with configuration settings and results. + + """ + if grouper is None: + # minimum separation before sources are fit simultaneously: + grouper = SourceGrouper(min_separation=20) # [pix] + + if fitter is None: + fitter = LevMarLSQFitter(calc_uncertainties=True) + + # the iterative PSF method requires a finder: + psf_photometry_kwargs = {} + if photometry_cls is IterativePSFPhotometry or (x_init is None and y_init is None): + if finder is None: + finder = default_finder + psf_photometry_kwargs["finder"] = finder + + if localbkg_estimator is not None: + localbkg_estimator = LocalBackground( + inner_radius=10, # [pix] + outer_radius=30, # [pix] + ) + + photometry = photometry_cls( + grouper=grouper, + localbkg_estimator=localbkg_estimator, + psf_model=psf_model, + fitter=fitter, + fit_shape=fit_shape, + aperture_radius=fit_shape[0], + progress_bar=progress_bar, + **psf_photometry_kwargs, + ) + + if x_init is not None and y_init is not None: + guesses = Table(np.column_stack([x_init, y_init]), names=["x_init", "y_init"]) + else: + guesses = None + + if image_model is None: + if data is None and error is None: + raise ValueError( + "PSF fitting requires either an ImageModel, " + "or arrays for the data and error." + ) + + if dq is None: + if image_model is not None: + mask = dq_to_boolean_mask(image_model) + else: + mask = None + else: + mask = dq_to_boolean_mask(dq) + + if data is None and image_model is not None: + data = image_model.data + + if error is None and image_model is not None: + error = image_model.err + + if error_lower_limit is not None: + # option to enforce a lower limit on the flux uncertainties + error = np.clip(error, error_lower_limit, None) + + # we also mask non-finite values in the data and error arrays: + non_finite = ~np.isfinite(data) | ~np.isfinite(error) + + if exclude_out_of_bounds and guesses is not None: + # don't attempt to fit PSFs for objects with initial centroids + # outside the detector boundaries: + init_centroid_in_range = ( + (guesses["x_init"] > 0) + & (guesses["x_init"] < data.shape[0]) + & (guesses["y_init"] > 0) + & (guesses["y_init"] < data.shape[1]) + ) + guesses = guesses[init_centroid_in_range] + + # fit the model PSF to the data: + results_table = photometry( + data=data, error=error, init_params=guesses, mask=mask | non_finite + ) + + # results are stored on the PSFPhotometry instance: + return results_table, photometry + + +def dq_to_boolean_mask(image_model_or_dq, ignore_flags=0, flag_map_name="ROMAN_DQ"): + """ + Convert a DQ bitmask to a boolean mask. Useful for photutils methods. + + Parameters + ---------- + image_model_or_dq : `roman_datamodels.datamodels.ImageModel` or `numpy.ndarray` + ImageModel containing the DQ bitmask to convert to a boolean mask, + or the DQ bitmask itself. + ignore_flags : int, str, list, None (default = 0) + See docs for `astropy.nddata.bitmask.extend_bit_flag_map` + flag_map_name : str + Name for the bitmask flag map in the astropy bitmask registry + + Returns + ------- + mask : `numpy.ndarray` + Boolean mask + """ + + if isinstance(image_model_or_dq, ImageModel): + dq = image_model_or_dq.dq + else: + dq = image_model_or_dq + + # add the Roman DQ flags to the astropy bitmask registry: + dq_flag_map = dict(roman_dq_flag_map) + dq_flag_map.pop("GOOD") + + bitmask.extend_bit_flag_map(flag_map_name, **dq_flag_map) + + # convert the bitmask to a boolean mask: + mask = bitmask.bitfield_to_boolean_mask(dq, ignore_flags=ignore_flags) + return mask.astype(bool) diff --git a/romancal/lib/tests/test_psf.py b/romancal/lib/tests/test_psf.py new file mode 100644 index 000000000..23f18012a --- /dev/null +++ b/romancal/lib/tests/test_psf.py @@ -0,0 +1,179 @@ +""" + Unit tests for the Roman source detection step code +""" + +import os +import tempfile + +import numpy as np +import pytest +from astropy import units as u +from astropy.nddata import overlap_slices +from photutils.psf import PSFPhotometry +from roman_datamodels import maker_utils as testutil +from roman_datamodels.datamodels import ImageModel + +from romancal.lib.psf import create_gridded_psf_model, fit_psf_to_image_model + +n_sources = 10 +image_model_shape = (100, 100) +rng = np.random.default_rng(0) + + +@pytest.fixture +def setup_inputs(): + def _setup( + nrows=image_model_shape[0], ncols=image_model_shape[1], noise=1.0, seed=None + ): + """ + Return ImageModel of level 2 image. + """ + shape = (nrows, ncols) + wfi_image = testutil.mk_level2_image(shape=shape) + wfi_image.data = u.Quantity( + np.ones(shape, dtype=np.float32), u.electron / u.s, dtype=np.float32 + ) + wfi_image.meta.filename = "filename" + + # add noise to data + if noise is not None: + setup_rng = np.random.default_rng(seed or 19) + wfi_image.data = u.Quantity( + setup_rng.normal(scale=noise, size=shape), + u.electron / u.s, + dtype=np.float32, + ) + wfi_image.err = noise * np.ones(shape, dtype=np.float32) * u.electron / u.s + + # add dq array + wfi_image.dq = np.zeros(shape, dtype=np.uint32) + + # construct ImageModel + mod = ImageModel(wfi_image) + + return mod + + return _setup + + +def add_synthetic_sources( + image_model, + psf_model, + true_x, + true_y, + true_amp, + oversample, + xname="x_0", + yname="y_0", +): + fit_models = [] + + # ensure truths are arrays: + true_x, true_y, true_amp = ( + np.atleast_1d(truth) for truth in [true_x, true_y, true_amp] + ) + + for x, y, amp in zip(true_x, true_y, true_amp): + psf = psf_model.copy() + psf.parameters = [amp, x, y] + fit_models.append(psf) + + synth_image = image_model.data + synth_err = image_model.err + psf_shape = np.array(psf_model.data.shape[1:]) // oversample + + for fit_model in fit_models: + x0 = getattr(fit_model, xname).value + y0 = getattr(fit_model, yname).value + slc_lg, _ = overlap_slices(synth_image.shape, psf_shape, (y0, x0), mode="trim") + yy, xx = np.mgrid[slc_lg] + model_data = fit_model(xx, yy) * image_model.data.unit + model_err = np.sqrt(model_data.value) * model_data.unit + synth_image[slc_lg] += ( + np.random.normal( + model_data.to_value(image_model.data.unit), + model_err.to_value(image_model.data.unit), + size=model_data.shape, + ) + * image_model.data.unit + ) + synth_err[slc_lg] = np.sqrt(synth_err[slc_lg] ** 2 + model_err**2) + + +@pytest.mark.parametrize( + "dx, dy, true_amp", + zip( + rng.uniform(-1, 1, n_sources), + rng.uniform(-1, 1, n_sources), + np.geomspace(10, 10_000, n_sources), + ), +) +def test_psf_fit(setup_inputs, dx, dy, true_amp, seed=42): + # input parameters for PSF model: + filt = "F087" + detector = "SCA01" + oversample = 12 + fov_pixels = 15 + + dir_path = tempfile.gettempdir() + filename_prefix = f"psf_model_{filt}" + file_path = os.path.join(dir_path, filename_prefix) + + # compute gridded PSF model: + psf_model, centroids = create_gridded_psf_model( + file_path, + filt, + detector, + oversample=oversample, + fov_pixels=fov_pixels, + overwrite=False, + logging_level="ERROR", + ) + + # generate an ImageModel + image_model = setup_inputs(seed=seed) + init_data_stddev = np.std(image_model.data.value) + + # add synthetic sources to the ImageModel: + true_x = image_model_shape[0] / 2 + dx + true_y = image_model_shape[1] / 2 + dy + add_synthetic_sources( + image_model, psf_model, true_x, true_y, true_amp, oversample=oversample + ) + + if fov_pixels % 2 == 0: + fit_shape = (fov_pixels + 1, fov_pixels + 1) + else: + fit_shape = (fov_pixels, fov_pixels) + + # fit the PSF to the ImageModel: + results_table, photometry = fit_psf_to_image_model( + image_model=image_model, + photometry_cls=PSFPhotometry, + psf_model=psf_model, + x_init=true_x, + y_init=true_y, + fit_shape=fit_shape, + ) + + # difference between input and output, normalized by the + # uncertainty. Has units of sigma: + delta_x = np.abs(true_x - results_table["x_fit"]) / results_table["x_err"] + delta_y = np.abs(true_y - results_table["y_fit"]) / results_table["y_err"] + + sigma_threshold = 3.5 + assert np.all(delta_x < sigma_threshold) + assert np.all(delta_y < sigma_threshold) + + # now check that the uncertainties aren't way too large, which could cause + # the above test to pass even when the fits are bad. Use overly-simple approximation + # that astrometric uncertainty be proportional to the PSF's FWHM / SNR: + approx_snr = true_amp / init_data_stddev + approx_fwhm = 1 + approx_centroid_err = approx_fwhm / approx_snr + + # centroid err heuristic above is an underestimate, so we scale it up: + scale_factor_approx = 100 + + assert np.all(results_table["x_err"] < scale_factor_approx * approx_centroid_err) + assert np.all(results_table["y_err"] < scale_factor_approx * approx_centroid_err) diff --git a/tox.ini b/tox.ini index 37766beb7..935da7520 100644 --- a/tox.ini +++ b/tox.ini @@ -53,6 +53,9 @@ pass_env = TEST_BIGDATA CODECOV_* DD_* + WEBBPSF_PATH +set_env = + devdeps: PIP_EXTRA_INDEX_URL = https://pypi.anaconda.org/scientific-python-nightly-wheels/simpl extras = test