diff --git a/CHANGES.rst b/CHANGES.rst index 16e0c470cb..1ade3e9c42 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -25,6 +25,23 @@ outlier_detection - Fixed failures due to a missing ``wcs.array_shape`` attribute when the ``outlier_detection`` step was run standalone using e.g. ``strun`` [#8645] +resample_spec +------------- + +- Modified the output NIRSpec spectral WCS to sample the input data linearly in sky + coordinates, rather than slit coordinates, in order to conserve spectral + flux in default reductions. [#8596] + +- Updated handling for the ``pixel_scale_ratio`` parameter to apply only to the + spatial dimension, to match the sense of the parameter application to the + documented intent, and to conserve spectral flux when applied. [#8596] + +- Implemented handling for the ``pixel_scale`` parameter, which was previously + ignored for spectral resampling. [#8596] + +- Fixed a bug resulting in incorrect output slit coordinates for NIRSpec moving + targets in the ``calwebb_spec3`` pipeline. [#8596] + scripts ------- diff --git a/docs/jwst/resample/arguments.rst b/docs/jwst/resample/arguments.rst index c21f711b48..123e5d4ac4 100644 --- a/docs/jwst/resample/arguments.rst +++ b/docs/jwst/resample/arguments.rst @@ -15,15 +15,52 @@ image. image. Available kernels are `square`, `gaussian`, `point`, `turbo`, `lanczos2`, and `lanczos3`. + For spectral data, only the `square` and `point` kernels should be used. + The other kernels do not conserve spectral flux. + ``--pixel_scale_ratio`` (float, default=1.0) - Ratio of input to output pixel scale. A value of 0.5 means the output + Ratio of input to output pixel scale. + + For imaging data, a value of 0.5 means the output image would have 4 pixels sampling each input pixel. + + For spectral data, values greater than 1 indicate that the input + pixels have a larger spatial scale, so more output pixels will + sample the same input pixel. For example, a value of 2.0 + means the output image would have 2 pixels sampling each input + spatial pixel. If the input data has units of flux density (MJy/pixel), + the output flux per pixel will be half the input flux per pixel. + If the input data has units of surface brightness (MJy/sr), the output + flux per pixel is not scaled. + + Note that this parameter is only applied in the cross-dispersion + direction for spectral data: sampling wavelengths are not affected. + Ignored when ``pixel_scale`` or ``output_wcs`` are provided. + .. note:: + If this parameter is modified for spectral data, the extraction + aperture for the :ref:`extract_1d ` step must + also be modified, since it is specified in pixels. + ``--pixel_scale`` (float, default=None) Absolute pixel scale in ``arcsec``. When provided, overrides ``pixel_scale_ratio``. Ignored when ``output_wcs`` is provided. + For spectral data, if the input data has units of flux density + (MJy/pixel), the output flux per pixel will be scaled by the ratio + of the selected output pixel scale to an average input pixel scale. + If the input data has units of surface brightness (MJy/sr), + the output flux per pixel is not scaled. + + Note that this parameter is only applied in the cross-dispersion + direction for spectral data: sampling wavelengths are not affected. + + .. note:: + If this parameter is modified for spectral data, the extraction + aperture for the :ref:`extract_1d ` step must + also be modified, since it is specified in pixels. + ``--rotation`` (float, default=None) Position angle of output image’s Y-axis relative to North. A value of 0.0 would orient the final output image to be North up. @@ -31,19 +68,20 @@ image. but will instead be resampled in the default orientation for the camera with the x and y axes of the resampled image corresponding approximately to the detector axes. Ignored when ``pixel_scale`` - or ``output_wcs`` are provided. + or ``output_wcs`` are provided. Also ignored for all spectral data. ``--crpix`` (tuple of float, default=None) Position of the reference pixel in the image array in the ``x, y`` order. If ``crpix`` is not specified, it will be set to the center of the bounding box of the returned WCS object. When supplied from command line, it should be a comma-separated list of floats. Ignored when ``output_wcs`` - is provided. + is provided. Also ignored for all spectral data. ``--crval`` (tuple of float, default=None) Right ascension and declination of the reference pixel. Automatically computed if not provided. When supplied from command line, it should be a comma-separated list of floats. Ignored when ``output_wcs`` is provided. + Also ignored for all spectral data. ``--output_shape`` (tuple of int, default=None) Shape of the image (data array) using "standard" ``nx`` first and ``ny`` @@ -117,6 +155,8 @@ image. For example, if set to ``0.5``, only resampled images that use less than half the available memory can be created. + This parameter is ignored for spectral data. + ``--in_memory`` (boolean, default=True) Specifies whether or not to load and create all images that are used during processing into memory. If ``False``, input files are loaded from disk when diff --git a/docs/jwst/resample/resample.rst b/docs/jwst/resample/resample.rst index 3e264d726e..0a2bba77e2 100644 --- a/docs/jwst/resample/resample.rst +++ b/docs/jwst/resample/resample.rst @@ -1,6 +1,7 @@ .. resample_: -Python Interface to Drizzle: ResampleData() -=========================================== +Python Interface to Drizzle: ResampleData() and ResampleSpecData() +================================================================== .. automodapi:: jwst.resample.resample +.. automodapi:: jwst.resample.resample_spec diff --git a/docs/jwst/resample/resample_step.rst b/docs/jwst/resample/resample_step.rst index d0a87a5b75..afa5d95df4 100644 --- a/docs/jwst/resample/resample_step.rst +++ b/docs/jwst/resample/resample_step.rst @@ -1,6 +1,7 @@ .. resample_step_: -Python Step Interface: ResampleStep() -===================================== +Python Step Interface: ResampleStep() and ResampleSpecStep() +============================================================ .. automodapi:: jwst.resample.resample_step +.. automodapi:: jwst.resample.resample_spec_step diff --git a/jwst/outlier_detection/outlier_detection.py b/jwst/outlier_detection/outlier_detection.py index 92cbdc6db4..829a5ae622 100644 --- a/jwst/outlier_detection/outlier_detection.py +++ b/jwst/outlier_detection/outlier_detection.py @@ -572,10 +572,16 @@ def gwcs_blot(median_model, blot_img, interp='poly5', sinscl=1.0): # Set array shape, needed to compute image pixel area blot_img.meta.wcs.array_shape = blot_img.shape if 'SPECTRAL' not in blot_img.meta.wcs.output_frame.axes_type: + # Account for intensity scaling, needed if there is a difference + # between nominal pixel area and average pixel area, + # computed from the WCS input_pixflux_area = blot_img.meta.photometry.pixelarea_steradians input_pixel_area = compute_image_pixel_area(blot_img.meta.wcs) pix_ratio = np.sqrt(input_pixflux_area / input_pixel_area) else: + # Note: spectral scaling is only needed if the pixel ratio + # is not set to 1.0, which is not supported for + # outlier detection. pix_ratio = 1.0 log.info('Blotting {} <-- {}'.format(blot_img.data.shape, median_model.data.shape)) diff --git a/jwst/regtest/test_nirspec_fs_spec3.py b/jwst/regtest/test_nirspec_fs_spec3.py index fd6bb4e783..4f3bca7edd 100644 --- a/jwst/regtest/test_nirspec_fs_spec3.py +++ b/jwst/regtest/test_nirspec_fs_spec3.py @@ -46,24 +46,13 @@ def test_nirspec_fs_spec3(run_pipeline, rtdata_module, fitsdiff_default_kwargs, diff = FITSDiff(rtdata.output, rtdata.truth, **fitsdiff_default_kwargs) assert diff.identical, diff.report() - if output == "s2d": - # Compare the calculated wavelengths + # Check output wavelength array against its own wcs + if suffix == "s2d": tolerance = 1e-03 - dmt = datamodels.open(rtdata.truth) dmr = datamodels.open(rtdata.output) - if isinstance(dmt, datamodels.MultiSlitModel): - names = [s.name for s in dmt.slits] - for name in names: - st_idx = [(s.wcs, s.wavelength) for s in dmt.slits if s.name==name] - w = dmt.slits[st_idx].meta.wcs - x, y = wcstools.grid_from_bounding_box(w.bounding_box, step=(1, 1), center=True) - _, _, wave = w(x, y) - sr_idx = [(s.wcs, s.wavelength) for s in dmr.slits if s.name==name] - wlr = dmr.slits[sr_idx].wavelength - assert np.all(np.isclose(wave, wlr, atol=tolerance)) - else: - w = dmt.meta.wcs - x, y = wcstools.grid_from_bounding_box(w.bounding_box, step=(1, 1), center=True) - _, _, wave = w(x, y) - wlr = dmr.wavelength - assert np.all(np.isclose(wave, wlr, atol=tolerance)) + + w = dmr.meta.wcs + x, y = wcstools.grid_from_bounding_box(w.bounding_box, step=(1, 1), center=True) + _, _, wave = w(x, y) + wlr = dmr.wavelength + assert np.all(np.isclose(wave, wlr, atol=tolerance)) diff --git a/jwst/regtest/test_nirspec_fs_spec3_moving_target.py b/jwst/regtest/test_nirspec_fs_spec3_moving_target.py new file mode 100644 index 0000000000..8845cd4201 --- /dev/null +++ b/jwst/regtest/test_nirspec_fs_spec3_moving_target.py @@ -0,0 +1,57 @@ +from astropy.io.fits.diff import FITSDiff +import pytest +import numpy as np +from gwcs import wcstools + +from jwst.stpipe import Step +from stdatamodels.jwst import datamodels + + +@pytest.fixture(scope="module") +def run_pipeline(rtdata_module): + """ + Run the calwebb_spec3 pipeline on a NIRSpec FS moving target. + """ + rtdata = rtdata_module + + # Get the ASN file and input exposures + rtdata.get_asn('nirspec/fs/jw01245-o002_20240701t053319_spec3_00001_asn.json') + + # Run the calwebb_spec3 pipeline; save results from intermediate steps + args = ["calwebb_spec3", rtdata.input, + "--steps.outlier_detection.save_results=true", + "--steps.resample_spec.save_results=true", + "--steps.extract_1d.save_results=true"] + Step.from_cmdline(args) + + +@pytest.mark.bigdata +@pytest.mark.parametrize("suffix", ["cal", "crf", "s2d", "x1d"]) +def test_nirspec_fs_spec3_moving_target( + run_pipeline, rtdata_module, fitsdiff_default_kwargs, suffix): + """Test spec3 pipeline on a NIRSpec FS moving target.""" + rtdata = rtdata_module + + output = f"jw01245-o002_s000000001_nirspec_clear-prism-s200a1-subs200a1_{suffix}.fits" + rtdata.output = output + rtdata.get_truth(f"truth/test_nirspec_fs_spec3_moving_target/{output}") + + # Adjust tolerance for machine precision with float32 drizzle code + if suffix == "s2d": + fitsdiff_default_kwargs["rtol"] = 1e-2 + fitsdiff_default_kwargs["atol"] = 2e-4 + + # Compare the results + diff = FITSDiff(rtdata.output, rtdata.truth, **fitsdiff_default_kwargs) + assert diff.identical, diff.report() + + # Check output wavelength array against its own wcs + if suffix == "s2d": + tolerance = 1e-03 + dmr = datamodels.open(rtdata.output) + + w = dmr.meta.wcs + x, y = wcstools.grid_from_bounding_box(w.bounding_box, step=(1, 1), center=True) + _, _, wave = w(x, y) + wlr = dmr.wavelength + assert np.all(np.isclose(wave, wlr, atol=tolerance)) diff --git a/jwst/regtest/test_nirspec_mos_spec3.py b/jwst/regtest/test_nirspec_mos_spec3.py index 4e54e30a11..c7f33128a7 100644 --- a/jwst/regtest/test_nirspec_mos_spec3.py +++ b/jwst/regtest/test_nirspec_mos_spec3.py @@ -42,24 +42,13 @@ def test_nirspec_mos_spec3(run_pipeline, suffix, source_id, fitsdiff_default_kwa diff = FITSDiff(rtdata.output, rtdata.truth, **fitsdiff_default_kwargs) assert diff.identical, diff.report() + # Check output wavelength array against its own wcs if suffix == "s2d": - # Compare the calculated wavelengths tolerance = 1e-03 - dmt = datamodels.open(rtdata.truth) dmr = datamodels.open(rtdata.output) - if isinstance(dmt, datamodels.MultiSlitModel): - names = [s.name for s in dmt.slits] - for name in names: - st_idx = [(s.wcs, s.wavelength) for s in dmt.slits if s.name==name] - w = dmt.slits[st_idx].meta.wcs - x, y = wcstools.grid_from_bounding_box(w.bounding_box, step=(1, 1), center=True) - _, _, wave = w(x, y) - sr_idx = [(s.wcs, s.wavelength) for s in dmr.slits if s.name==name] - wlr = dmr.slits[sr_idx].wavelength - assert np.all(np.isclose(wave, wlr, atol=tolerance)) - else: - w = dmt.meta.wcs - x, y = wcstools.grid_from_bounding_box(w.bounding_box, step=(1, 1), center=True) - _, _, wave = w(x, y) - wlr = dmr.wavelength - assert np.all(np.isclose(wave, wlr, atol=tolerance)) + + w = dmr.meta.wcs + x, y = wcstools.grid_from_bounding_box(w.bounding_box, step=(1, 1), center=True) + _, _, wave = w(x, y) + wlr = dmr.wavelength + assert np.all(np.isclose(wave, wlr, atol=tolerance)) diff --git a/jwst/resample/resample.py b/jwst/resample/resample.py index 641e6dc88f..95eaaef3e9 100644 --- a/jwst/resample/resample.py +++ b/jwst/resample/resample.py @@ -3,9 +3,8 @@ import warnings import numpy as np -from drizzle import util -from drizzle import cdrizzle import psutil +from drizzle import cdrizzle, util from spherical_geometry.polygon import SphericalPolygon from stdatamodels.jwst import datamodels @@ -216,6 +215,57 @@ def blend_output_metadata(self, output_model): ignore=ignore_list ) + def _get_intensity_scale(self, img): + """ + Compute an intensity scale from the input and output pixel area. + + For imaging data, the scaling is used to account for differences + between the nominal pixel area and the average pixel area for + the input data. + + For spectral data, the scaling is used to account for flux + conservation with non-unity pixel scale ratios, when the + data units are flux density. + + Parameters + ---------- + img : DataModel + The input data model. + + Returns + ------- + iscale : float + The scale to apply to the input data before drizzling. + """ + input_pixflux_area = img.meta.photometry.pixelarea_steradians + if input_pixflux_area: + if 'SPECTRAL' in img.meta.wcs.output_frame.axes_type: + # Use the nominal area as is + input_pixel_area = input_pixflux_area + + # If input image is in flux density units, correct the + # flux for the user-specified change to the spatial dimension + if resample_utils.is_flux_density(img.meta.bunit_data): + input_pixel_area *= self.pscale_ratio + else: + img.meta.wcs.array_shape = img.data.shape + input_pixel_area = compute_image_pixel_area(img.meta.wcs) + if input_pixel_area is None: + raise ValueError( + "Unable to compute input pixel area from WCS of input " + f"image {repr(img.meta.filename)}." + ) + if self.input_pixscale0 is None: + self.input_pixscale0 = np.rad2deg( + np.sqrt(input_pixel_area) + ) + if self._recalc_pscale_ratio: + self.pscale_ratio = self.pscale / self.input_pixscale0 + iscale = np.sqrt(input_pixflux_area / input_pixel_area) + else: + iscale = 1.0 + return iscale + def resample_many_to_many(self): """Resample many inputs to many outputs where outputs have a common frame. @@ -245,28 +295,9 @@ def resample_many_to_many(self): log.info(f"{len(exposure)} exposures to drizzle together") for img in exposure: img = datamodels.open(img) + iscale = self._get_intensity_scale(img) + log.debug(f'Using intensity scale iscale={iscale}') - input_pixflux_area = img.meta.photometry.pixelarea_steradians - if (input_pixflux_area and - 'SPECTRAL' not in img.meta.wcs.output_frame.axes_type): - img.meta.wcs.array_shape = img.data.shape - input_pixel_area = compute_image_pixel_area(img.meta.wcs) - if input_pixel_area is None: - raise ValueError( - "Unable to compute input pixel area from WCS of input " - f"image {repr(img.meta.filename)}." - ) - if self.input_pixscale0 is None: - self.input_pixscale0 = np.rad2deg( - np.sqrt(input_pixel_area) - ) - if self._recalc_pscale_ratio: - self.pscale_ratio = self.pscale / self.input_pixscale0 - iscale = np.sqrt(input_pixflux_area / input_pixel_area) - else: - iscale = 1.0 - - # TODO: should weight_type=None here? inwht = resample_utils.build_driz_weight( img, weight_type=self.weight_type, @@ -332,26 +363,8 @@ def resample_many_to_one(self): log.info("Resampling science data") for img in self.input_models: - input_pixflux_area = img.meta.photometry.pixelarea_steradians - if (input_pixflux_area and - 'SPECTRAL' not in img.meta.wcs.output_frame.axes_type): - img.meta.wcs.array_shape = img.data.shape - input_pixel_area = compute_image_pixel_area(img.meta.wcs) - if input_pixel_area is None: - raise ValueError( - "Unable to compute input pixel area from WCS of input " - f"image {repr(img.meta.filename)}." - ) - if self.input_pixscale0 is None: - self.input_pixscale0 = np.rad2deg( - np.sqrt(input_pixel_area) - ) - if self._recalc_pscale_ratio: - self.pscale_ratio = self.pscale / self.input_pixscale0 - iscale = np.sqrt(input_pixflux_area / input_pixel_area) - else: - iscale = 1.0 - + iscale = self._get_intensity_scale(img) + log.debug(f'Using intensity scale iscale={iscale}') img.meta.iscale = iscale inwht = resample_utils.build_driz_weight(img, @@ -829,7 +842,7 @@ def compute_image_pixel_area(wcs): k = 0 dxy = [1, -1, -1, 1] - + ra, dec, center = np.nan, np.nan, (np.nan, np.nan) while xmin < xmax and ymin < ymax: try: x, y, image_area, center, b, r, t, l = _get_boundary_points( @@ -854,7 +867,6 @@ def compute_image_pixel_area(wcs): if not (np.all(np.isfinite(ra[sl])) and np.all(np.isfinite(dec[sl]))): limits[k] += dxy[k] - ymin, xmax, ymax, xmin = limits k = (k + 1) % 4 break k = (k + 1) % 4 diff --git a/jwst/resample/resample_spec.py b/jwst/resample/resample_spec.py index e462fb6f2e..43333df674 100644 --- a/jwst/resample/resample_spec.py +++ b/jwst/resample/resample_spec.py @@ -2,24 +2,23 @@ import warnings import numpy as np -from scipy.optimize import minimize_scalar from astropy import coordinates as coord from astropy import units as u from astropy.modeling.models import ( - Mapping, Tabular1D, Linear1D, Pix2Sky_TAN, RotateNative2Celestial, Const1D + Const1D, Linear1D, Mapping, Pix2Sky_TAN, RotateNative2Celestial, Tabular1D ) from astropy.modeling.fitting import LinearLSQFitter +from astropy.stats import sigma_clip +from astropy.utils.exceptions import AstropyUserWarning from gwcs import wcstools, WCS from gwcs import coordinate_frames as cf from gwcs.geometry import SphericalToCartesian - from stdatamodels.jwst import datamodels +from jwst.assign_wcs.util import compute_scale, wrap_ra from jwst.datamodels import ModelContainer - -from ..assign_wcs.util import wrap_ra -from . import resample_utils -from .resample import ResampleData +from jwst.resample import resample_utils +from jwst.resample.resample import ResampleData log = logging.getLogger(__name__) @@ -27,10 +26,12 @@ _S2C = SphericalToCartesian() +__all__ = ["ResampleSpecData"] + class ResampleSpecData(ResampleData): """ - This is the controlling routine for the resampling process. + This is the controlling routine for the resampling process for spectral data. Notes ----- @@ -61,14 +62,13 @@ def __init__(self, input_models, output=None, single=False, blendheaders=False, Other parameters """ self.input_models = input_models - - self.output_filename = output self.output_dir = None + self.output_filename = output if output is not None and '.fits' not in str(output): self.output_dir = output self.output_filename = None + self.pscale_ratio = pscale_ratio - self.pscale = pscale self.single = single self.blendheaders = blendheaders self.pixfrac = pixfrac @@ -77,54 +77,169 @@ def __init__(self, input_models, output=None, single=False, blendheaders=False, self.weight_type = wht_type self.good_bits = good_bits self.in_memory = kwargs.get('in_memory', True) + self._recalc_pscale_ratio = False + + log.info(f"Driz parameter kernal: {self.kernel}") + log.info(f"Driz parameter pixfrac: {self.pixfrac}") + log.info(f"Driz parameter fillval: {self.fillval}") + log.info(f"Driz parameter weight_type: {self.weight_type}") output_wcs = kwargs.get('output_wcs', None) output_shape = kwargs.get('output_shape', None) - - self.input_pixscale0 = None # computed pixel scale of the first image (deg) - self._recalc_pscale_ratio = pscale is not None - self.asn_id = kwargs.get('asn_id', None) - # Define output WCS based on all inputs, including a reference WCS - if output_wcs is None: - if resample_utils.is_sky_like( - self.input_models[0].meta.wcs.output_frame - ): + # Get an average input pixel scale for parameter calculations + disp_axis = self.input_models[0].meta.wcsinfo.dispersion_direction + self.input_pixscale0 = compute_spectral_pixel_scale( + self.input_models[0].meta.wcs, disp_axis=disp_axis) + if np.isnan(self.input_pixscale0): + log.warning('Input pixel scale could not be determined.') + if pscale is not None: + log.warning('Output pixel scale setting is not supported ' + 'without an input pixel scale. Setting pscale=None.') + pscale = None + + nominal_area = self.input_models[0].meta.photometry.pixelarea_steradians + if nominal_area is None: + log.warning('Nominal pixel area not set in input data.') + if pscale is not None: + log.warning('Output pixel scale setting is not supported ' + 'without a nominal pixel scale. Setting pscale=None.') + pscale = None + + if output_wcs: + # Use user-supplied reference WCS for the resampled image: + self.output_wcs = output_wcs + if output_wcs.pixel_area is None: + if nominal_area is not None: + # Compare input and output spatial scale to update nominal area + output_pscale = compute_spectral_pixel_scale( + output_wcs, disp_axis=disp_axis) + if np.isnan(output_pscale) or np.isnan(self.input_pixscale0): + log.warning('Output pixel scale could not be determined.') + output_pix_area = None + else: + log.debug(f'Setting output pixel area from the approximate ' + f'output spatial scale: {output_pscale}') + output_pix_area = (output_pscale * nominal_area + / self.input_pixscale0) + else: + log.warning("Unable to compute output pixel area " + "from 'output_wcs'.") + output_pix_area = None + else: # pragma: no cover + # This clause is not reachable under usual circumstances: + # gwcs WCS discards the pixel_area attribute when saved as ASDF + log.debug(f'Setting output pixel area from wcs.pixel_area: ' + f'{output_wcs.pixel_area}') + output_pix_area = output_wcs.pixel_area + + # Set the pscale ratio for scaling reasons + if output_pix_area is not None: + self.pscale_ratio = nominal_area / output_pix_area + else: + self.pscale_ratio = 1.0 + + # Set the output shape if specified + if output_shape is not None: + self.output_wcs.array_shape = output_shape[::-1] + else: + if pscale is not None and nominal_area is not None: + log.info(f'Specified output pixel scale: {pscale} arcsec.') + pscale /= 3600.0 + + # Set the pscale ratio from the input pixel scale + # (pscale is input / output) + if self.pscale_ratio != 1.0: + log.warning('Ignoring input pixel_scale_ratio in favor ' + 'of explicit pixel_scale.') + self.pscale_ratio = self.input_pixscale0 / pscale + log.info(f'Computed output pixel scale ratio: {self.pscale_ratio:.5g}') + + # Define output WCS based on all inputs, including a reference WCS. + # These functions internally use self.pscale_ratio to accommodate + # user settings. + # Any other customizations (crpix, crval, rotation) are ignored. + if resample_utils.is_sky_like(self.input_models[0].meta.wcs.output_frame): if self.input_models[0].meta.instrument.name != "NIRSPEC": self.output_wcs = self.build_interpolated_output_wcs() else: self.output_wcs = self.build_nirspec_output_wcs() else: self.output_wcs = self.build_nirspec_lamp_output_wcs() - else: - self.output_wcs = output_wcs - if output_shape is not None: - self.output_wcs.array_shape = output_shape[::-1] + # Use the nominal output pixel area in sr if available, + # scaling for user-set pixel_scale ratio if needed. + if nominal_area is not None: + # Note that there is only one spatial dimension so the + # pscale_ratio is not squared. + output_pix_area = nominal_area / self.pscale_ratio + else: + output_pix_area = None + + if pscale is None: + log.info(f'Specified output pixel scale ratio: {self.pscale_ratio}.') + pscale = compute_spectral_pixel_scale( + self.output_wcs, disp_axis=disp_axis) + log.info(f'Computed output pixel scale: {3600.0 * pscale:.5g} arcsec.') + + # Output model self.blank_output = datamodels.SlitModel(tuple(self.output_wcs.array_shape)) - self.blank_output.update(self.input_models[0]) + + # update meta data and wcs + self.blank_output.update(input_models[0]) self.blank_output.meta.wcs = self.output_wcs - self.output_models = ModelContainer() + if output_pix_area is not None: + self.blank_output.meta.photometry.pixelarea_steradians = output_pix_area + self.blank_output.meta.photometry.pixelarea_arcsecsq = ( + output_pix_area * np.rad2deg(3600)**2) - log.info(f"Driz parameter kernal: {self.kernel}") - log.info(f"Driz parameter pixfrac: {self.pixfrac}") - log.info(f"Driz parameter fillval: {self.fillval}") - log.info(f"Driz parameter weight_type: {self.weight_type}") + self.output_models = ModelContainer() def build_nirspec_output_wcs(self, refmodel=None): """ - Create a spatial/spectral WCS covering footprint of the input + Create a spatial/spectral WCS covering the footprint of the input. + + Creates the output frame by linearly fitting RA, Dec along the slit + and producing a lookup table to interpolate wavelengths in the + dispersion direction. + + For NIRSpec, the output WCS must also provide slit coordinates + to support source location in the spectral extraction step. To do so, + this step creates a lookup table for virtual slit coordinates, + corresponding to the slit y-position at the center of the array + in the input reference model. Slit x-position is set to zero for + all pixels. + + Frames available in the output WCS are: + + - `detector`: image x, y + - `slit_frame`: slit x, slit y, wavelength + - `world`: RA, Dec, wavelength + + Parameters + ---------- + refmodel : `~jwst.datamodels.JwstDataModel`, optional + The reference input image from which the fiducial WCS is created. + If not specified, the first image in self.input_models. If the + first model is empty (all-NaN or all-zero), the first non-empty + model is used. + + Returns + ------- + output_wcs : `~gwcs.WCS` + A gwcs WCS object defining the output frame WCS. """ all_wcs = [m.meta.wcs for m in self.input_models if m is not refmodel] if refmodel: all_wcs.insert(0, refmodel.meta.wcs) else: - # Use the first model with any good data as the reference model + # Use the first model with a reasonable amount of good data + # as the reference model for model in self.input_models: dq_mask = resample_utils.build_mask(model.dq, self.good_bits) good = np.isfinite(model.data) & (model.data != 0) & dq_mask - if np.any(good) and refmodel is None: + if np.sum(good) > 100 and refmodel is None: refmodel = model break @@ -132,137 +247,189 @@ def build_nirspec_output_wcs(self, refmodel=None): if refmodel is None: refmodel = self.input_models[0] - # make a copy of the data array for internal manipulation + # Make a copy of the data array for internal manipulation refmodel_data = refmodel.data.copy() - # renormalize to the minimum value, for best results when + # Renormalize to the minimum value, for best results when # computing the weighted mean below with warnings.catch_warnings(): warnings.filterwarnings("ignore", category=RuntimeWarning, message="All-NaN") refmodel_data -= np.nanmin(refmodel_data) - # save the wcs of the reference model + # Save the wcs of the reference model refwcs = refmodel.meta.wcs - # setup the transforms that are needed + # Set up the transforms that are needed s2d = refwcs.get_transform('slit_frame', 'detector') d2s = refwcs.get_transform('detector', 'slit_frame') - s2w = refwcs.get_transform('slit_frame', 'world') + if 'moving_target' in refwcs.available_frames: + s2w = refwcs.get_transform('slit_frame', 'moving_target') + w2s = refwcs.get_transform('moving_target', 'slit_frame') + else: + s2w = refwcs.get_transform('slit_frame', 'world') + w2s = refwcs.get_transform('world', 'slit_frame') - # estimate position of the target without relying on the meta.target: + # Estimate position of the target without relying on the meta.target: # compute the mean spatial and wavelength coords weighted # by the spectral intensity bbox = refwcs.bounding_box grid = wcstools.grid_from_bounding_box(bbox) _, s, lam = np.array(d2s(*grid)) - sd = s * refmodel_data - ld = lam * refmodel_data - good_s = np.isfinite(sd) - total = np.sum(refmodel_data[good_s]) - if np.any(good_s) and total != 0: - wmean_s = np.sum(sd[good_s]) / total - wmean_l = np.sum(ld[good_s]) / total + + # Find invalid values + good = np.isfinite(s) & np.isfinite(lam) & np.isfinite(refmodel_data) + refmodel_data[~good] = np.nan + + # Reject the worst outliers in the data + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=AstropyUserWarning, + message=".*automatically clipped.*") + weights = sigma_clip(refmodel_data, masked=True, sigma=100.0) + weights = np.ma.filled(weights, fill_value=0.0) + if not np.all(weights == 0.0): + wmean_s = np.average(s[good], weights=weights[good]) else: - wmean_s = 0.5 * (refmodel.slit_ymax - refmodel.slit_ymin) - wmean_l = d2s(*np.mean(bbox, axis=1))[2] + wmean_s = np.nanmean(s) + wmean_l = np.nanmean(lam) - # transform the weighted means into target RA/Dec + # Transform the weighted means into target RA/Dec + # (at the center of the slit in x) targ_ra, targ_dec, _ = s2w(0, wmean_s, wmean_l) + sx, sy = s2d(0, wmean_s, wmean_l) + log.debug(f'Fiducial RA, Dec, wavelength: ' + f'{targ_ra}, {targ_dec}, {wmean_l}') + log.debug(f'Index at fiducial center: x={sx}, y={sy}') + + # Estimate spatial sampling from the reference model + # at the center of the array + lam_center_idx = int(np.mean(bbox, axis=1)[0]) + log.debug(f'Center of dispersion axis: {lam_center_idx}') + grid_center = grid[0][:, lam_center_idx], grid[1][:, lam_center_idx] + ra_ref, dec_ref, _ = np.array(refwcs(*grid_center)) + + # Convert ra and dec to tangent projection + tan = Pix2Sky_TAN() + native2celestial = RotateNative2Celestial(targ_ra, targ_dec, 180) + undist2sky = tan | native2celestial + x_tan, y_tan = undist2sky.inverse(ra_ref, dec_ref) + is_nan = np.isnan(x_tan) | np.isnan(y_tan) + x_tan = x_tan[~is_nan] + y_tan = y_tan[~is_nan] + + # Estimate the spatial sampling from the tangent projection + # offset from center + fitter = LinearLSQFitter() + fit_model = Linear1D() + + xstop = x_tan.shape[0] * self.pscale_ratio + x_idx = np.linspace(0, xstop, x_tan.shape[0], endpoint=False) + ystop = y_tan.shape[0] * self.pscale_ratio + y_idx = np.linspace(0, ystop, y_tan.shape[0], endpoint=False) + pix_to_xtan = fitter(fit_model, x_idx, x_tan) + pix_to_ytan = fitter(fit_model, y_idx, y_tan) - ref_lam = _find_nirspec_output_sampling_wavelengths( - all_wcs, - targ_ra, targ_dec - ) - ref_lam = np.array(ref_lam) - n_lam = ref_lam.size + # Check whether sampling is more along RA or along Dec + swap_xy = abs(pix_to_xtan.slope) < abs(pix_to_ytan.slope) + log.debug(f'Swap xy: {swap_xy}') + + # Get output wavelengths from all data + ref_lam = _find_nirspec_output_sampling_wavelengths(all_wcs) + n_lam = len(ref_lam) if not n_lam: raise ValueError("Not enough data to construct output WCS.") - x_slit = np.zeros(n_lam) - lam = 1e-6 * ref_lam - - # Find the spatial pixel scale: - y_slit_min, y_slit_max = self._max_virtual_slit_extent(all_wcs, targ_ra, targ_dec) - - nsampl = 50 - xy_min = s2d( - nsampl * [0], - nsampl * [y_slit_min], - lam[(tuple((i * n_lam) // nsampl for i in range(nsampl)), )] - ) - xy_max = s2d( - nsampl * [0], - nsampl * [y_slit_max], - lam[(tuple((i * n_lam) // nsampl for i in range(nsampl)), )] - ) - - good = np.logical_and(np.isfinite(xy_min), np.isfinite(xy_max)) - if not np.any(good): - raise ValueError("Error estimating output WCS pixel scale.") - - xy1 = s2d(x_slit, np.full(n_lam, refmodel.slit_ymin), lam) - xy2 = s2d(x_slit, np.full(n_lam, refmodel.slit_ymax), lam) - xylen = np.nanmax(np.linalg.norm(np.array(xy1) - np.array(xy2), axis=0)) + 1 - pscale = (refmodel.slit_ymax - refmodel.slit_ymin) / xylen - - # compute image span along Y-axis (length of the slit in the detector plane) - # det_slit_span = np.linalg.norm(np.subtract(xy_max, xy_min)) - det_slit_span = np.nanmax(np.linalg.norm(np.subtract(xy_max, xy_min), axis=0)) - ny = int(np.ceil(det_slit_span * self.pscale_ratio + 0.5)) + 1 - - border = 0.5 * (ny - det_slit_span * self.pscale_ratio) - 0.5 - - if xy_min[1][1] < xy_max[1][1]: - y_slit_model = Linear1D( - slope=pscale / self.pscale_ratio, - intercept=y_slit_min - border * pscale * self.pscale_ratio - ) + # Find the spatial extent in x/y tangent + min_tan, max_tan = self._max_spatial_extent(all_wcs, undist2sky.inverse, swap_xy) + diff = np.abs(max_tan - min_tan) + if swap_xy: + pix_to_tan_slope = np.abs(pix_to_ytan.slope) + slope_sign = np.sign(pix_to_ytan.slope) + else: + pix_to_tan_slope = np.abs(pix_to_xtan.slope) + slope_sign = np.sign(pix_to_xtan.slope) + + # Image size in spatial dimension from the maximum slope + # and tangent offset span, plus one pixel to make sure + # we catch all the data + ny = int(np.ceil(diff / pix_to_tan_slope)) + 1 + + # Correct the intercept for the new minimum value. + # Also account for integer pixel size to make sure the + # data is centered in the array. + offset = ny/2 * pix_to_tan_slope - diff/2 + if slope_sign > 0: + zero_value = min_tan else: - y_slit_model = Linear1D( - slope=-pscale / self.pscale_ratio, - intercept=y_slit_max + border * pscale * self.pscale_ratio - ) + zero_value = max_tan + if swap_xy: + pix_to_ytan.intercept = zero_value - slope_sign * offset + else: + pix_to_xtan.intercept = zero_value - slope_sign * offset - # extrapolate 1/2 pixel at the edges and make tabular model w/inverse: - lam = lam.tolist() - pixel_coord = list(range(n_lam)) + # Now set up the final transforms + # For wavelengths, extrapolate 1/2 pixel at the edges and + # make tabular model w/inverse + pixel_coord = list(range(n_lam)) if len(pixel_coord) > 1: # left: - slope = (lam[1] - lam[0]) / pixel_coord[1] - lam.insert(0, -0.5 * slope + lam[0]) + slope = (ref_lam[1] - ref_lam[0]) / pixel_coord[1] + ref_lam.insert(0, -0.5 * slope + ref_lam[0]) pixel_coord.insert(0, -0.5) + # right: - slope = (lam[-1] - lam[-2]) / (pixel_coord[-1] - pixel_coord[-2]) - lam.append(slope * (pixel_coord[-1] + 0.5) + lam[-2]) + slope = (ref_lam[-1] - ref_lam[-2]) / (pixel_coord[-1] - pixel_coord[-2]) + ref_lam.append(slope * (pixel_coord[-1] + 0.5) + ref_lam[-2]) pixel_coord.append(pixel_coord[-1] + 0.5) - else: - lam = 3 * lam + ref_lam = 3 * ref_lam pixel_coord = [-0.5, 0, 0.5] - wavelength_transform = Tabular1D(points=pixel_coord, - lookup_table=lam, + lookup_table=ref_lam, bounds_error=False, fill_value=np.nan) - wavelength_transform.inverse = Tabular1D(points=lam, - lookup_table=pixel_coord, - bounds_error=False, - fill_value=np.nan) - self.data_size = (ny, len(ref_lam)) - - # Construct the final transform. - # First coordinate is set to 0 to represent the "horizontal" center - # of the slit (if we imagine slit to be vertical in the usual X-Y 2D - # cartesian frame): + + # For spatial coordinates, map detector pixels to tangent offset, + # then to world coordinates (RA, Dec, wavelength in um). + # Make sure the inverse returns the axis with the larger slope, + # in case the smaller one is close to zero + mapping = Mapping((1, 1, 0)) + if swap_xy: + mapping.inverse = Mapping((2, 1)) + else: + mapping.inverse = Mapping((2, 0)) + pix2world = mapping | (pix_to_xtan & pix_to_ytan | undist2sky) & wavelength_transform + + # For NIRSpec, slit coordinates are still needed to locate the + # planned source position. Since the slit is now rectified, + # return the central slit coords for all x, converting from pixels + # to world coordinates, then back to slit units. + slit_center = w2s(*pix2world(np.full(ny, n_lam // 2), np.arange(ny)))[1] + + # Make a 1D lookup table for all ny. + # Allow linear extrapolation at the edges. + slit_transform = Tabular1D( + points=np.arange(ny), lookup_table=slit_center, + bounds_error=False, fill_value=None) + + # In the transform, the first slit coordinate is always set to 0 + # to represent the "horizontal" center of the slit + # (if we imagine the slit to be vertical in the usual + # X-Y 2D cartesian frame). mapping = Mapping((0, 1, 0)) - inv_mapping = Mapping((2, 1)) + inv_mapping = Mapping((2, 1), n_inputs=3) inv_mapping.inverse = mapping mapping.inverse = inv_mapping zero_model = Const1D(0) zero_model.inverse = zero_model - det2slit = mapping | zero_model & y_slit_model & wavelength_transform - # Create coordinate frames + # Final detector to slit transform (x, y -> slit_x, slit_y, wavelength) + det2slit = mapping | zero_model & slit_transform & wavelength_transform + + # The slit to world coordinates is just the inverse of the slit transform, + # piped back into the pixel to world transform + slit2world = det2slit.inverse | pix2world + + # Create coordinate frames: detector, slit_frame, and world det = cf.Frame2D(name='detector', axes_order=(0, 1)) slit_spatial = cf.Frame2D(name='slit_spatial', axes_order=(0, 1), unit=("", ""), axes_names=('x_slit', 'y_slit')) @@ -273,81 +440,59 @@ def build_nirspec_output_wcs(self, refmodel=None): reference_frame=coord.ICRS()) world = cf.CompositeFrame([sky, spec], name='world') - pipeline = [(det, det2slit), (slit_frame, s2w), (world, None)] + pipeline = [(det, det2slit), (slit_frame, slit2world), (world, None)] output_wcs = WCS(pipeline) - # Compute bounding box and output array shape. Add one to the y (slit) - # height to account for the half pixel at top and bottom due to pixel - # coordinates being centers of pixels + # Compute bounding box and output array shape. + self.data_size = (ny, n_lam) bounding_box = resample_utils.wcs_bbox_from_shape(self.data_size) output_wcs.bounding_box = bounding_box output_wcs.array_shape = self.data_size return output_wcs - def _max_virtual_slit_extent(self, wcs_list, target_ra, target_dec): + def _max_spatial_extent(self, wcs_list, transform, swap_xy): """ - Compute min & max slit coordinates for all nods in the "virtual" + Compute min & max spatial coordinates for all nods in the "virtual" slit frame. - - NOTE: this code, potentially, might have troubles dealing - with large dithers such that ``target_ra`` and ``target_dec`` - may not be converted to slit frame (i.e., result in ``NaN``). - - A more sophisticated algorithm may be needed to "stitch" large - dithers. But then distortions may come into play. """ - y_slit_min = np.inf - y_slit_max = -np.inf - - t0 = 0 - + min_tan_all, max_tan_all = np.inf, -np.inf for wcs in wcs_list: - d2s = wcs.get_transform('detector', 'slit_frame') - w2s = wcs.get_transform('world', 'slit_frame') - x, y = wcstools.grid_from_bounding_box(wcs.bounding_box) - ra, dec, lam = wcs(x, y) + ra, dec, _ = wcs(x, y) good = np.logical_and(np.isfinite(ra), np.isfinite(dec)) - x = x[good] - y = y[good] - lm = lam[good] + ra = ra[good] + dec = dec[good] - _, yslit, _ = d2s(x, y) - - # position of the target in the slit relative to its position - # for the refence image: - ts = w2s(target_ra, target_dec, np.mean(lm))[1] - t0 - - if wcs is wcs_list[0]: - t0 = ts - ts = 0 - - y_slit_min_i = np.min(yslit) - ts - y_slit_max_i = np.max(yslit) - ts + xtan, ytan = transform(ra, dec) + if swap_xy: + tan_all = ytan + else: + tan_all = xtan - if y_slit_min_i < y_slit_min: - y_slit_min = y_slit_min_i + min_tan = np.min(tan_all) + max_tan = np.max(tan_all) - if y_slit_max_i > y_slit_max: - y_slit_max = y_slit_max_i + if min_tan < min_tan_all: + min_tan_all = min_tan + if max_tan > max_tan_all: + max_tan_all = max_tan - return y_slit_min, y_slit_max + return min_tan_all, max_tan_all - def build_interpolated_output_wcs(self, refmodel=None): + def build_interpolated_output_wcs(self): """ - Create a spatial/spectral WCS output frame using all the input models + Create a spatial/spectral WCS output frame using all the input models. Creates output frame by linearly fitting RA, Dec along the slit and producing a lookup table to interpolate wavelengths in the dispersion direction. - Parameters - ---------- - refmodel : `~jwst.datamodels.JwstDataModel` - The reference input image from which the fiducial WCS is created. - If not specified, the first image in self.input_models is used. + Frames available in the output WCS are: + + - `detector`: image x, y + - `world`: RA, Dec, wavelength Returns ------- @@ -386,7 +531,7 @@ def build_interpolated_output_wcs(self, refmodel=None): wavelength_array = wavelength_array[~np.isnan(wavelength_array)] # We need to estimate the spatial sampling to use for the output WCS. - # Tt is assumed the spatial sampling is the same for all the input + # It is assumed the spatial sampling is the same for all the input # models. So we can use the first input model to set the spatial # sampling. @@ -405,12 +550,14 @@ def build_interpolated_output_wcs(self, refmodel=None): # find the center ra and dec for this slit at central wavelength lam_center_index = int((bbox[spectral_axis][1] - bbox[spectral_axis][0]) / 2) - if spatial_axis == 0: # MIRI LRS, the WCS x axis is spatial + if spatial_axis == 0: + # MIRI LRS, the WCS x axis is spatial ra_slice = ra[lam_center_index, :] dec_slice = dec[lam_center_index, :] else: ra_slice = ra[:, lam_center_index] dec_slice = dec[:, lam_center_index] + # wrap RA if near zero ra_center_pt = np.nanmean(wrap_ra(ra_slice)) dec_center_pt = np.nanmean(dec_slice) @@ -419,6 +566,7 @@ def build_interpolated_output_wcs(self, refmodel=None): tan = Pix2Sky_TAN() native2celestial = RotateNative2Celestial(ra_center_pt, dec_center_pt, 180) undist2sky1 = tan | native2celestial + # Filter out RuntimeWarnings due to computed NaNs in the WCS with warnings.catch_warnings(): warnings.simplefilter("ignore", RuntimeWarning) # was ignore. need to make more specific @@ -439,12 +587,13 @@ def build_interpolated_output_wcs(self, refmodel=None): # estimate the spatial sampling fitter = LinearLSQFitter() fit_model = Linear1D() - xstop = x_tan_array.shape[0] / self.pscale_ratio - xstep = 1 / self.pscale_ratio - ystop = y_tan_array.shape[0] / self.pscale_ratio - ystep = 1 / self.pscale_ratio - pix_to_xtan = fitter(fit_model, np.arange(0, xstop, xstep), x_tan_array) - pix_to_ytan = fitter(fit_model, np.arange(0, ystop, ystep), y_tan_array) + + xstop = x_tan_array.shape[0] * self.pscale_ratio + x_idx = np.linspace(0, xstop, x_tan_array.shape[0], endpoint=False) + ystop = y_tan_array.shape[0] * self.pscale_ratio + y_idx = np.linspace(0, ystop, y_tan_array.shape[0], endpoint=False) + pix_to_xtan = fitter(fit_model, x_idx, x_tan_array) + pix_to_ytan = fitter(fit_model, y_idx, y_tan_array) # append all ra and dec values to use later to find min and max # ra and dec @@ -479,8 +628,8 @@ def build_interpolated_output_wcs(self, refmodel=None): if self.input_models[0].meta.exposure.type == 'MIR_LRS-FIXEDSLIT': wavelength_array = np.flip(wavelength_array, axis=None) - step = 1 / self.pscale_ratio - stop = wavelength_array.shape[0] / self.pscale_ratio + step = 1 + stop = wavelength_array.shape[0] points = np.arange(0, stop, step) pix_to_wavelength = Tabular1D(points=points, lookup_table=wavelength_array, @@ -576,8 +725,8 @@ def build_interpolated_output_wcs(self, refmodel=None): # compute the output array size in WCS axes order, i.e. (x, y) output_array_size = [0, 0] - output_array_size[spectral_axis] = int(np.ceil(len(wavelength_array) / self.pscale_ratio)) - output_array_size[spatial_axis] = int(np.ceil(x_size / self.pscale_ratio)) + output_array_size[spectral_axis] = int(np.ceil(len(wavelength_array))) + output_array_size[spatial_axis] = int(np.ceil(x_size * self.pscale_ratio)) # turn the size into a numpy shape in (y, x) order output_wcs.array_shape = output_array_size[::-1] @@ -595,10 +744,15 @@ def build_nirspec_lamp_output_wcs(self): producing a lookup table to interpolate wavelengths in the dispersion direction. + Frames available in the output WCS are: + + - `detector`: image x, y + - `world`: MSA x, MSA y, wavelength + Returns ------- output_wcs : `~gwcs.WCS` object - A gwcs WCS object defining the output frame WCS + A gwcs WCS object defining the output frame WCS. """ model = self.input_models[0] wcs = model.meta.wcs @@ -628,15 +782,15 @@ def build_nirspec_lamp_output_wcs(self): # Estimate and fit the spatial sampling fitter = LinearLSQFitter() fit_model = Linear1D() - xstop = x_msa_array.shape[0] / self.pscale_ratio - xstep = 1 / self.pscale_ratio - ystop = y_msa_array.shape[0] / self.pscale_ratio - ystep = 1 / self.pscale_ratio - pix_to_x_msa = fitter(fit_model, np.arange(0, xstop, xstep), x_msa_array) - pix_to_y_msa = fitter(fit_model, np.arange(0, ystop, ystep), y_msa_array) - - step = 1 / self.pscale_ratio - stop = wavelength_array.shape[0] / self.pscale_ratio + xstop = x_msa_array.shape[0] * self.pscale_ratio + x_idx = np.linspace(0, xstop, x_msa_array.shape[0], endpoint=False) + ystop = y_msa_array.shape[0] * self.pscale_ratio + y_idx = np.linspace(0, ystop, y_msa_array.shape[0], endpoint=False) + pix_to_x_msa = fitter(fit_model, x_idx, x_msa_array) + pix_to_y_msa = fitter(fit_model, y_idx, y_msa_array) + + step = 1 + stop = wavelength_array.shape[0] points = np.arange(0, stop, step) pix_to_wavelength = Tabular1D(points=points, lookup_table=wavelength_array, @@ -679,9 +833,10 @@ def build_nirspec_lamp_output_wcs(self): # Compute the output array size and bounding box output_array_size = [0, 0] - output_array_size[spectral_axis] = int(np.ceil(len(wavelength_array) / self.pscale_ratio)) + output_array_size[spectral_axis] = len(wavelength_array) x_size = len(x_msa_array) - output_array_size[spatial_axis] = int(np.ceil(x_size / self.pscale_ratio)) + output_array_size[spatial_axis] = int(np.ceil(x_size * self.pscale_ratio)) + # turn the size into a numpy shape in (y, x) order output_wcs.array_shape = output_array_size[::-1] output_wcs.pixel_shape = output_array_size @@ -700,33 +855,14 @@ def find_dispersion_axis(refmodel): return dispaxis - 1 -def _spherical_sep(j, k, wcs, xyz_ref): - """ - Objective function that computes the angle between two points - on the sphere for small separations. - """ - ra, dec, _ = wcs(k, j, with_bounding_box=False) - return 1 - np.dot(_S2C(ra, dec), xyz_ref) - - -def _find_nirspec_output_sampling_wavelengths(wcs_list, targ_ra, targ_dec, mode='median'): - assert mode in ['median', 'fast', 'accurate'] +def _find_nirspec_output_sampling_wavelengths(wcs_list): refwcs = wcs_list[0] bbox = refwcs.bounding_box grid = wcstools.grid_from_bounding_box(bbox) ra, dec, lambdas = refwcs(*grid) - if mode == 'median': - ref_lam = sorted(np.nanmedian(lambdas[:, np.any(np.isfinite(lambdas), axis=0)], axis=0)) - else: - ref_lam, _, _ = _find_nirspec_sampling_wavelengths( - refwcs, - targ_ra, targ_dec, - ra, dec, - fast=mode == 'fast' - ) - + ref_lam = sorted(np.nanmedian(lambdas[:, np.any(np.isfinite(lambdas), axis=0)], axis=0)) lam1 = ref_lam[0] lam2 = ref_lam[-1] @@ -737,15 +873,7 @@ def _find_nirspec_output_sampling_wavelengths(wcs_list, targ_ra, targ_dec, mode= bbox = w.bounding_box grid = wcstools.grid_from_bounding_box(bbox) ra, dec, lambdas = w(*grid) - if mode == 'median': - lam = sorted(np.nanmedian(lambdas[:, np.any(np.isfinite(lambdas), axis=0)], axis=0)) - else: - lam, _, _ = _find_nirspec_sampling_wavelengths( - w, - targ_ra, targ_dec, - ra, dec, - fast=mode == 'fast' - ) + lam = sorted(np.nanmedian(lambdas[:, np.any(np.isfinite(lambdas), axis=0)], axis=0)) image_lam.append((lam, np.min(lam), np.max(lam))) min_delta = min(min_delta, np.fabs(np.ediff1d(ref_lam).min())) @@ -794,121 +922,29 @@ def _find_nirspec_output_sampling_wavelengths(wcs_list, targ_ra, targ_dec, mode= return ref_lam -def _find_nirspec_sampling_wavelengths(wcs, ra0, dec0, ra, dec, fast=True): - xdet = [] - lms = [] - ys = [] - skipped = [] - - eps = 10 * np.finfo(float).eps - - xyz_ref = _S2C(ra0, dec0) - ymax, xmax = ra.shape - good = np.logical_and(np.isfinite(ra), np.isfinite(dec)) - - j0 = 0 - - for k in range(xmax): - if not any(good[:, k]): - if xdet: - skipped.append(k) - continue - - idx = np.flatnonzero(good[:, k]).tolist() - if j0 in idx: - i = idx.index(j0) - else: - i = 0 - j0 = idx[0] - - dmin = _spherical_sep(j0, k, wcs, xyz_ref) - - for j in idx[i + 1:]: - d = _spherical_sep(j, k, wcs, xyz_ref) - if d < dmin: - dmin = d - j0 = j - elif d > dmin: - break - - for j in idx[max(i - 1, 0):None if i else 0:-1]: - d = _spherical_sep(j, k, wcs, xyz_ref) - if d < dmin: - dmin = d - j0 = j - elif d > dmin: - break - - if j0 == 0 or not good[j0 - 1, k]: - j1 = j0 - 0.49999 - else: - j1 = j0 - 0.99999 - - if j0 == ymax - 1 or not good[j0 + 1, k]: - j2 = j0 + 0.49999 - else: - j2 = j0 + 0.99999 - - if fast: - # parabolic minimization: - f0 = dmin - f1 = _spherical_sep(j1, k, wcs, xyz_ref) - if not np.isfinite(f1): - # give another try with 1/2 step: - j1 = 0.5 * (j1 + j0) - f1 = _spherical_sep(j1, k, wcs, xyz_ref) - - f2 = _spherical_sep(j2, k, wcs, xyz_ref) - if not np.isfinite(f2): - # give another try with 1/2 step: - j2 = 0.5 * (j2 + j0) - f2 = _spherical_sep(j2, k, wcs, xyz_ref) - - if np.isfinite(f1) and np.isfinite(f2): - dn = (j0 - j1) * (f0 - f2) - (j0 - j2) * (f0 - f1) - if np.abs(dn) < eps: - jmin = j0 - else: - jmin = j0 - 0.5 * ((j0 - j1)**2 * (f0 - f2) - - (j0 - j2)**2 * (f0 - f1)) / dn - jmin = max(min(jmin, j2), j1) - else: - jmin = j0 +def compute_spectral_pixel_scale(wcs, fiducial=None, disp_axis=1): + """Compute an approximate spatial pixel scale for spectral data. + + Parameters + ---------- + wcs : gwcs.WCS + Spatial/spectral WCS. + fiducial : tuple of float, optional + (RA, Dec, wavelength) taken as the fiducial reference. If + not specified, the center of the array is used. + disp_axis : int + Dispersion axis for the data. Assumes the same convention + as `wcsinfo.dispersion_direction` (1 for NIRSpec, 2 for MIRI). + + Returns + ------- + pixel_scale : float + The spatial scale in degrees. + """ + # Get the coordinates for the center of the array + if fiducial is None: + center_x, center_y = np.mean(wcs.bounding_box, axis=1) + fiducial = wcs(center_x, center_y) - else: - r = minimize_scalar( - _spherical_sep, - method='golden', - bracket=(j1, j2), - args=(k, wcs, xyz_ref), - tol=None, - options={'maxiter': 10, 'xtol': 1e-2 / (j0 + 1)} - ) - jmin = r['x'] - if np.isfinite(jmin): - jmin = max(min(jmin, j2), j1) - else: - jmin = j0 - - targ_lam = wcs(k, jmin)[-1] - if not np.isfinite(targ_lam): - targ_lam = wcs(k, j0)[-1] - - if not np.isfinite(targ_lam): - if xdet: - skipped.append(k) - continue - - lms.append(targ_lam) - ys.append(jmin) - xdet.append(k) - - skipped = [s for s in skipped if s <= xdet[-1]] - if skipped and skipped[0] < xdet[-1]: - # there are columns with all pixels having invalid world, - # coordinates. Fill the gaps using linear interpolation. - raise NotImplementedError( - "Support for discontinuous sampling was not implemented." - ) - - return lms, xdet, ys + pixel_scale = compute_scale(wcs, fiducial, disp_axis=disp_axis) + return float(pixel_scale) diff --git a/jwst/resample/resample_spec_step.py b/jwst/resample/resample_spec_step.py index e215a7bb61..b0630ed590 100755 --- a/jwst/resample/resample_spec_step.py +++ b/jwst/resample/resample_spec_step.py @@ -114,6 +114,7 @@ def _process_multislit(self, input_models): result.update(input_models[0]) + pscale_ratio = None for container in containers.values(): resamp = resample_spec.ResampleSpecData(container, **self.drizpars) @@ -124,10 +125,17 @@ def _process_multislit(self, input_models): update_s_region_spectral(model) result.slits.append(model) + # Keep the first computed pixel scale ratio for storage + if self.pixel_scale is not None and pscale_ratio is None: + pscale_ratio = resamp.pscale_ratio + result.meta.cal_step.resample = "COMPLETE" result.meta.asn.pool_name = input_models.asn_pool_name result.meta.asn.table_name = input_models.asn_table_name - result.meta.resample.pixel_scale_ratio = self.pixel_scale_ratio + if self.pixel_scale is None or pscale_ratio is None: + result.meta.resample.pixel_scale_ratio = self.pixel_scale_ratio + else: + result.meta.resample.pixel_scale_ratio = pscale_ratio result.meta.resample.pixfrac = self.pixfrac return result @@ -157,7 +165,10 @@ def _process_slit(self, input_models): result.meta.asn.pool_name = input_models.asn_pool_name result.meta.asn.table_name = input_models.asn_table_name result.meta.bunit_data = drizzled_models[0].meta.bunit_data - result.meta.resample.pixel_scale_ratio = self.pixel_scale_ratio + if self.pixel_scale is None: + result.meta.resample.pixel_scale_ratio = self.pixel_scale_ratio + else: + result.meta.resample.pixel_scale_ratio = resamp.pscale_ratio result.meta.resample.pixfrac = self.pixfrac self.update_slit_metadata(result) update_s_region_spectral(result) diff --git a/jwst/resample/resample_utils.py b/jwst/resample/resample_utils.py index 0317216e58..0ba20c62c9 100644 --- a/jwst/resample/resample_utils.py +++ b/jwst/resample/resample_utils.py @@ -219,6 +219,27 @@ def is_sky_like(frame): return u.Unit("deg") in frame.unit or u.Unit("arcsec") in frame.unit +def is_flux_density(bunit): + """ + Differentiate between surface brightness and flux density data units. + + Parameters + ---------- + bunit : str or `~astropy.units.Unit` + Data units, e.g. 'MJy' (is flux density) or 'MJy/sr' (is not). + + Returns + ------- + bool + True if the units are equivalent to flux density units. + """ + try: + flux_density = u.Unit(bunit).is_equivalent(u.Jy) + except (ValueError, TypeError): + flux_density = False + return flux_density + + def decode_context(context, x, y): """ Get 0-based indices of input images that contributed to (resampled) output pixel with coordinates ``x`` and ``y``. diff --git a/jwst/resample/tests/test_resample_step.py b/jwst/resample/tests/test_resample_step.py index f9cfa56ef3..62af3e496f 100644 --- a/jwst/resample/tests/test_resample_step.py +++ b/jwst/resample/tests/test_resample_step.py @@ -10,10 +10,11 @@ from jwst.datamodels import ModelContainer from jwst.assign_wcs import AssignWcsStep from jwst.assign_wcs.util import compute_fiducial, compute_scale +from jwst.exp_to_source import multislit_to_container from jwst.extract_2d import Extract2dStep from jwst.resample import ResampleSpecStep, ResampleStep from jwst.resample.resample import compute_image_pixel_area -from jwst.resample.resample_spec import ResampleSpecData +from jwst.resample.resample_spec import ResampleSpecData, compute_spectral_pixel_scale def _set_photom_kwd(im): @@ -37,42 +38,36 @@ def _set_photom_kwd(im): @pytest.fixture -def nirspec_rate(): - ysize = 2048 - xsize = 2048 +def miri_rate(): + xsize = 72 + ysize = 416 shape = (ysize, xsize) im = ImageModel(shape) + im.data += 5 im.var_rnoise += 1 - im.meta.target = {'ra': 100.1237, 'dec': 39.86} im.meta.wcsinfo = { 'dec_ref': 40, 'ra_ref': 100, - 'roll_ref': 0, + 'roll_ref': 0.0, 'v2_ref': -453.5134, 'v3_ref': -373.4826, 'v3yangle': 0.0, 'vparity': -1} im.meta.instrument = { - 'detector': 'NRS1', - 'filter': 'CLEAR', - 'grating': 'PRISM', - 'name': 'NIRSPEC', - 'gwa_tilt': 37.0610, - 'gwa_xtilt': 0.0001, - 'gwa_ytilt': 0.0001, - 'fixed_slit': 'S200A1'} + 'detector': 'MIRIMAGE', + 'filter': 'P750L', + 'name': 'MIRI'} + im.meta.observation = { + 'date': '2019-01-01', + 'time': '17:00:00'} im.meta.subarray = { 'fastaxis': 1, - 'name': 'SUBS200A1', + 'name': 'SLITLESSPRISM', 'slowaxis': 2, - 'xsize': 72, + 'xsize': xsize, 'xstart': 1, - 'ysize': 416, + 'ysize': ysize, 'ystart': 529} - im.meta.observation = { - 'program_number': '1234', - 'date': '2016-09-05', - 'time': '8:59:37'} im.meta.exposure = { 'duration': 11.805952, 'end_time': 58119.85416, @@ -87,29 +82,40 @@ def nirspec_rate(): 'nints': 1, 'nresets_between_ints': 0, 'nsamples': 1, - 'readpatt': 'NRSRAPID', + 'readpatt': 'FAST', 'sample_time': 10.0, 'start_time': 58119.8333, - 'type': 'NRS_FIXEDSLIT', + 'type': 'MIR_LRS-SLITLESS', 'zero_frame': False} + yield im + im.close() + + +@pytest.fixture +def miri_cal(miri_rate): + im = AssignWcsStep.call(miri_rate) + _set_photom_kwd(im) + + # Add non-zero values to check flux conservation + im.data += 1.0 - return im + yield im + im.close() @pytest.fixture -def miri_rate(): - xsize = 72 - ysize = 416 +def miri_rate_zero_crossing(): + xsize = 1032 + ysize = 1024 shape = (ysize, xsize) im = ImageModel(shape) - im.data += 5 - im.var_rnoise += 1 + im.var_rnoise = np.random.random(shape) im.meta.wcsinfo = { - 'dec_ref': 40, - 'ra_ref': 100, + 'dec_ref': 2.16444343946559e-05, + 'ra_ref': -0.00026031780056776, 'roll_ref': 0.0, - 'v2_ref': -453.5134, - 'v3_ref': -373.4826, + 'v2_ref': -415.0690466121227, + 'v3_ref': -400.575920398547, 'v3yangle': 0.0, 'vparity': -1} im.meta.instrument = { @@ -121,17 +127,16 @@ def miri_rate(): 'time': '17:00:00'} im.meta.subarray = { 'fastaxis': 1, - 'name': 'SLITLESSPRISM', + 'name': 'FULL', 'slowaxis': 2, 'xsize': xsize, 'xstart': 1, 'ysize': ysize, - 'ystart': 529} + 'ystart': 1} im.meta.exposure = { 'duration': 11.805952, 'end_time': 58119.85416, 'exposure_time': 11.776, - 'measurement_time': 11.65824, 'frame_time': 0.11776, 'group_time': 0.11776, 'groupgap': 0, @@ -144,10 +149,26 @@ def miri_rate(): 'readpatt': 'FAST', 'sample_time': 10.0, 'start_time': 58119.8333, - 'type': 'MIR_LRS-SLITLESS', + 'type': 'MIR_LRS-FIXEDSLIT', 'zero_frame': False} - return im + yield im + im.close() + + +@pytest.fixture +def miri_rate_pair(miri_rate_zero_crossing): + im1 = miri_rate_zero_crossing + # Create a nodded version + im2 = im1.copy() + im2.meta.wcsinfo.ra_ref = 0.00026308279776455 + im2.meta.wcsinfo.dec_ref = -2.1860888891293e-05 + im1 = AssignWcsStep.call(im1) + im2 = AssignWcsStep.call(im2) + + yield im1, im2 + im1.close() + im2.close() @pytest.fixture @@ -220,22 +241,133 @@ def nircam_rate(): 'pixelarea_steradians': 1e-13, 'pixelarea_arcsecsq': 4e-3, } + yield im + im.close() - return im +@pytest.fixture +def nirspec_rate(): + ysize = 2048 + xsize = 2048 + shape = (ysize, xsize) + im = ImageModel(shape) + im.var_rnoise += 1 + im.meta.target = {'ra': 100.1237, 'dec': 39.86} + im.meta.wcsinfo = { + 'dec_ref': 40, + 'ra_ref': 100, + 'roll_ref': 0, + 'v2_ref': -453.5134, + 'v3_ref': -373.4826, + 'v3yangle': 0.0, + 'vparity': -1} + im.meta.instrument = { + 'detector': 'NRS1', + 'filter': 'CLEAR', + 'grating': 'PRISM', + 'name': 'NIRSPEC', + 'gwa_tilt': 37.0610, + 'gwa_xtilt': 0.0001, + 'gwa_ytilt': 0.0001, + 'fixed_slit': 'S200A1'} + im.meta.subarray = { + 'fastaxis': 1, + 'name': 'SUBS200A1', + 'slowaxis': 2, + 'xsize': 72, + 'xstart': 1, + 'ysize': 416, + 'ystart': 529} + im.meta.observation = { + 'program_number': '1234', + 'date': '2016-09-05', + 'time': '8:59:37'} + im.meta.exposure = { + 'duration': 11.805952, + 'end_time': 58119.85416, + 'exposure_time': 11.776, + 'measurement_time': 11.65824, + 'frame_time': 0.11776, + 'group_time': 0.11776, + 'groupgap': 0, + 'integration_time': 11.776, + 'nframes': 1, + 'ngroups': 100, + 'nints': 1, + 'nresets_between_ints': 0, + 'nsamples': 1, + 'readpatt': 'NRSRAPID', + 'sample_time': 10.0, + 'start_time': 58119.8333, + 'type': 'NRS_FIXEDSLIT', + 'zero_frame': False} + + yield im + im.close() -def test_nirspec_wcs_roundtrip(nirspec_rate): + +@pytest.fixture +def nirspec_cal(nirspec_rate): im = AssignWcsStep.call(nirspec_rate) # Since the ra_targ, and dec_targ are flux-weighted, we need non-zero - # flux values. Add random values. - rng = np.random.default_rng(1234) - im.data += rng.random(im.data.shape) + # flux values. + im.data += 1.0 im = Extract2dStep.call(im) for slit in im.slits: _set_photom_kwd(slit) - im = ResampleSpecStep.call(im) + yield im + im.close() + + +@pytest.fixture +def nirspec_cal_pair(nirspec_rate): + # copy the rate model to make files with different filters + rate1 = nirspec_rate + rate1.meta.instrument.grating = 'G140H' + rate1.meta.instrument.filter = 'F070LP' + rate2 = nirspec_rate.copy() + rate2.meta.instrument.grating = 'G140H' + rate2.meta.instrument.filter = 'F100LP' + + im1 = AssignWcsStep.call(nirspec_rate) + im2 = AssignWcsStep.call(rate2) + + # Since the ra_targ, and dec_targ are flux-weighted, we need non-zero + # flux values. + im1.data += 1.0 + im2.data += 1.0 + + im1 = Extract2dStep.call(im1) + im2 = Extract2dStep.call(im2) + for slit in im1.slits: + _set_photom_kwd(slit) + for slit in im2.slits: + _set_photom_kwd(slit) + yield im1, im2 + im1.close() + im2.close() + + +@pytest.fixture +def nirspec_lamp(nirspec_rate): + nirspec_rate.meta.exposure.type = 'NRS_LAMP' + nirspec_rate.meta.instrument.lamp_mode = 'FIXEDSLIT' + nirspec_rate.meta.instrument.lamp_state = 'FLAT' + im = AssignWcsStep.call(nirspec_rate) + im.data += 1.0 + + im = Extract2dStep.call(im) + for slit in im.slits: + _set_photom_kwd(slit) + + yield im + im.close() + + +def test_nirspec_wcs_roundtrip(nirspec_cal): + im = ResampleSpecStep.call(nirspec_cal) for slit in im.slits: x, y = grid_from_bounding_box(slit.meta.wcs.bounding_box) @@ -244,29 +376,31 @@ def test_nirspec_wcs_roundtrip(nirspec_rate): assert_allclose(x, xp, rtol=0, atol=1e-8) assert_allclose(y, yp, rtol=0, atol=3e-4) + im.close() -def test_miri_wcs_roundtrip(miri_rate): - im = AssignWcsStep.call(miri_rate) - _set_photom_kwd(im) - im = ResampleSpecStep.call(im) +def test_nirspec_lamp_wcs_roundtrip(nirspec_lamp): + im = ResampleSpecStep.call(nirspec_lamp) + + for slit in im.slits: + x, y = grid_from_bounding_box(slit.meta.wcs.bounding_box) + ra, dec, lam = slit.meta.wcs(x, y) + xp, yp = slit.meta.wcs.invert(ra, dec, lam) + + assert_allclose(x, xp, rtol=0, atol=1e-8) + assert_allclose(y, yp, rtol=0, atol=3e-4) + im.close() + +def test_miri_wcs_roundtrip(miri_cal): + im = ResampleSpecStep.call(miri_cal) x, y = grid_from_bounding_box(im.meta.wcs.bounding_box) ra, dec, lam = im.meta.wcs(x, y) xp, yp = im.meta.wcs.invert(ra, dec, lam) assert_allclose(x, xp, atol=1e-8) assert_allclose(y, yp, atol=1e-8) - - -@pytest.mark.parametrize("ratio", [0.5, 0.7, 1.0]) -def test_pixel_scale_ratio_spec(miri_rate, ratio): - im = AssignWcsStep.call(miri_rate, sip_approx=False) - _set_photom_kwd(im) - result1 = ResampleSpecStep.call(im) - result2 = ResampleSpecStep.call(im, pixel_scale_ratio=ratio) - - assert_allclose(np.array(result1.data.shape), np.array(result2.data.shape) * ratio, rtol=1, atol=1) + im.close() @pytest.mark.parametrize("ratio", [0.5, 0.7, 1.0]) @@ -293,6 +427,129 @@ def test_pixel_scale_ratio_imaging(nircam_rate, ratio): assert result1.meta.resample.pixel_scale_ratio == 1.0 assert result2.meta.resample.pixel_scale_ratio == ratio + im.close() + result1.close() + result2.close() + + +@pytest.mark.parametrize("units", ["MJy", "MJy/sr"]) +@pytest.mark.parametrize("ratio", [0.7, 1.0, 1.3]) +def test_pixel_scale_ratio_spec_miri(miri_cal, ratio, units): + miri_cal.meta.bunit_data = units + + # Make an input pixel scale equivalent to the specified ratio + input_scale = compute_spectral_pixel_scale(miri_cal.meta.wcs, disp_axis=2) + pscale = 3600.0 * input_scale / ratio + + result1 = ResampleSpecStep.call(miri_cal) + result2 = ResampleSpecStep.call(miri_cal, pixel_scale_ratio=ratio) + result3 = ResampleSpecStep.call(miri_cal, pixel_scale=pscale) + + # pixel_scale and pixel_scale_ratio should be equivalent + nn = np.isnan(result2.data) | np.isnan(result3.data) + assert np.allclose(result2.data[~nn], result3.data[~nn]) + + # Check result2 for expected results + + # wavelength size does not change + assert result1.data.shape[0] == result2.data.shape[0] + + # spatial dimension is scaled + assert np.isclose(result1.data.shape[1], result2.data.shape[1] / ratio, atol=1) + + # data is non-trivial + assert np.nansum(result1.data) > 0.0 + assert np.nansum(result2.data) > 0.0 + + # flux is conserved + if 'sr' not in units: + # flux density conservation: sum over pixels in each row + # needs to be about the same, other than the edges + # Check the maximum sums, to avoid edges. + assert np.allclose(np.max(np.nansum(result1.data, axis=1)), + np.max(np.nansum(result1.data, axis=1)), rtol=0.05) + else: + # surface brightness conservation: mean values are the same + assert np.allclose(np.nanmean(result1.data, axis=1), + np.nanmean(result2.data, axis=1), rtol=0.05, + equal_nan=True) + + # output area is updated either way + area1 = result1.meta.photometry.pixelarea_steradians + area2 = result2.meta.photometry.pixelarea_steradians + area3 = result2.meta.photometry.pixelarea_steradians + assert np.isclose(area1 / area2, ratio) + assert np.isclose(area1 / area3, ratio) + + assert result1.meta.resample.pixel_scale_ratio == 1.0 + assert result2.meta.resample.pixel_scale_ratio == ratio + assert np.isclose(result3.meta.resample.pixel_scale_ratio, ratio) + + result1.close() + result2.close() + result3.close() + + +@pytest.mark.parametrize("units", ["MJy", "MJy/sr"]) +@pytest.mark.parametrize("ratio", [0.7, 1.0, 1.3]) +def test_pixel_scale_ratio_spec_nirspec(nirspec_cal, ratio, units): + for slit in nirspec_cal.slits: + slit.meta.bunit_data = units + + # Make an input pixel scale equivalent to the specified ratio + input_scale = compute_spectral_pixel_scale( + nirspec_cal.slits[0].meta.wcs, disp_axis=1) + pscale = 3600.0 * input_scale / ratio + + result1 = ResampleSpecStep.call(nirspec_cal) + result2 = ResampleSpecStep.call(nirspec_cal, pixel_scale_ratio=ratio) + result3 = ResampleSpecStep.call(nirspec_cal, pixel_scale=pscale) + + for slit1, slit2, slit3 in zip(result1.slits, result2.slits, result3.slits): + # pixel_scale and pixel_scale_ratio should be equivalent + nn = np.isnan(slit2.data) | np.isnan(slit3.data) + assert np.allclose(slit2.data[~nn], slit3.data[~nn]) + + # Check result2 for expected results + + # wavelength size does not change + assert slit1.data.shape[1] == slit2.data.shape[1] + + # spatial dimension is scaled + assert np.isclose(slit1.data.shape[0], slit2.data.shape[0] / ratio, atol=1) + + # data is non-trivial + assert np.nansum(slit1.data) > 0.0 + assert np.nansum(slit2.data) > 0.0 + + # flux is conserved + if 'sr' not in units: + # flux density conservation: sum over pixels in each column + # needs to be about the same, other than edge effects. + # Check the maximum sums, to avoid edges. + assert np.allclose(np.max(np.nansum(slit1.data, axis=0)), + np.max(np.nansum(slit2.data, axis=0)), rtol=0.05) + else: + # surface brightness conservation: mean values are the same + assert np.allclose(np.nanmean(slit1.data, axis=0), + np.nanmean(slit2.data, axis=0), rtol=0.05, + equal_nan=True) + + # output area is updated either way + area1 = slit1.meta.photometry.pixelarea_steradians + area2 = slit2.meta.photometry.pixelarea_steradians + area3 = slit3.meta.photometry.pixelarea_steradians + assert np.isclose(area1 / area2, ratio) + assert np.isclose(area1 / area3, ratio) + + assert result1.meta.resample.pixel_scale_ratio == 1.0 + assert result2.meta.resample.pixel_scale_ratio == ratio + assert np.isclose(result3.meta.resample.pixel_scale_ratio, ratio) + + result1.close() + result2.close() + result3.close() + def test_weight_type(nircam_rate, tmp_cwd): """Check that weight_type of exptime and ivm work""" @@ -336,6 +593,13 @@ def test_weight_type(nircam_rate, tmp_cwd): assert_allclose(result3.data[100:105, 100:105], 6.667, rtol=1e-2) assert_allclose(result3.wht[100:105, 100:105], expectation_value * expected_ratio, rtol=1e-2) + im1.close() + im2.close() + im3.close() + result1.close() + result2.close() + result3.close() + def test_sip_coeffs_do_not_propagate(nircam_rate): im = AssignWcsStep.call(nircam_rate, sip_degree=2) @@ -357,70 +621,8 @@ def test_sip_coeffs_do_not_propagate(nircam_rate): # Make sure we have a PC matrix assert result.meta.wcsinfo.pc1_1 is not None - -@pytest.fixture -def miri_rate_zero_crossing(): - xsize = 1032 - ysize = 1024 - shape = (ysize, xsize) - im = ImageModel(shape) - im.var_rnoise = np.random.random(shape) - im.meta.wcsinfo = { - 'dec_ref': 2.16444343946559e-05, - 'ra_ref': -0.00026031780056776, - 'roll_ref': 0.0, - 'v2_ref': -415.0690466121227, - 'v3_ref': -400.575920398547, - 'v3yangle': 0.0, - 'vparity': -1} - im.meta.instrument = { - 'detector': 'MIRIMAGE', - 'filter': 'P750L', - 'name': 'MIRI'} - im.meta.observation = { - 'date': '2019-01-01', - 'time': '17:00:00'} - im.meta.subarray = { - 'fastaxis': 1, - 'name': 'FULL', - 'slowaxis': 2, - 'xsize': xsize, - 'xstart': 1, - 'ysize': ysize, - 'ystart': 1} - im.meta.exposure = { - 'duration': 11.805952, - 'end_time': 58119.85416, - 'exposure_time': 11.776, - 'frame_time': 0.11776, - 'group_time': 0.11776, - 'groupgap': 0, - 'integration_time': 11.776, - 'nframes': 1, - 'ngroups': 100, - 'nints': 1, - 'nresets_between_ints': 0, - 'nsamples': 1, - 'readpatt': 'FAST', - 'sample_time': 10.0, - 'start_time': 58119.8333, - 'type': 'MIR_LRS-FIXEDSLIT', - 'zero_frame': False} - - return im - - -@pytest.fixture -def miri_rate_pair(miri_rate_zero_crossing): - im1 = miri_rate_zero_crossing - # Create a nodded version - im2 = im1.copy() - im2.meta.wcsinfo.ra_ref = 0.00026308279776455 - im2.meta.wcsinfo.dec_ref = -2.1860888891293e-05 - im1 = AssignWcsStep.call(im1) - im2 = AssignWcsStep.call(im2) - - return im1, im2 + im.close() + result.close() def test_build_interpolated_output_wcs(miri_rate_pair): @@ -434,15 +636,58 @@ def test_build_interpolated_output_wcs(miri_rate_pair): grid = grid_from_bounding_box(im2.meta.wcs.bounding_box) ra, dec, lam = im2.meta.wcs(*grid) x, y = output_wcs.invert(ra, dec, lam) - - # This currently fails, as we see a slight offset - # assert (x > 0).all() + nn = ~(np.isnan(x) | np.isnan(y)) + assert np.sum(nn) > 0 + assert np.all(x[nn] > -1) # Make sure the output slit size is larger than the input slit size # for this nodded data assert output_wcs.array_shape[1] > ra.shape[1] +def test_build_nirspec_output_wcs(nirspec_cal_pair): + im1, im2 = nirspec_cal_pair + containers = multislit_to_container([im1, im2]) + driz = ResampleSpecData(containers['1']) + output_wcs = driz.build_nirspec_output_wcs() + + # Make sure that all slit values in the input images have a + # location in the output frame, in both RA/Dec and slit units + output_s2d = output_wcs.get_transform('slit_frame', 'detector') + for im in [im1, im2]: + grid = grid_from_bounding_box(im.slits[0].meta.wcs.bounding_box) + + # check slit values + input_d2s = im.slits[0].meta.wcs.get_transform('detector', 'slit_frame') + sx, sy, lam = input_d2s(*grid) + x, y = output_s2d(np.full_like(sy, 0), sy, lam * 1e6) + nn = ~(np.isnan(x) | np.isnan(y)) + assert np.sum(nn) > 0 + assert np.all(y[nn] > -1) + + # check RA, Dec, lam + ra, dec, lam = im.slits[0].meta.wcs(*grid) + x, y = output_wcs.invert(ra, dec, lam) + nn = ~(np.isnan(x) | np.isnan(y)) + assert np.sum(nn) > 0 + assert np.all(y[nn] > -1) + + # Make a WCS for each input individually + containers = multislit_to_container([im1]) + driz = ResampleSpecData(containers['1']) + compare_wcs_1 = driz.build_nirspec_output_wcs() + + containers = multislit_to_container([im2]) + driz = ResampleSpecData(containers['1']) + compare_wcs_2 = driz.build_nirspec_output_wcs() + + # The output shape should be the larger of the two + assert output_wcs.array_shape[0] == max( + compare_wcs_1.array_shape[0], compare_wcs_2.array_shape[0]) + assert output_wcs.array_shape[1] == max( + compare_wcs_1.array_shape[1], compare_wcs_2.array_shape[1]) + + def test_wcs_keywords(nircam_rate): """Make sure certain wcs keywords are removed after resample """ @@ -457,6 +702,9 @@ def test_wcs_keywords(nircam_rate): assert result.meta.wcsinfo.v3yangle is None assert result.meta.wcsinfo.vparity is None + im.close() + result.close() + @pytest.mark.parametrize("n_images,weight_type", [(1, 'ivm'), (2, 'ivm'), (3, 'ivm'), (9, 'ivm'), @@ -484,6 +732,9 @@ def test_resample_variance(nircam_rate, n_images, weight_type): assert_allclose(result.var_rnoise[5:-5, 5:-5].mean(), var_rnoise / n_images, atol=1e-7) assert_allclose(result.var_poisson[5:-5, 5:-5].mean(), var_poisson / n_images, atol=1e-7) + im.close() + result.close() + @pytest.mark.parametrize("shape", [(0, ), (10, 1)]) def test_resample_undefined_variance(nircam_rate, shape): @@ -504,6 +755,9 @@ def test_resample_undefined_variance(nircam_rate, shape): assert_allclose(result.var_poisson, np.nan) assert_allclose(result.var_flat, np.nan) + im.close() + result.close() + @pytest.mark.parametrize('ratio', [0.7, 1.2]) @pytest.mark.parametrize('rotation', [0, 15, 135]) @@ -541,6 +795,9 @@ def test_custom_wcs_resample_imaging(nircam_rate, ratio, rotation, crpix, crval, # test output image shape assert result.data.shape == shape[::-1] + im.close() + result.close() + @pytest.mark.parametrize( 'output_shape2, match', @@ -604,6 +861,105 @@ def test_custom_refwcs_resample_imaging(nircam_rate, output_shape2, match, assert np.isclose(input_mean * iscale**2, output_mean_1, atol=1e-4) assert np.isclose(input_mean * iscale**2, output_mean_2, atol=1e-4) + im.close() + result.close() + + +@pytest.mark.parametrize('ratio', [0.7, 1.0, 1.3]) +def test_custom_refwcs_resample_miri(miri_cal, tmp_path, ratio): + im = miri_cal + miri_cal.meta.bunit_data = "MJy" + + # mock a spectrum by giving the first slit some random + # values at the center + rng = np.random.default_rng(seed=77) + new_values = rng.random(im.data.shape) + + center = im.data.shape[1] // 2 + im.data[:] = 0.0 + im.data[:, center - 2:center + 2] = new_values[:, center - 2:center + 2] + + # first pass: create a reference output WCS with a custom pixel scale + result = ResampleSpecStep.call(im, pixel_scale_ratio=ratio) + + # make sure results are nontrivial + data1 = result.data + assert not np.all(np.isnan(data1)) + + # save the wcs from the output + refwcs = str(tmp_path / "resample_refwcs.asdf") + asdf.AsdfFile({"wcs": result.meta.wcs}).write_to(refwcs) + + # run again, this time using the created WCS as input + result = ResampleSpecStep.call(im, output_wcs=refwcs) + data2 = result.data + assert not np.all(np.isnan(data2)) + + # check output data against first pass + assert data1.shape == data2.shape + assert np.allclose(data1, data2, equal_nan=True, rtol=1e-4) + + # make sure flux is conserved: sum over spatial dimension + # should be same in input and output + # (assuming inputs are in flux density units) + input_sum = np.nanmean(np.nansum(im.data, axis=1)) + output_sum_1 = np.nanmean(np.nansum(data1, axis=1)) + output_sum_2 = np.nanmean(np.nansum(data2, axis=1)) + assert np.allclose(input_sum, output_sum_1, rtol=0.005) + assert np.allclose(input_sum, output_sum_2, rtol=0.005) + + im.close() + result.close() + + +@pytest.mark.parametrize('ratio', [0.7, 1.0, 1.3]) +def test_custom_refwcs_resample_nirspec(nirspec_cal, tmp_path, ratio): + im = nirspec_cal + for slit in im.slits: + slit.meta.bunit_data = "MJy" + + # mock a spectrum by giving the first slit some random + # values at the center + rng = np.random.default_rng(seed=77) + new_values = rng.random(im.slits[0].data.shape) + + center = im.slits[0].data.shape[0] // 2 + im.slits[0].data[:] = 0.0 + im.slits[0].data[center - 2:center + 2, :] = new_values[center - 2:center + 2, :] + + # first pass: create a reference output WCS with a custom pixel scale + result = ResampleSpecStep.call(im, pixel_scale_ratio=ratio) + + # make sure results are nontrivial + data1 = result.slits[0].data + assert not np.all(np.isnan(data1)) + + # save the wcs from the output + refwcs = str(tmp_path / "resample_refwcs.asdf") + asdf.AsdfFile({"wcs": result.slits[0].meta.wcs}).write_to(tmp_path / refwcs) + + # run again, this time using the created WCS as input + result = ResampleSpecStep.call(im, output_wcs=refwcs) + + data2 = result.slits[0].data + assert not np.all(np.isnan(data2)) + + # check output data against first pass + assert data1.shape == data2.shape + assert np.allclose(data1, data2, equal_nan=True, rtol=1e-4) + + # make sure flux is conserved: sum over spatial dimension + # should be same in input and output + # (assuming inputs are in flux density units) + input_sum = np.nanmean(np.nansum(im.slits[0].data, axis=0)) + output_sum_1 = np.nanmean(np.nansum(data1, axis=0)) + output_sum_2 = np.nanmean(np.nansum(data2, axis=0)) + assert np.allclose(input_sum, output_sum_1, rtol=0.005) + assert np.allclose(input_sum, output_sum_2, rtol=0.005) + + im.close() + result.close() + @pytest.mark.parametrize('ratio', [1.3, 1]) def test_custom_wcs_pscale_resample_imaging(nircam_rate, ratio): @@ -622,6 +978,46 @@ def test_custom_wcs_pscale_resample_imaging(nircam_rate, ratio): # test scales are close assert np.allclose(output_scale, input_scale * 0.75) + im.close() + result.close() + + +@pytest.mark.parametrize('ratio', [1.3, 1]) +def test_custom_wcs_pscale_resample_miri(miri_cal, ratio): + im = miri_cal + + # pass both ratio and direct scale: ratio is ignored in favor of scale + input_scale = compute_spectral_pixel_scale(im.meta.wcs, disp_axis=2) + result = ResampleSpecStep.call( + im, + pixel_scale_ratio=ratio, + pixel_scale=3600 * input_scale * 0.75 + ) + output_scale = compute_spectral_pixel_scale(result.meta.wcs, disp_axis=2) + + # test scales are close to scale specified, regardless of ratio + assert np.allclose(output_scale, input_scale * 0.75) + + result.close() + +@pytest.mark.parametrize('ratio', [1.3, 1]) +def test_custom_wcs_pscale_resample_nirspec(nirspec_cal, ratio): + im = nirspec_cal.slits[0] + + # pass both ratio and direct scale: ratio is ignored in favor of scale + input_scale = compute_spectral_pixel_scale(im.meta.wcs, disp_axis=1) + result = ResampleSpecStep.call( + nirspec_cal, + pixel_scale_ratio=ratio, + pixel_scale=3600 * input_scale * 0.75 + ) + output_scale = compute_spectral_pixel_scale(result.slits[0].meta.wcs, disp_axis=1) + + # test scales are close to scale specified, regardless of ratio + assert np.allclose(output_scale, input_scale * 0.75) + + result.close() + def test_pixscale(nircam_rate): @@ -643,6 +1039,9 @@ def test_pixscale(nircam_rate): res = ResampleStep.call(im, pixel_scale_ratio=0.7) assert res.meta.resample.pixel_scale_ratio == 0.7 + im.close() + res.close() + def test_phot_keywords(nircam_rate): # test that resample keywords agree with photometry keywords after step is run @@ -675,3 +1074,84 @@ def test_phot_keywords(nircam_rate): atol=0, rtol=1e-12 ) + + im.close() + res.close() + + +def test_missing_nominal_area(miri_cal, tmp_path): + # remove nominal pixel area + miri_cal.meta.photometry.pixelarea_steradians = None + miri_cal.meta.photometry.pixelarea_arcsecsq = None + + # result should still process okay + result = ResampleSpecStep.call(miri_cal) + + # no area keywords in output + assert result.meta.photometry.pixelarea_steradians is None + assert result.meta.photometry.pixelarea_arcsecsq is None + + # direct pixel scale setting is not supported + result2 = ResampleSpecStep.call(miri_cal, pixel_scale=0.5) + assert np.allclose(result2.data, result.data, equal_nan=True) + assert result2.meta.resample.pixel_scale_ratio == 1.0 + + # setting pixel_scale_ratio is still allowed, + # output area is still None + result3 = ResampleSpecStep.call(miri_cal, pixel_scale_ratio=0.5) + assert result3.data.shape[0] == result.data.shape[0] + assert result3.data.shape[1] < result.data.shape[1] + assert result3.meta.resample.pixel_scale_ratio == 0.5 + assert result3.meta.photometry.pixelarea_steradians is None + assert result3.meta.photometry.pixelarea_arcsecsq is None + + # specifying a custom WCS without nominal area sets output pixel + # scale ratio to 1, since direct scale cannot be computed + # save the wcs from the output + refwcs = str(tmp_path / "resample_refwcs.asdf") + asdf.AsdfFile({"wcs": result3.meta.wcs}).write_to(refwcs) + result4 = ResampleSpecStep.call(miri_cal, output_wcs=refwcs) + assert result4.data.shape == result3.data.shape + assert result4.meta.resample.pixel_scale_ratio == 1.0 + assert result4.meta.photometry.pixelarea_steradians is None + assert result4.meta.photometry.pixelarea_arcsecsq is None + + result.close() + result2.close() + result3.close() + result4.close() + + +def test_nirspec_lamp_pixscale(nirspec_lamp, tmp_path): + result = ResampleSpecStep.call(nirspec_lamp) + + # output data should have the same wavelength size, + # spatial size is close + assert np.isclose(result.slits[0].data.shape[0], + nirspec_lamp.slits[0].data.shape[0], atol=5) + assert (result.slits[0].data.shape[1] + == nirspec_lamp.slits[0].data.shape[1]) + + # test pixel scale setting: will not work without sky-based WCS + result2 = ResampleSpecStep.call(nirspec_lamp, pixel_scale=0.5) + assert np.allclose(result2.slits[0].data, result.slits[0].data, equal_nan=True) + assert result2.meta.resample.pixel_scale_ratio == 1.0 + + # setting pixel_scale_ratio is still allowed + result3 = ResampleSpecStep.call(nirspec_lamp, pixel_scale_ratio=0.5) + assert result3.slits[0].data.shape[0] < result.slits[0].data.shape[0] + assert result3.slits[0].data.shape[1] == result.slits[0].data.shape[1] + assert result3.meta.resample.pixel_scale_ratio == 0.5 + + # specifying a custom WCS should work, but output ratio is 1.0, + # since output scale cannot be determined + refwcs = str(tmp_path / "resample_refwcs.asdf") + asdf.AsdfFile({"wcs": result3.slits[0].meta.wcs}).write_to(refwcs) + result4 = ResampleSpecStep.call(nirspec_lamp, output_wcs=refwcs) + assert result4.slits[0].data.shape == result3.slits[0].data.shape + assert result4.meta.resample.pixel_scale_ratio == 1.0 + + result.close() + result2.close() + result3.close() + result4.close() diff --git a/jwst/resample/tests/test_utils.py b/jwst/resample/tests/test_utils.py index af41f9730b..7e7deb3070 100644 --- a/jwst/resample/tests/test_utils.py +++ b/jwst/resample/tests/test_utils.py @@ -15,6 +15,7 @@ build_mask, build_driz_weight, decode_context, + is_flux_density, reproject ) @@ -202,3 +203,11 @@ def test_reproject(wcs1, wcs2, offset, request): def test_reproject_with_garbage_input(): with pytest.raises(TypeError): reproject("foo", "bar") + + +@pytest.mark.parametrize('unit,result', + [('Jy', True), ('MJy', True), + ('MJy/sr', False), ('DN/s', False), + ('bad_unit', False), (None, False)]) +def test_is_flux_density(unit, result): + assert is_flux_density(unit) is result