From 9ce63b4728c2fb076fd9db51cbd8faae2e4d2053 Mon Sep 17 00:00:00 2001 From: Ned Molter Date: Mon, 1 Jul 2024 15:31:30 -0400 Subject: [PATCH 01/36] moved tweakreg into stcal --- src/stcal/alignment/resample_utils.py | 89 +++++ src/stcal/alignment/util.py | 205 +++++++++++- src/stcal/tweakreg/__init__.py | 0 src/stcal/tweakreg/astrometric_utils.py | 215 ++++++++++++ src/stcal/tweakreg/tweakreg.py | 422 ++++++++++++++++++++++++ src/stcal/tweakreg/utils.py | 38 +++ tests/data/mosaic_long_i2d_gwcs.asdf | Bin 0 -> 2738 bytes tests/data/nrcb1-wcs.asdf | 383 +++++++++++++++++++++ tests/test_alignment.py | 8 + tests/test_tweakreg.py | 287 ++++++++++++++++ 10 files changed, 1646 insertions(+), 1 deletion(-) create mode 100644 src/stcal/tweakreg/__init__.py create mode 100644 src/stcal/tweakreg/astrometric_utils.py create mode 100644 src/stcal/tweakreg/tweakreg.py create mode 100644 src/stcal/tweakreg/utils.py create mode 100644 tests/data/mosaic_long_i2d_gwcs.asdf create mode 100644 tests/data/nrcb1-wcs.asdf create mode 100644 tests/test_tweakreg.py diff --git a/src/stcal/alignment/resample_utils.py b/src/stcal/alignment/resample_utils.py index 9de7c305..2d1c654a 100644 --- a/src/stcal/alignment/resample_utils.py +++ b/src/stcal/alignment/resample_utils.py @@ -1,4 +1,5 @@ import logging +from copy import deepcopy import numpy as np from gwcs.wcstools import grid_from_bounding_box @@ -39,3 +40,91 @@ def calc_pixmap(in_wcs, out_wcs, shape=None): grid = grid_from_bounding_box(bb) transform_function = util.reproject(in_wcs, out_wcs) return np.dstack(transform_function(grid[0], grid[1])) + + +# is this allowed in stcal, since it operates on a datamodel? +# seems ok. jump step for example does use models +def make_output_wcs(input_models, ref_wcs=None, + pscale_ratio=None, pscale=None, rotation=None, shape=None, + crpix=None, crval=None): + """Generate output WCS here based on footprints of all input WCS objects. + + Parameters + ---------- + input_models : list of `DataModel objects` + Each datamodel must have a ~gwcs.WCS object. + + pscale_ratio : float, optional + Ratio of input to output pixel scale. Ignored when ``pscale`` + is provided. + + pscale : float, None, optional + Absolute pixel scale in degrees. When provided, overrides + ``pscale_ratio``. + + rotation : float, None, optional + Position angle of output image Y-axis relative to North. + A value of 0.0 would orient the final output image to be North up. + The default of `None` specifies that the images will not be rotated, + 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. + + shape : tuple of int, None, optional + Shape of the image (data array) using ``numpy.ndarray`` convention + (``ny`` first and ``nx`` second). This value will be assigned to + ``pixel_shape`` and ``array_shape`` properties of the returned + WCS object. + + crpix : tuple of float, None, optional + Position of the reference pixel in the image array. If ``crpix`` is not + specified, it will be set to the center of the bounding box of the + returned WCS object. + + crval : tuple of float, None, optional + Right ascension and declination of the reference pixel. Automatically + computed if not provided. + + Returns + ------- + output_wcs : object + WCS object, with defined domain, covering entire set of input frames + """ + if ref_wcs is None: + wcslist = [i.meta.wcs for i in input_models] + for w, i in zip(wcslist, input_models): + if w.bounding_box is None: + w.bounding_box = util.wcs_bbox_from_shape(i.data.shape) + naxes = wcslist[0].output_frame.naxes + + if naxes != 2: + msg = f"Output WCS needs 2 spatial axes \ + but the supplied WCS has {naxes} axes." + raise RuntimeError(msg) + + output_wcs = util.wcs_from_footprints( + input_models, + pscale_ratio=pscale_ratio, + pscale=pscale, + rotation=rotation, + shape=shape, + crpix=crpix, + crval=crval + ) + + else: + naxes = ref_wcs.output_frame.naxes + if naxes != 2: + msg = f"Output WCS needs 2 spatial axes \ + but the supplied WCS has {naxes} axes." + raise RuntimeError(msg) + output_wcs = deepcopy(ref_wcs) + if shape is not None: + output_wcs.array_shape = shape + + # Check that the output data shape has no zero length dimensions + if not np.prod(output_wcs.array_shape): + msg = f"Invalid output frame shape: {tuple(output_wcs.array_shape)}" + raise ValueError(msg) + + return output_wcs diff --git a/src/stcal/alignment/util.py b/src/stcal/alignment/util.py index f3e7b085..27fbc6ce 100644 --- a/src/stcal/alignment/util.py +++ b/src/stcal/alignment/util.py @@ -20,11 +20,14 @@ log = logging.getLogger(__name__) log.setLevel(logging.DEBUG) +_MAX_SIP_DEGREE = 6 __all__ = [ "compute_scale", "compute_fiducial", "calc_rotation_matrix", + "update_fits_wcsinfo", + "update_s_region_imaging", "wcs_from_footprints", "reproject", ] @@ -552,6 +555,200 @@ def calc_rotation_matrix(roll_ref: float, v3i_yangle: float, vparity: int = 1) - return [pc1_1, pc1_2, pc2_1, pc2_2] +def update_fits_wcsinfo(datamodel, max_pix_error=0.01, degree=None, + max_inv_pix_error=0.01, inv_degree=None, + npoints=12, crpix=None, projection='TAN', + imwcs=None, **kwargs): + """ + Update ``datamodel.meta.wcsinfo`` based on a FITS WCS + SIP approximation + of a GWCS object. By default, this function will approximate + the datamodel's GWCS object stored in ``datamodel.meta.wcs`` but it can + also approximate a user-supplied GWCS object when provided via + the ``imwcs`` parameter. + + The default mode in using this attempts to achieve roughly 0.01 pixel + accuracy over the entire image. + + This function uses the :py:meth:`~gwcs.wcs.WCS.to_fits_sip` to + create FITS WCS representations of GWCS objects. Only most important + :py:meth:`~gwcs.wcs.WCS.to_fits_sip` parameters are exposed here. Other + arguments to :py:meth:`~gwcs.wcs.WCS.to_fits_sip` can be passed via + ``kwargs`` - see "Other Parameters" section below. + Please refer to the documentation of :py:meth:`~gwcs.wcs.WCS.to_fits_sip` + for more details. + + .. warning:: + This function modifies input data model's ``datamodel.meta.wcsinfo`` + members. + + + Parameters + ---------- + + datamodel : `ImageModel` + The input data model for imaging or WFSS mode whose ``meta.wcsinfo`` + field should be updated from GWCS. By default, ``datamodel.meta.wcs`` + is used to compute FITS WCS + SIP approximation. When ``imwcs`` is + not `None` then computed FITS WCS will be an approximation of the WCS + provided through the ``imwcs`` parameter. + + max_pix_error : float, optional + Maximum allowed error over the domain of the pixel array. This + error is the equivalent pixel error that corresponds to the maximum + error in the output coordinate resulting from the fit based on + a nominal plate scale. + + degree : int, iterable, None, optional + Degree of the SIP polynomial. Default value `None` indicates that + all allowed degree values (``[1...6]``) will be considered and + the lowest degree that meets accuracy requerements set by + ``max_pix_error`` will be returned. Alternatively, ``degree`` can be + an iterable containing allowed values for the SIP polynomial degree. + This option is similar to default `None` but it allows caller to + restrict the range of allowed SIP degrees used for fitting. + Finally, ``degree`` can be an integer indicating the exact SIP degree + to be fit to the WCS transformation. In this case + ``max_pixel_error`` is ignored. + + max_inv_pix_error : float, None, optional + Maximum allowed inverse error over the domain of the pixel array + in pixel units. With the default value of `None` no inverse + is generated. + + inv_degree : int, iterable, None, optional + Degree of the SIP polynomial. Default value `None` indicates that + all allowed degree values (``[1...6]``) will be considered and + the lowest degree that meets accuracy requerements set by + ``max_pix_error`` will be returned. Alternatively, ``degree`` can be + an iterable containing allowed values for the SIP polynomial degree. + This option is similar to default `None` but it allows caller to + restrict the range of allowed SIP degrees used for fitting. + Finally, ``degree`` can be an integer indicating the exact SIP degree + to be fit to the WCS transformation. In this case + ``max_inv_pixel_error`` is ignored. + + npoints : int, optional + The number of points in each dimension to sample the bounding box + for use in the SIP fit. Minimum number of points is 3. + + crpix : list of float, None, optional + Coordinates (1-based) of the reference point for the new FITS WCS. + When not provided, i.e., when set to `None` (default) the reference + pixel already specified in ``wcsinfo`` will be re-used. If + ``wcsinfo`` does not contain ``crpix`` information, then the + reference pixel will be chosen near the center of the bounding box + for axes corresponding to the celestial frame. + + projection : str, `~astropy.modeling.projections.Pix2SkyProjection`, optional + Projection to be used for the created FITS WCS. It can be specified + as a string of three characters specifying a FITS projection code + from Table 13 in + `Representations of World Coordinates in FITS \ + `_ + (Paper I), Greisen, E. W., and Calabretta, M. R., A & A, 395, + 1061-1075, 2002. Alternatively, it can be an instance of one of the + `astropy's Pix2Sky_* `_ + projection models inherited from + :py:class:`~astropy.modeling.projections.Pix2SkyProjection`. + + imwcs : `gwcs.WCS`, None, optional + Imaging GWCS object for WFSS mode whose FITS WCS approximation should + be computed and stored in the ``datamodel.meta.wcsinfo`` field. + When ``imwcs`` is `None` then WCS from ``datamodel.meta.wcs`` + will be used. + + .. warning:: + + Used with WFSS modes only. For other modes, supplying a different + WCS from ``datamodel.meta.wcs`` will result in the GWCS and + FITS WCS descriptions to diverge. + + Other Parameters + ---------------- + bounding_box : tuple, None, optional + A pair of tuples, each consisting of two numbers + Represents the range of pixel values in both dimensions + ((xmin, xmax), (ymin, ymax)) + + verbose : bool, optional + Print progress of fits. + + Returns + ------- + FITS header with all SIP WCS keywords + + Raises + ------ + ValueError + If the WCS is not at least 2D, an exception will be raised. If the + specified accuracy (both forward and inverse, both rms and maximum) + is not achieved an exception will be raised. + + Notes + ----- + + Use of this requires a judicious choice of required accuracies. + Attempts to use higher degrees (~7 or higher) will typically fail due + to floating point problems that arise with high powers. + + For more details, see :py:meth:`~gwcs.wcs.WCS.to_fits_sip`. + + """ + if crpix is None: + crpix = [datamodel.meta.wcsinfo.crpix1, datamodel.meta.wcsinfo.crpix2] + if None in crpix: + crpix = None + + # For WFSS modes the imaging WCS is passed as an argument. + # For imaging modes it is retrieved from the datamodel. + if imwcs is None: + imwcs = datamodel.meta.wcs + + # make a copy of kwargs: + kwargs = {k: v for k, v in kwargs.items()} + + # limit default 'degree' ranges to _MAX_SIP_DEGREE: + if degree is None: + degree = range(1, _MAX_SIP_DEGREE) + if inv_degree is None: + inv_degree = range(1, _MAX_SIP_DEGREE) + + hdr = imwcs.to_fits_sip( + max_pix_error=max_pix_error, + degree=degree, + max_inv_pix_error=max_inv_pix_error, + inv_degree=inv_degree, + npoints=npoints, + crpix=crpix, + projection=projection, + **kwargs + ) + + # update meta.wcsinfo with FITS keywords except for naxis* + del hdr['naxis*'] + + # maintain convention of lowercase keys + hdr_dict = {k.lower(): v for k, v in hdr.items()} + + # delete naxis, cdelt, pc from wcsinfo + rm_keys = ['naxis', 'cdelt1', 'cdelt2', + 'pc1_1', 'pc1_2', 'pc2_1', 'pc2_2', + 'a_order', 'b_order', 'ap_order', 'bp_order'] + + rm_keys.extend(f"{s}_{i}_{j}" for i in range(10) for j in range(10) + for s in ['a', 'b', 'ap', 'bp']) + + for key in rm_keys: + if key in datamodel.meta.wcsinfo.instance: + del datamodel.meta.wcsinfo.instance[key] + + # update meta.wcs_info with fit keywords + datamodel.meta.wcsinfo.instance.update(hdr_dict) + + return hdr + + def wcs_from_footprints( dmodels, refmodel=None, @@ -563,6 +760,7 @@ def wcs_from_footprints( shape=None, crpix=None, crval=None, + wcs_list=None, ): """ Create a WCS from a list of input datamodels. @@ -629,13 +827,18 @@ def wcs_from_footprints( Right ascension and declination of the reference pixel. Automatically computed if not provided. + wcs_list : list + A list of WCS objects. If not supplied, the WCS objects are extracted + from the input datamodels. + Returns ------- wcs_new : ~gwcs.wcs.WCS The WCS object corresponding to the combined input footprints. """ - wcs_list = [im.meta.wcs for im in dmodels] + if wcs_list is None: + wcs_list = [im.meta.wcs for im in dmodels] _validate_wcs_list(wcs_list) diff --git a/src/stcal/tweakreg/__init__.py b/src/stcal/tweakreg/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/stcal/tweakreg/astrometric_utils.py b/src/stcal/tweakreg/astrometric_utils.py new file mode 100644 index 00000000..eb8d772f --- /dev/null +++ b/src/stcal/tweakreg/astrometric_utils.py @@ -0,0 +1,215 @@ +import os + +import requests +from astropy import table +from astropy import units as u +from astropy.coordinates import SkyCoord +from astropy.table import Table +from astropy.time import Time + +from stcal.alignment import compute_fiducial, resample_utils + +ASTROMETRIC_CAT_ENVVAR = "ASTROMETRIC_CATALOG_URL" +DEF_CAT_URL = "http://gsss.stsci.edu/webservices" + +SERVICELOCATION = os.environ.get(ASTROMETRIC_CAT_ENVVAR, DEF_CAT_URL) + +TIMEOUT = 30.0 # in seconds + +""" + +Primary function for creating an astrometric reference catalog. + +""" + + +__all__ = [ + "TIMEOUT", + "compute_radius", + "create_astrometric_catalog", + "get_catalog"] + + +def create_astrometric_catalog( + input_models, + catalog="GAIADR3", + output="ref_cat.ecsv", + gaia_only=False, + table_format="ascii.ecsv", + existing_wcs=None, + num_sources=None, + epoch=None): + """Create an astrometric catalog that covers the inputs' field-of-view. + + Parameters + ---------- + input_models : list of `~jwst.datamodel.JwstDataModel` + Each datamodel must have a ~gwcs.WCS object. + + catalog : str, optional + Name of catalog to extract astrometric positions for sources in the + input images' field-of-view. Default: GAIADR3. Options available are + documented on the catalog web page. + + output : str, optional + Filename to give to the astrometric catalog read in from the master + catalog web service. If None, no file will be written out. + + gaia_only : bool, optional + Specify whether or not to only use sources from GAIA in output catalog + + existing_wcs : model + existing WCS object specified by the user as generated by + `resample.resample_utils.make_output_wcs` + + num_sources : int + Maximum number of brightest/faintest sources to return in catalog. + If `num_sources` is negative, return that number of the faintest + sources. By default, all sources are returned. + + epoch : float, optional + Reference epoch used to update the coordinates for proper motion + (in decimal year). If `None` then the epoch is obtained from + the metadata. + + Notes + ----- + This function will point to astrometric catalog web service defined + through the use of the ASTROMETRIC_CATALOG_URL environment variable. + + Returns + ------- + ref_table : `~astropy.table.Table` + Astropy Table object of the catalog + """ + # start by creating a composite field-of-view for all inputs + # This default output WCS will have the same plate-scale and orientation + # as the first member in the list. + # Fortunately, for alignment, this doesn't matter since no resampling of + # data will be performed. + outwcs = existing_wcs if existing_wcs is not None \ + else resample_utils.make_output_wcs(input_models) + radius, fiducial = compute_radius(outwcs) + + # perform query for this field-of-view + epoch = ( + epoch + if epoch is not None + else Time(input_models[0].meta.observation.date).decimalyear + ) + ref_dict = get_catalog( + fiducial[0], + fiducial[1], + epoch=epoch, + sr=radius, + catalog=catalog) + if len(ref_dict) == 0: + return ref_dict + + colnames = ("ra", "dec", "mag", "objID", "epoch") + ref_table = ref_dict[colnames] + + # Add catalog name as meta data + ref_table.meta["catalog"] = catalog + ref_table.meta["gaia_only"] = gaia_only + + # rename coordinate columns to be consistent with tweakwcs + ref_table.rename_column("ra", "RA") + ref_table.rename_column("dec", "DEC") + + # Append GAIA ID as a new column to the table... + gaia_sources = [] + for source in ref_dict: + if "GAIAsourceID" in source: + g = source["GAIAsourceID"] + if gaia_only and g.strip() == "": + continue + else: + g = "-1" # indicator for no source ID extracted + gaia_sources.append(g) + + gaia_col = table.Column(data=gaia_sources, name="GaiaID", dtype="U25") + ref_table.add_column(gaia_col) + + # sort table by magnitude, fainter to brightest + ref_table.sort("mag", reverse=True) + + # If specified by the use through the 'num_sources' parameter, + # trim the returned catalog down to the brightest 'num_sources' sources + # Should 'num_sources' be a negative value, it will return the faintest + # 'num_sources' sources. + if num_sources is not None: + indx = -1 * num_sources + ref_table = ref_table[:indx] if num_sources < 0 else ref_table[indx:] + + # Write out table to a file, if specified + if output is not None: + ref_table.write(output, format=table_format, overwrite=True) + + return ref_table + + +""" + +Utility functions for creating an astrometric reference catalog. + +""" + + +def compute_radius(wcs): + """Compute the radius from the center to the furthest edge of the WCS.""" + fiducial = compute_fiducial([wcs], wcs.bounding_box) + img_center = SkyCoord( + ra=fiducial[0] * u.degree, + dec=fiducial[1] * u.degree) + wcs_foot = wcs.footprint() + img_corners = SkyCoord(ra=wcs_foot[:, 0] * u.degree, + dec=wcs_foot[:, 1] * u.degree) + radius = img_center.separation(img_corners).max().value + + return radius, fiducial + + +def get_catalog(ra, dec, epoch=2016.0, sr=0.1, catalog="GAIADR3"): + """Extract catalog from VO web service. + + Parameters + ---------- + ra : float + Right Ascension (RA) of center of field-of-view (in decimal degrees) + + dec : float + Declination (Dec) of center of field-of-view (in decimal degrees) + + epoch : float, optional + Reference epoch used to update the coordinates for proper motion + (in decimal year). Default: 2016.0 + + sr : float, optional + Search radius (in decimal degrees) from field-of-view center to use + for sources from catalog. Default: 0.1 degrees + + catalog : str, optional + Name of catalog to query, as defined by web-service. Default: 'GAIADR3' + + Returns + ------- + csv : CSV object + CSV object of returned sources with all columns as provided by catalog + + """ + service_type = "vo/CatalogSearch.aspx" + spec_str = "RA={}&DEC={}&EPOCH={}&SR={}&FORMAT={}&CAT={}&MINDET=5" + headers = {"Content-Type": "text/csv"} + fmt = "CSV" + + spec = spec_str.format(ra, dec, epoch, sr, fmt, catalog) + service_url = f"{SERVICELOCATION}/{service_type}?{spec}" + rawcat = requests.get(service_url, headers=headers, timeout=TIMEOUT) + r_contents = rawcat.content.decode() # convert from bytes to a String + rstr = r_contents.split("\r\n") + # remove initial line describing the number of sources returned + # CRITICAL to proper interpretation of CSV data + del rstr[0] + + return Table.read(rstr, format="csv") diff --git a/src/stcal/tweakreg/tweakreg.py b/src/stcal/tweakreg/tweakreg.py new file mode 100644 index 00000000..adf270d7 --- /dev/null +++ b/src/stcal/tweakreg/tweakreg.py @@ -0,0 +1,422 @@ +from __future__ import annotations + +import math +import warnings +from pathlib import Path + +import astropy.units as u +from astropy.coordinates import SkyCoord +from astropy.table import Table +from astropy.time import Time +from jwst.datamodels import ModelContainer +from stdatamodels import DataModel +from tweakwcs.correctors import JWSTWCSCorrector +from tweakwcs.imalign import align_wcs +from tweakwcs.matchutils import XYXYMatch + +from stcal.alignment import update_fits_wcsinfo, update_s_region_imaging, wcs_from_footprints + +from .astrometric_utils import create_astrometric_catalog + + +def _oxford_or_str_join(str_list): + nelem = len(str_list) + if not nelem: + return "N/A" + str_list = list(map(repr, str_list)) + if nelem == 1: + return str_list + if nelem == 2: + return str_list[0] + " or " + str_list[1] + return ", ".join(map(repr, str_list[:-1])) + ", or " + repr(str_list[-1]) + + +_SQRT2 = math.sqrt(2.0) +SINGLE_GROUP_REFCAT = ["GAIADR3", "GAIADR2", "GAIADR1"] +_SINGLE_GROUP_REFCAT_STR = _oxford_or_str_join(SINGLE_GROUP_REFCAT) + + +class TweakregError(BaseException): + pass + + +def tweakreg(images: ModelContainer, catalogs: list[Table], + ref_cat: Table = None, + abs_refcat: str | None = None, + save_abs_catalog: bool = False, + abs_catalog_output_dir: str | None = None, + searchrad: float = 2.0, + abs_searchrad: float = 6.0, + separation: float = 1.0, + abs_separation: float = 1.0, + use2dhist: bool = True, + abs_use2dhist: bool = True, + tolerance: float = 0.7, + abs_tolerance: float = 0.7, + xoffset: float = 0.0, + yoffset: float = 0.0, + enforce_user_order: bool = False, + expand_refcat: bool = False, + minobj: int = 15, + abs_minobj: int = 15, + fitgeometry: str = "rshift", + abs_fitgeometry: str = "rshift", + nclip: int = 3, + abs_nclip: int = 3, + abs_sigma: float = 3.0, + sigma: float = 3.0, + sip_approx: bool = True, + sip_max_pix_error: float = 0.01, + sip_degree: int | None = None, + sip_max_inv_pix_error: float = 0.01, + sip_inv_degree: int | None = None, + sip_npoints: int = 12,) -> ModelContainer: + """ + whatever. + + Parameters + ---------- + ref_cat: only required when align_to_abs_refcat is True + """ + # perform some input validations + if separation <= _SQRT2 * tolerance: + msg = "Parameter 'separation' must be larger than 'tolerance' by at \ + least a factor of sqrt(2) to avoid source confusion." + raise TweakregError(msg) + + if abs_separation <= _SQRT2 * abs_tolerance: + msg = "Parameter 'abs_separation' must be larger than 'abs_tolerance' by at \ + least a factor of sqrt(2) to avoid source confusion." + raise TweakregError(msg) + + if len(images) == 0: + msg = "Input must contain at least one image model." + raise ValueError(msg) + + if abs_refcat is None: + align_to_abs_refcat = False + + n_groups = len(images.group_names) + + # pre-allocate correctors (same length and order as images) + correctors = [None] * len(images) + for (model_index, image_model) in enumerate(images): + catalog = _filter_catalog_by_bounding_box( + catalogs[model_index], + image_model.meta.wcs.bounding_box + ) + corrector = _construct_wcs_corrector(image_model, catalog) + correctors[model_index] = corrector + + # relative alignment of images to each other (if more than one group) + if n_groups > 1: + + # align images: + xyxymatch = XYXYMatch( + searchrad=searchrad, + separation=separation, + use2dhist=use2dhist, + tolerance=tolerance, + xoffset=xoffset, + yoffset=yoffset + ) + + try: + align_wcs( + correctors, + refcat=None, + enforce_user_order=enforce_user_order, + expand_refcat=expand_refcat, + minobj=minobj, + match=xyxymatch, + fitgeom=fitgeometry, + nclip=nclip, + sigma=(sigma, "rmse") + ) + local_align_failed = False + + except ValueError as e: + msg = e.args[0] + if (msg == "Too few input images (or groups of images) with " + "non-empty catalogs."): + local_align_failed = True + if not align_to_abs_refcat: + msg += "At least two exposures are required for image alignment. Nothing to do." + raise TweakregError(msg) from None + else: + raise + + except RuntimeError as e: + msg = e.args[0] + if msg.startswith("Number of output coordinates exceeded allocation"): + # we need at least two exposures to perform image alignment + msg += "Multiple sources within specified tolerance \ + matched to a single reference source. Try to \ + adjust 'tolerance' and/or 'separation' parameters." + raise TweakregError(msg) from None + raise + + with warnings.catch_warnings(record=True) as w: + is_small = _is_wcs_correction_small(correctors, + use2dhist, + searchrad, + tolerance, + xoffset, + yoffset) + warning_msg = "".join([str(mess.message) for mess in w]) + if not local_align_failed and not is_small: + if align_to_abs_refcat: + warning_msg += " Skipping relative alignment (stage 1)..." + warnings.warn(warning_msg) + else: + raise TweakregError(warning_msg) + + # absolute alignment to the reference catalog + # can (and does) occur after alignment between groups + if align_to_abs_refcat: + + ref_cat = _parse_refcat(abs_refcat, + images, + correctors, + save_abs_catalog=save_abs_catalog, + output_dir=abs_catalog_output_dir) + + # Check that there are enough GAIA sources for a reliable/valid fit + num_ref = len(ref_cat) + if num_ref < abs_minobj: + msg = f"Not enough sources ({num_ref}) in the reference catalog \ + for the single-group alignment step to perform a fit. \ + Skipping alignment to the input reference catalog!" + raise TweakregError(msg) + + # align images: + # Update to separation needed to prevent confusion of sources + # from overlapping images where centering is not consistent or + # for the possibility that errors still exist in relative overlap. + xyxymatch_gaia = XYXYMatch( + searchrad=abs_searchrad, + separation=abs_separation, + use2dhist=abs_use2dhist, + tolerance=abs_tolerance, + xoffset=0.0, + yoffset=0.0 + ) + + # Set group_id to same value so all get fit as one observation + # The assigned value, 987654, has been hard-coded to make it + # easy to recognize when alignment to GAIA was being performed + # as opposed to the group_id values used for relative alignment + # earlier in this step. + for corrector in correctors: + corrector.meta["group_id"] = 987654 + if ("fit_info" in corrector.meta and + "REFERENCE" in corrector.meta["fit_info"]["status"]): + del corrector.meta["fit_info"] + + # Perform fit + try: + align_wcs( + correctors, + refcat=ref_cat, + enforce_user_order=True, + expand_refcat=False, + minobj=abs_minobj, + match=xyxymatch_gaia, + fitgeom=abs_fitgeometry, + nclip=abs_nclip, + sigma=(abs_sigma, "rmse") + ) + except ValueError as e: + msg = e.args[0] + if (msg == "Too few input images (or groups of images) with " + "non-empty catalogs."): + msg += "At least one exposure is required to align images \ + to an absolute reference catalog. Alignment to an \ + absolute reference catalog will not be performed." + if local_align_failed or n_groups == 1: + msg += " Nothing to do. Skipping 'TweakRegStep'..." + raise TweakregError(msg) from None + warnings.warn(msg) + else: + raise e + + except RuntimeError as e: + msg = e.args[0] + if msg.startswith("Number of output coordinates exceeded allocation"): + # we need at least two exposures to perform image alignment + msg += "Multiple sources within specified tolerance \ + matched to a single reference source. Try to \ + adjust 'tolerance' and/or 'separation' parameters. \ + Alignment to an absolute reference catalog will \ + not be performed." + if local_align_failed or n_groups == 1: + msg += "Skipping 'TweakRegStep'..." + raise TweakregError(msg) from None + else: + warnings.warn(msg) + else: + raise e + + # one final pass through all the models to update them based + # on the results of this step + for (image_model, corrector) in zip(images, correctors): + image_model.meta.cal_step.tweakreg = "COMPLETE" + + # retrieve fit status and update wcs if fit is successful: + if ("fit_info" in corrector.meta and + "SUCCESS" in corrector.meta["fit_info"]["status"]): + + # Update/create the WCS .name attribute with information + # on this astrometric fit as the only record that it was + # successful: + if align_to_abs_refcat: + # NOTE: This .name attrib agreed upon by the JWST Cal + # Working Group. + # Current value is merely a place-holder based + # on HST conventions. This value should also be + # translated to the FITS WCSNAME keyword + # IF that is what gets recorded in the archive + # for end-user searches. + corrector.wcs.name = f"FIT-LVL3-{abs_refcat}" + + image_model.meta.wcs = corrector.wcs + update_s_region_imaging(image_model) + + # Also update FITS representation in input exposures for + # subsequent reprocessing by the end-user. + if sip_approx: + try: + update_fits_wcsinfo( + image_model, + max_pix_error=sip_max_pix_error, + degree=sip_degree, + max_inv_pix_error=sip_max_inv_pix_error, + inv_degree=sip_inv_degree, + npoints=sip_npoints, + crpix=None + ) + except (ValueError, RuntimeError) as e: + msg = f"Failed to update 'meta.wcsinfo' with FITS SIP \ + approximation. Reported error is: \n {e.args[0]}" + warnings.warn(msg) + + return images + + +def _parse_refcat(abs_refcat: str, + images: ModelContainer, + correctors: list, + save_abs_catalog: bool = False, + output_dir: str | None = None) -> Table: + """ + Figure out if abs_refcat is an input filename or + the name of a GAIA catalog. If the former, load it, + and if the latter, retrieve that catalog from the Web. + If desired, save the reference catalog in the specified directory. + """ + if save_abs_catalog: + root = f"fit_{abs_refcat.lower()}_ref.ecsv" + output_name = Path(root) if output_dir is None \ + else Path(output_dir) / root + else: + output_name = None + + abs_refcat = abs_refcat.strip() + gaia_cat_name = abs_refcat.upper() + if gaia_cat_name in SINGLE_GROUP_REFCAT: + ref_model = images[0] + + epoch = Time(ref_model.meta.observation.date).decimalyear + + # combine all aligned wcs to compute a new footprint to + # filter the absolute catalog sources + combined_wcs = wcs_from_footprints( + None, + refmodel=ref_model, + wcs_list=[corrector.wcs for corrector in correctors], + ) + + return create_astrometric_catalog( + None, + gaia_cat_name, + existing_wcs=combined_wcs, + output=output_name, + epoch=epoch, + ) + + if Path.isfile(abs_refcat): + return Table.read(abs_refcat) + + msg = f"Invalid 'abs_refcat' value: {abs_refcat}. 'abs_refcat' must be \ + a path to an existing file name or one of the supported \ + reference catalogs: {_SINGLE_GROUP_REFCAT_STR}." + raise ValueError(msg) + + +def _is_wcs_correction_small(correctors, + use2dhist=True, + searchrad=2.0, + tolerance=0.7, + xoffset=0.0, + yoffset=0.0): + # check for a small wcs correction, it should be small + if use2dhist: + max_corr = 2 * (searchrad + tolerance) * u.arcsec + else: + max_corr = 2 * (max(abs(xoffset), abs(yoffset)) + + tolerance) * u.arcsec + for corrector in correctors: + aligned_skycoord = _wcs_to_skycoord(corrector.wcs) + original_skycoord = corrector.meta["original_skycoord"] + separation = original_skycoord.separation(aligned_skycoord) + if not (separation < max_corr).all(): + # Large corrections are typically a result of source + # mis-matching or poorly-conditioned fit. Skip such models. + msg = f"WCS has been tweaked by more than {10 * tolerance} arcsec" + warnings.warn(msg) + return False + return True + + +def _wcs_to_skycoord(wcs): + ra, dec = wcs.footprint(axis_type="spatial").T + return SkyCoord(ra=ra, dec=dec, unit="deg") + + +def _filter_catalog_by_bounding_box(catalog: Table, bounding_box: list[float]) -> Table: + """ + Given a catalog of x,y positions, only return sources that fall + inside the bounding box. + """ + if bounding_box is None: + return catalog + + # filter out sources outside the WCS bounding box + ((xmin, xmax), (ymin, ymax)) = bounding_box + x = catalog["x"] + y = catalog["y"] + mask = (x > xmin) & (x < xmax) & (y > ymin) & (y < ymax) + return catalog[mask] + + +def _construct_wcs_corrector(image_model: DataModel, + catalog: Table) -> JWSTWCSCorrector: + """ + pre-compute skycoord here so we can later use it + to check for a small wcs correction. + """ + wcs = image_model.meta.wcs + refang = image_model.meta.wcsinfo.instance + return JWSTWCSCorrector( + wcs=image_model.meta.wcs, + wcsinfo={"roll_ref": refang["roll_ref"], + "v2_ref": refang["v2_ref"], + "v3_ref": refang["v3_ref"]}, + # catalog and group_id are required meta + meta={ + "catalog": catalog, + "name": catalog.meta.get("name"), + "group_id": image_model.meta.group_id, + "original_skycoord": _wcs_to_skycoord(wcs), + } + ) diff --git a/src/stcal/tweakreg/utils.py b/src/stcal/tweakreg/utils.py new file mode 100644 index 00000000..feb6d527 --- /dev/null +++ b/src/stcal/tweakreg/utils.py @@ -0,0 +1,38 @@ +from astropy.modeling.rotations import RotationSequence3D + +__all__ = ["_wcsinfo_from_wcs_transform"] + + +def _wcsinfo_from_wcs_transform(wcs): + frames = wcs.available_frames + if "v2v3" not in frames or "world" not in frames or frames[-1] != "world": + msg = "Unsupported WCS structure." + raise ValueError(msg) + + # Initially get v2_ref, v3_ref, and roll_ref from + # the v2v3 to world transform. Also get ra_ref, dec_ref + t = wcs.get_transform(frames[-2], "world") + for m in t: + if isinstance(m, RotationSequence3D) and m.parameters.size == 5: + v2_ref, nv3_ref, roll_ref, dec_ref, nra_ref = m.angles.value + break + else: + msg = "Unsupported WCS structure." + raise ValueError(msg) + + # overwrite v2_ref, v3_ref, and roll_ref with + # values from the tangent plane when available: + if "v2v3corr" in frames: + # get v2_ref, v3_ref, and roll_ref from + # the v2v3 to v2v3corr transform: + frm1 = "v2v3vacorr" if "v2v3vacorr" in frames else "v2v3" + tpcorr = wcs.get_transform(frm1, "v2v3corr") + v2_ref, nv3_ref, roll_ref = tpcorr["det_to_optic_axis"].angles.value + + return { + "v2_ref": 3600 * v2_ref, + "v3_ref": -3600 * nv3_ref, + "roll_ref": roll_ref, + "ra_ref": -nra_ref, + "dec_ref": dec_ref + } diff --git a/tests/data/mosaic_long_i2d_gwcs.asdf b/tests/data/mosaic_long_i2d_gwcs.asdf new file mode 100644 index 0000000000000000000000000000000000000000..7cac94506fe0930e34d83324dbb20abad133d723 GIT binary patch literal 2738 zcmcgu&5s*36t9rDL_$L1!qKZ$6%L8#E6z*?RFv(Ow(L?6Sp+DmM$U{Uv6zgfw$n|t z+W&zQ2gH>t|3W3WbAS_fB#s;rCwQKZY?2MyQWY#E_W1ev{hr@@Kl^YvI(`p1*v5A2 z(2S$e+3?Zv@Z)2c?7`&i&xaqJ!q{ouJ{#Ug2T0RtsI-bXX2~L?DoL$Yx7$Srv5?F% z-(6S@nr0Yfd?Kk_g|%3TRG(AHUZO}U;eCO8&zOBsFLsFx_(ofbklor8iw(m_v|R9xgCaxuZ~ zWoyQj761*Fv886Y5i0Z5__Cgj=1kLsYFfdDB^xSHoKdBW5iLcr!d&1vS1~q5@p=n9 zti<;kOXVibafIliL0WFI0k#c6XkY%vsD^;`|3|iUK1U0lX`bJRVqYX*G-T8BSZ##p z{-<|GH`q5C8^N)$<6--<1+$Rh?`)TbS!=pnDwVYgSxP>B*DHIj_wvd1)+gfGpuF$I) zPc>+`h8;wjDyBo!b!;!dec}!VzCRdveL_q{R1K>2In)VuNI(KVa2$^~jrNxAzJt37 z?tYy;T03siH08ND8Sr!MiScJnwdBi&F7t#+NmsDW@N?~O-6^qtu-J%jrZ%G7tTAqnE!q~|&P@^HYRf5C5i*T&{v*PLci$%0ii7(O+PE}4o9u>u2+ zGm&=?WN4EarLa!+F>u|Qy6BRtsF7t3$BIm$_;{(%M=C3*i)K1v6-9h{3AGa{Zt)GuhPj1<_wi~KH*ZtS}BSznz{P5OS zU;gssmG58uF8W0N@z?Lqezfw}zG2gKU(dh0ch}eJUDy5lzrE4__2kcItDhG5@WGqk n*79{%zB$p+>4$eu(8EW^_deTraKfvy)kQAxT+{WPp!DW%Z`2cJ literal 0 HcmV?d00001 diff --git a/tests/data/nrcb1-wcs.asdf b/tests/data/nrcb1-wcs.asdf new file mode 100644 index 00000000..0588294e --- /dev/null +++ b/tests/data/nrcb1-wcs.asdf @@ -0,0 +1,383 @@ +#ASDF 1.0.0 +#ASDF_STANDARD 1.5.0 +%YAML 1.1 +%TAG ! tag:stsci.edu:asdf/ +--- !core/asdf-1.1.0 +asdf_library: !core/software-1.0.0 {author: The ASDF Developers, homepage: 'http://github.com/asdf-format/asdf', + name: asdf, version: 2.14.1} +history: + extensions: + - !core/extension_metadata-1.0.0 + extension_class: asdf.extension._manifest.ManifestExtension + extension_uri: asdf://asdf-format.org/transform/extensions/transform-1.5.0 + software: !core/software-1.0.0 {name: asdf-astropy, version: 0.3.0} + - !core/extension_metadata-1.0.0 + extension_class: asdf.extension.BuiltinExtension + software: !core/software-1.0.0 {name: asdf, version: 2.14.1} + - !core/extension_metadata-1.0.0 + extension_class: asdf.extension._manifest.ManifestExtension + extension_uri: asdf://asdf-format.org/astronomy/gwcs/extensions/gwcs-1.0.0 + software: !core/software-1.0.0 {name: gwcs, version: 0.18.3} + - !core/extension_metadata-1.0.0 + extension_class: asdf.extension._manifest.ManifestExtension + extension_uri: asdf://asdf-format.org/core/extensions/core-1.5.0 + software: !core/software-1.0.0 {name: asdf-astropy, version: 0.3.0} + - !core/extension_metadata-1.0.0 + extension_class: asdf.extension._manifest.ManifestExtension + extension_uri: asdf://asdf-format.org/astronomy/coordinates/extensions/coordinates-1.0.0 + software: !core/software-1.0.0 {name: asdf-astropy, version: 0.3.0} +wcs: ! + name: '' + steps: + - ! + frame: ! + axes_names: [x, y] + axes_order: [0, 1] + axis_physical_types: ['custom:x', 'custom:y'] + name: detector + unit: [!unit/unit-1.0.0 pixel, !unit/unit-1.0.0 pixel] + transform: !transform/compose-1.2.0 + bounding_box: !transform/property/bounding_box-1.0.0 + ignore: [] + intervals: + x0: [-0.5, 2047.5] + x1: [-0.5, 2047.5] + order: C + forward: + - !transform/concatenate-1.2.0 + forward: + - !transform/shift-1.2.0 + inputs: [x] + offset: 1.016 + outputs: [y] + - !transform/shift-1.2.0 + inputs: [x] + offset: 1.625 + outputs: [y] + inputs: [x0, x1] + outputs: [y0, y1] + - !transform/compose-1.2.0 + forward: + - !transform/compose-1.2.0 + forward: + - !transform/compose-1.2.0 + forward: + - !transform/concatenate-1.2.0 + forward: + - &id001 !transform/shift-1.2.0 + inputs: [x] + offset: 1.0 + outputs: [y] + - *id001 + inputs: [x0, x1] + outputs: [y0, y1] + - !transform/concatenate-1.2.0 + forward: + - !transform/shift-1.2.0 + inputs: [x] + offset: -1024.5 + outputs: [y] + - !transform/shift-1.2.0 + inputs: [x] + offset: -1024.5 + outputs: [y] + inputs: [x0, x1] + outputs: [y0, y1] + inputs: [x0, x1] + outputs: [y0, y1] + - !transform/compose-1.2.0 + forward: + - !transform/compose-1.2.0 + forward: + - !transform/remap_axes-1.3.0 + inputs: [x0, x1] + mapping: [0, 1, 0, 1] + outputs: [x0, x1, x2, x3] + - !transform/concatenate-1.2.0 + forward: + - !transform/polynomial-1.2.0 + coefficients: !core/ndarray-1.0.0 + source: 0 + datatype: float64 + byteorder: little + shape: [6, 6] + domain: + - [-1, 1] + - [-1, 1] + inputs: [x, y] + outputs: [z] + window: + - [-1, 1] + - [-1, 1] + - !transform/polynomial-1.2.0 + coefficients: !core/ndarray-1.0.0 + source: 1 + datatype: float64 + byteorder: little + shape: [6, 6] + domain: + - [-1, 1] + - [-1, 1] + inputs: [x, y] + outputs: [z] + window: + - [-1, 1] + - [-1, 1] + inputs: [x0, y0, x1, y1] + outputs: [z0, z1] + inputs: [x0, x1] + inverse: !transform/compose-1.2.0 + forward: + - !transform/remap_axes-1.3.0 + inputs: [x0, x1] + mapping: [0, 1, 0, 1] + outputs: [x0, x1, x2, x3] + - !transform/concatenate-1.2.0 + forward: + - !transform/polynomial-1.2.0 + coefficients: !core/ndarray-1.0.0 + source: 2 + datatype: float64 + byteorder: little + shape: [6, 6] + domain: + - [-1, 1] + - [-1, 1] + inputs: [x, y] + outputs: [z] + window: + - [-1, 1] + - [-1, 1] + - !transform/polynomial-1.2.0 + coefficients: !core/ndarray-1.0.0 + source: 3 + datatype: float64 + byteorder: little + shape: [6, 6] + domain: + - [-1, 1] + - [-1, 1] + inputs: [x, y] + outputs: [z] + window: + - [-1, 1] + - [-1, 1] + inputs: [x0, y0, x1, y1] + outputs: [z0, z1] + inputs: [x0, x1] + outputs: [z0, z1] + outputs: [z0, z1] + - !transform/compose-1.2.0 + forward: + - !transform/remap_axes-1.3.0 + inputs: [x0, x1] + mapping: [0, 1, 0, 1] + outputs: [x0, x1, x2, x3] + - !transform/concatenate-1.2.0 + forward: + - !transform/polynomial-1.2.0 + coefficients: !core/ndarray-1.0.0 + source: 4 + datatype: float64 + byteorder: little + shape: [2, 2] + domain: + - [-1, 1] + - [-1, 1] + inputs: [x, y] + outputs: [z] + window: + - [-1, 1] + - [-1, 1] + - !transform/polynomial-1.2.0 + coefficients: !core/ndarray-1.0.0 + source: 5 + datatype: float64 + byteorder: little + shape: [2, 2] + domain: + - [-1, 1] + - [-1, 1] + inputs: [x, y] + outputs: [z] + window: + - [-1, 1] + - [-1, 1] + inputs: [x0, y0, x1, y1] + outputs: [z0, z1] + inputs: [x0, x1] + inverse: !transform/compose-1.2.0 + forward: + - !transform/remap_axes-1.3.0 + inputs: [x0, x1] + mapping: [0, 1, 0, 1] + outputs: [x0, x1, x2, x3] + - !transform/concatenate-1.2.0 + forward: + - !transform/polynomial-1.2.0 + coefficients: !core/ndarray-1.0.0 + source: 6 + datatype: float64 + byteorder: little + shape: [2, 2] + domain: + - [-1, 1] + - [-1, 1] + inputs: [x, y] + outputs: [z] + window: + - [-1, 1] + - [-1, 1] + - !transform/polynomial-1.2.0 + coefficients: !core/ndarray-1.0.0 + source: 7 + datatype: float64 + byteorder: little + shape: [2, 2] + domain: + - [-1, 1] + - [-1, 1] + inputs: [x, y] + outputs: [z] + window: + - [-1, 1] + - [-1, 1] + inputs: [x0, y0, x1, y1] + outputs: [z0, z1] + inputs: [x0, x1] + outputs: [z0, z1] + outputs: [z0, z1] + inputs: [x0, x1] + outputs: [z0, z1] + inputs: [x0, x1] + outputs: [z0, z1] + - !transform/concatenate-1.2.0 + forward: + - !transform/shift-1.2.0 + inputs: [x] + offset: -122.37890283 + outputs: [y] + - !transform/shift-1.2.0 + inputs: [x] + offset: -457.7308175 + outputs: [y] + inputs: [x0, x1] + outputs: [y0, y1] + inputs: [x0, x1] + outputs: [y0, y1] + inputs: [x0, x1] + outputs: [y0, y1] + - ! + frame: ! + axes_names: [v2, v3] + axes_order: [0, 1] + axis_physical_types: ['custom:v2', 'custom:v3'] + name: v2v3 + unit: [!unit/unit-1.0.0 arcsec, !unit/unit-1.0.0 arcsec] + transform: !transform/compose-1.2.0 + forward: + - !transform/concatenate-1.2.0 + forward: + - !transform/scale-1.2.0 + factor: 0.9999940844823835 + inputs: [x] + name: dva_scale_v2 + outputs: [y] + - !transform/scale-1.2.0 + factor: 0.9999940844823835 + inputs: [x] + name: dva_scale_v3 + outputs: [y] + inputs: [x0, x1] + outputs: [y0, y1] + - !transform/concatenate-1.2.0 + forward: + - !transform/shift-1.2.0 + inputs: [x] + name: dva_v2_shift + offset: -0.000725459878720835 + outputs: [y] + - !transform/shift-1.2.0 + inputs: [x] + name: dva_v3_shift + offset: -0.002707956537951509 + outputs: [y] + inputs: [x0, x1] + outputs: [y0, y1] + inputs: [x0, x1] + name: DVA_Correction + outputs: [y0, y1] + - ! + frame: ! + axes_names: [v2, v3] + axes_order: [0, 1] + axis_physical_types: ['custom:v2', 'custom:v3'] + name: v2v3vacorr + unit: [!unit/unit-1.0.0 arcsec, !unit/unit-1.0.0 arcsec] + transform: !transform/compose-1.2.0 + forward: + - !transform/compose-1.2.0 + forward: + - !transform/compose-1.2.0 + forward: + - !transform/concatenate-1.2.0 + forward: + - !transform/scale-1.2.0 + factor: 0.0002777777777777778 + inputs: [x] + outputs: [y] + - !transform/scale-1.2.0 + factor: 0.0002777777777777778 + inputs: [x] + outputs: [y] + inputs: [x0, x1] + outputs: [y0, y1] + - ! + inputs: [lon, lat] + outputs: [x, y, z] + transform_type: spherical_to_cartesian + wrap_lon_at: 180 + inputs: [x0, x1] + outputs: [x, y, z] + - !transform/rotate_sequence_3d-1.0.0 + angles: [-0.034065765, 0.12715880472222224, 144.7989166584132, -73.45620084868634, + -110.8732066925156] + axes_order: zyxyz + inputs: [x, y, z] + outputs: [x, y, z] + rotation_type: cartesian + inputs: [x0, x1] + outputs: [x, y, z] + - ! + inputs: [x, y, z] + outputs: [lon, lat] + transform_type: cartesian_to_spherical + wrap_lon_at: 360 + inputs: [x0, x1] + name: v23tosky + outputs: [lon, lat] + - ! + frame: ! + axes_names: [lon, lat] + axes_order: [0, 1] + axis_physical_types: [pos.eq.ra, pos.eq.dec] + name: world + reference_frame: ! + frame_attributes: {} + unit: [!unit/unit-1.0.0 deg, !unit/unit-1.0.0 deg] + transform: null +... +BLK0   Rq1Ve#.H$I;|tc>lGk-N<1Kk;xo?ܜ;؅[=&'ը<B +9$|->`+d=-}|<W'<e<=n\Z).9<hH̯< a m0OqOBLK0   SnE*듰bnv?St %=[X7}{oK8T ?kyc#Lsn=xJ<3m6w>0pOϣ=SN-<%<<L2 +z=%6<8!yBZɰ:ԬsH<Tz;8BLK0   'b4EA6NI$I2<(SÄo݈"Cl%xؽ|dI@@Jv?]辈`lQQ^l:t>ɂ:jx$S*r(9JHy뇎 C[ZᄆrQK>5ɌgeSd;> g1>BLK0   FP@Wϭ3] +Jq<@@`'De?JȘK㾕Gxcgt=,]͑T>T?z5Ȇ" ת5Q~ =q'aXhK_OGa*Y墾7}}dHT/=}OB"Mj8>BLK0 :9Sӽ7Ӓ*x?BBLK0 g@R 1E-5 + else: + assert abs_delta < 1E-12 + + +@pytest.mark.parametrize("with_shift", [True, False]) +def test_sip_approx(example_input, with_shift): + """ + Test the output FITS WCS. + """ + if with_shift: + # shift 9 pixels so that the sources in one of the 2 images + # appear at different locations (resulting in a correct wcs update) + example_input[1].data[:-9] = example_input[1].data[9:] + example_input[1].data[-9:] = BKG_LEVEL + + # assign images to different groups (so they are aligned to each other) + example_input[0].meta.group_id = "a" + example_input[1].meta.group_id = "b" + + # create source catalogs + source_catalogs = [make_source_catalog(m.data) for m in example_input] + + # run the step on the example input modified above + result = tweakreg(example_input, source_catalogs, + sip_approx=True, sip_degree=3, sip_max_pix_error=0.1, + sip_max_inv_pix_error=0.1, sip_inv_degree=3, + sip_npoints=12) + + # output wcs differs by a small amount due to the shift above: + # project one point through each wcs and compare the difference + abs_delta = abs(result[1].meta.wcs(0, 0)[0] - result[0].meta.wcs(0, 0)[0]) + if with_shift: + assert abs_delta > 1E-5 + else: + assert abs_delta < 1E-12 + + # the first wcs is identical to the input and + # does not have SIP approximation keywords -- + # they are normally set by assign_wcs + assert np.allclose(result[0].meta.wcs(0, 0)[0], example_input[0].meta.wcs(0, 0)[0]) + for key in ["ap_order", "bp_order"]: + assert key not in result[0].meta.wcsinfo.instance + + # for the second, SIP approximation should be present + for key in ["ap_order", "bp_order"]: + assert result[1].meta.wcsinfo.instance[key] == 3 + + # evaluate fits wcs and gwcs for the approximation, make sure they agree + wcs_info = result[1].meta.wcsinfo.instance + grid = grid_from_bounding_box(result[1].meta.wcs.bounding_box) + gwcs_ra, gwcs_dec = result[1].meta.wcs(*grid) + fits_wcs = WCS(wcs_info) + fitswcs_res = fits_wcs.pixel_to_world(*grid) + + assert np.allclose(fitswcs_res.ra.deg, gwcs_ra) + assert np.allclose(fitswcs_res.dec.deg, gwcs_dec) From a4e86c04a08e359945f833ab909ce1434d6c32d4 Mon Sep 17 00:00:00 2001 From: Ned Molter Date: Tue, 2 Jul 2024 16:41:57 -0400 Subject: [PATCH 02/36] remove dependence on ModelContainer, operate on one model at a time --- src/stcal/alignment/resample_utils.py | 170 +++++---- src/stcal/alignment/util.py | 2 - src/stcal/tweakreg/astrometric_utils.py | 44 +-- src/stcal/tweakreg/tweakreg.py | 471 ++++++++++++------------ tests/test_tweakreg.py | 99 ++--- 5 files changed, 360 insertions(+), 426 deletions(-) diff --git a/src/stcal/alignment/resample_utils.py b/src/stcal/alignment/resample_utils.py index 2d1c654a..80ef0cc4 100644 --- a/src/stcal/alignment/resample_utils.py +++ b/src/stcal/alignment/resample_utils.py @@ -42,89 +42,87 @@ def calc_pixmap(in_wcs, out_wcs, shape=None): return np.dstack(transform_function(grid[0], grid[1])) -# is this allowed in stcal, since it operates on a datamodel? -# seems ok. jump step for example does use models -def make_output_wcs(input_models, ref_wcs=None, - pscale_ratio=None, pscale=None, rotation=None, shape=None, - crpix=None, crval=None): - """Generate output WCS here based on footprints of all input WCS objects. - - Parameters - ---------- - input_models : list of `DataModel objects` - Each datamodel must have a ~gwcs.WCS object. - - pscale_ratio : float, optional - Ratio of input to output pixel scale. Ignored when ``pscale`` - is provided. - - pscale : float, None, optional - Absolute pixel scale in degrees. When provided, overrides - ``pscale_ratio``. - - rotation : float, None, optional - Position angle of output image Y-axis relative to North. - A value of 0.0 would orient the final output image to be North up. - The default of `None` specifies that the images will not be rotated, - 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. - - shape : tuple of int, None, optional - Shape of the image (data array) using ``numpy.ndarray`` convention - (``ny`` first and ``nx`` second). This value will be assigned to - ``pixel_shape`` and ``array_shape`` properties of the returned - WCS object. - - crpix : tuple of float, None, optional - Position of the reference pixel in the image array. If ``crpix`` is not - specified, it will be set to the center of the bounding box of the - returned WCS object. - - crval : tuple of float, None, optional - Right ascension and declination of the reference pixel. Automatically - computed if not provided. - - Returns - ------- - output_wcs : object - WCS object, with defined domain, covering entire set of input frames - """ - if ref_wcs is None: - wcslist = [i.meta.wcs for i in input_models] - for w, i in zip(wcslist, input_models): - if w.bounding_box is None: - w.bounding_box = util.wcs_bbox_from_shape(i.data.shape) - naxes = wcslist[0].output_frame.naxes - - if naxes != 2: - msg = f"Output WCS needs 2 spatial axes \ - but the supplied WCS has {naxes} axes." - raise RuntimeError(msg) - - output_wcs = util.wcs_from_footprints( - input_models, - pscale_ratio=pscale_ratio, - pscale=pscale, - rotation=rotation, - shape=shape, - crpix=crpix, - crval=crval - ) - - else: - naxes = ref_wcs.output_frame.naxes - if naxes != 2: - msg = f"Output WCS needs 2 spatial axes \ - but the supplied WCS has {naxes} axes." - raise RuntimeError(msg) - output_wcs = deepcopy(ref_wcs) - if shape is not None: - output_wcs.array_shape = shape - - # Check that the output data shape has no zero length dimensions - if not np.prod(output_wcs.array_shape): - msg = f"Invalid output frame shape: {tuple(output_wcs.array_shape)}" - raise ValueError(msg) - - return output_wcs +# def make_output_wcs(input_models, ref_wcs=None, +# pscale_ratio=None, pscale=None, rotation=None, shape=None, +# crpix=None, crval=None): +# """Generate output WCS here based on footprints of all input WCS objects. + +# Parameters +# ---------- +# input_models : list of `DataModel objects` +# Each datamodel must have a ~gwcs.WCS object. + +# pscale_ratio : float, optional +# Ratio of input to output pixel scale. Ignored when ``pscale`` +# is provided. + +# pscale : float, None, optional +# Absolute pixel scale in degrees. When provided, overrides +# ``pscale_ratio``. + +# rotation : float, None, optional +# Position angle of output image Y-axis relative to North. +# A value of 0.0 would orient the final output image to be North up. +# The default of `None` specifies that the images will not be rotated, +# 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. + +# shape : tuple of int, None, optional +# Shape of the image (data array) using ``numpy.ndarray`` convention +# (``ny`` first and ``nx`` second). This value will be assigned to +# ``pixel_shape`` and ``array_shape`` properties of the returned +# WCS object. + +# crpix : tuple of float, None, optional +# Position of the reference pixel in the image array. If ``crpix`` is not +# specified, it will be set to the center of the bounding box of the +# returned WCS object. + +# crval : tuple of float, None, optional +# Right ascension and declination of the reference pixel. Automatically +# computed if not provided. + +# Returns +# ------- +# output_wcs : object +# WCS object, with defined domain, covering entire set of input frames +# """ +# if ref_wcs is None: +# wcslist = [i.meta.wcs for i in input_models] +# for w, i in zip(wcslist, input_models): +# if w.bounding_box is None: +# w.bounding_box = util.wcs_bbox_from_shape(i.data.shape) +# naxes = wcslist[0].output_frame.naxes + +# if naxes != 2: +# msg = f"Output WCS needs 2 spatial axes \ +# but the supplied WCS has {naxes} axes." +# raise RuntimeError(msg) + +# output_wcs = util.wcs_from_footprints( +# input_models, +# pscale_ratio=pscale_ratio, +# pscale=pscale, +# rotation=rotation, +# shape=shape, +# crpix=crpix, +# crval=crval +# ) + +# else: +# naxes = ref_wcs.output_frame.naxes +# if naxes != 2: +# msg = f"Output WCS needs 2 spatial axes \ +# but the supplied WCS has {naxes} axes." +# raise RuntimeError(msg) +# output_wcs = deepcopy(ref_wcs) +# if shape is not None: +# output_wcs.array_shape = shape + +# # Check that the output data shape has no zero length dimensions +# if not np.prod(output_wcs.array_shape): +# msg = f"Invalid output frame shape: {tuple(output_wcs.array_shape)}" +# raise ValueError(msg) + +# return output_wcs diff --git a/src/stcal/alignment/util.py b/src/stcal/alignment/util.py index 27fbc6ce..00121ec9 100644 --- a/src/stcal/alignment/util.py +++ b/src/stcal/alignment/util.py @@ -584,7 +584,6 @@ def update_fits_wcsinfo(datamodel, max_pix_error=0.01, degree=None, Parameters ---------- - datamodel : `ImageModel` The input data model for imaging or WFSS mode whose ``meta.wcsinfo`` field should be updated from GWCS. By default, ``datamodel.meta.wcs`` @@ -687,7 +686,6 @@ def update_fits_wcsinfo(datamodel, max_pix_error=0.01, degree=None, Notes ----- - Use of this requires a judicious choice of required accuracies. Attempts to use higher degrees (~7 or higher) will typically fail due to floating point problems that arise with high powers. diff --git a/src/stcal/tweakreg/astrometric_utils.py b/src/stcal/tweakreg/astrometric_utils.py index eb8d772f..45125060 100644 --- a/src/stcal/tweakreg/astrometric_utils.py +++ b/src/stcal/tweakreg/astrometric_utils.py @@ -5,9 +5,8 @@ from astropy import units as u from astropy.coordinates import SkyCoord from astropy.table import Table -from astropy.time import Time -from stcal.alignment import compute_fiducial, resample_utils +from stcal.alignment import compute_fiducial ASTROMETRIC_CAT_ENVVAR = "ASTROMETRIC_CATALOG_URL" DEF_CAT_URL = "http://gsss.stsci.edu/webservices" @@ -31,20 +30,27 @@ def create_astrometric_catalog( - input_models, + wcs, + epoch, catalog="GAIADR3", output="ref_cat.ecsv", gaia_only=False, table_format="ascii.ecsv", - existing_wcs=None, - num_sources=None, - epoch=None): + num_sources=None): """Create an astrometric catalog that covers the inputs' field-of-view. Parameters ---------- - input_models : list of `~jwst.datamodel.JwstDataModel` - Each datamodel must have a ~gwcs.WCS object. + wcs : `~astropy.wcs.WCS` + WCS object specified by the user as generated by + `resample.resample_utils.make_output_wcs`. This will typically + have the same plate-scale and orientation as the first member in the + list of input images to make_output_wcs. Fortunately, for alignment, + this doesn't matter since no resampling of data will be performed. + + epoch : float + Reference epoch used to update the coordinates for proper motion + (in decimal year). catalog : str, optional Name of catalog to extract astrometric positions for sources in the @@ -58,19 +64,12 @@ def create_astrometric_catalog( gaia_only : bool, optional Specify whether or not to only use sources from GAIA in output catalog - existing_wcs : model - existing WCS object specified by the user as generated by - `resample.resample_utils.make_output_wcs` - num_sources : int Maximum number of brightest/faintest sources to return in catalog. If `num_sources` is negative, return that number of the faintest sources. By default, all sources are returned. - epoch : float, optional - Reference epoch used to update the coordinates for proper motion - (in decimal year). If `None` then the epoch is obtained from - the metadata. + Notes ----- @@ -83,20 +82,9 @@ def create_astrometric_catalog( Astropy Table object of the catalog """ # start by creating a composite field-of-view for all inputs - # This default output WCS will have the same plate-scale and orientation - # as the first member in the list. - # Fortunately, for alignment, this doesn't matter since no resampling of - # data will be performed. - outwcs = existing_wcs if existing_wcs is not None \ - else resample_utils.make_output_wcs(input_models) - radius, fiducial = compute_radius(outwcs) + radius, fiducial = compute_radius(wcs) # perform query for this field-of-view - epoch = ( - epoch - if epoch is not None - else Time(input_models[0].meta.observation.date).decimalyear - ) ref_dict = get_catalog( fiducial[0], fiducial[1], diff --git a/src/stcal/tweakreg/tweakreg.py b/src/stcal/tweakreg/tweakreg.py index adf270d7..f8227f14 100644 --- a/src/stcal/tweakreg/tweakreg.py +++ b/src/stcal/tweakreg/tweakreg.py @@ -8,7 +8,6 @@ from astropy.coordinates import SkyCoord from astropy.table import Table from astropy.time import Time -from jwst.datamodels import ModelContainer from stdatamodels import DataModel from tweakwcs.correctors import JWSTWCSCorrector from tweakwcs.imalign import align_wcs @@ -40,130 +39,80 @@ class TweakregError(BaseException): pass -def tweakreg(images: ModelContainer, catalogs: list[Table], - ref_cat: Table = None, - abs_refcat: str | None = None, - save_abs_catalog: bool = False, - abs_catalog_output_dir: str | None = None, - searchrad: float = 2.0, - abs_searchrad: float = 6.0, - separation: float = 1.0, - abs_separation: float = 1.0, - use2dhist: bool = True, - abs_use2dhist: bool = True, - tolerance: float = 0.7, - abs_tolerance: float = 0.7, - xoffset: float = 0.0, - yoffset: float = 0.0, - enforce_user_order: bool = False, - expand_refcat: bool = False, - minobj: int = 15, - abs_minobj: int = 15, - fitgeometry: str = "rshift", - abs_fitgeometry: str = "rshift", - nclip: int = 3, - abs_nclip: int = 3, - abs_sigma: float = 3.0, - sigma: float = 3.0, - sip_approx: bool = True, - sip_max_pix_error: float = 0.01, - sip_degree: int | None = None, - sip_max_inv_pix_error: float = 0.01, - sip_inv_degree: int | None = None, - sip_npoints: int = 12,) -> ModelContainer: - """ - whatever. +def relative_align(correctors: list[JWSTWCSCorrector], + searchrad: float = 2.0, + separation: float = 1.0, + use2dhist: bool = True, + tolerance: float = 0.7, + xoffset: float = 0.0, + yoffset: float = 0.0, + enforce_user_order: bool = False, + expand_refcat: bool = False, + minobj: int = 15, + fitgeometry: str = "rshift", + nclip: int = 3, + sigma: float = 3.0, + align_to_abs_refcat: bool = False, + ) -> tuple[list[JWSTWCSCorrector], bool]: - Parameters - ---------- - ref_cat: only required when align_to_abs_refcat is True - """ - # perform some input validations if separation <= _SQRT2 * tolerance: msg = "Parameter 'separation' must be larger than 'tolerance' by at \ least a factor of sqrt(2) to avoid source confusion." raise TweakregError(msg) - if abs_separation <= _SQRT2 * abs_tolerance: - msg = "Parameter 'abs_separation' must be larger than 'abs_tolerance' by at \ - least a factor of sqrt(2) to avoid source confusion." - raise TweakregError(msg) + # align images: + xyxymatch = XYXYMatch( + searchrad=searchrad, + separation=separation, + use2dhist=use2dhist, + tolerance=tolerance, + xoffset=xoffset, + yoffset=yoffset + ) - if len(images) == 0: - msg = "Input must contain at least one image model." - raise ValueError(msg) - - if abs_refcat is None: - align_to_abs_refcat = False - - n_groups = len(images.group_names) - - # pre-allocate correctors (same length and order as images) - correctors = [None] * len(images) - for (model_index, image_model) in enumerate(images): - catalog = _filter_catalog_by_bounding_box( - catalogs[model_index], - image_model.meta.wcs.bounding_box - ) - corrector = _construct_wcs_corrector(image_model, catalog) - correctors[model_index] = corrector - - # relative alignment of images to each other (if more than one group) - if n_groups > 1: - - # align images: - xyxymatch = XYXYMatch( - searchrad=searchrad, - separation=separation, - use2dhist=use2dhist, - tolerance=tolerance, - xoffset=xoffset, - yoffset=yoffset + try: + align_wcs( + correctors, + refcat=None, + enforce_user_order=enforce_user_order, + expand_refcat=expand_refcat, + minobj=minobj, + match=xyxymatch, + fitgeom=fitgeometry, + nclip=nclip, + sigma=(sigma, "rmse") ) - - try: - align_wcs( - correctors, - refcat=None, - enforce_user_order=enforce_user_order, - expand_refcat=expand_refcat, - minobj=minobj, - match=xyxymatch, - fitgeom=fitgeometry, - nclip=nclip, - sigma=(sigma, "rmse") - ) - local_align_failed = False - - except ValueError as e: - msg = e.args[0] - if (msg == "Too few input images (or groups of images) with " - "non-empty catalogs."): - local_align_failed = True - if not align_to_abs_refcat: - msg += "At least two exposures are required for image alignment. Nothing to do." - raise TweakregError(msg) from None - else: - raise - - except RuntimeError as e: - msg = e.args[0] - if msg.startswith("Number of output coordinates exceeded allocation"): - # we need at least two exposures to perform image alignment - msg += "Multiple sources within specified tolerance \ - matched to a single reference source. Try to \ - adjust 'tolerance' and/or 'separation' parameters." + local_align_failed = False + + except ValueError as e: + msg = e.args[0] + if (msg == "Too few input images (or groups of images) with " + "non-empty catalogs."): + local_align_failed = True + if not align_to_abs_refcat: + msg += "At least two exposures are required for image alignment. Nothing to do." raise TweakregError(msg) from None + else: raise - with warnings.catch_warnings(record=True) as w: - is_small = _is_wcs_correction_small(correctors, - use2dhist, - searchrad, - tolerance, - xoffset, - yoffset) - warning_msg = "".join([str(mess.message) for mess in w]) + except RuntimeError as e: + msg = e.args[0] + if msg.startswith("Number of output coordinates exceeded allocation"): + # we need at least two exposures to perform image alignment + msg += "Multiple sources within specified tolerance \ + matched to a single reference source. Try to \ + adjust 'tolerance' and/or 'separation' parameters." + raise TweakregError(msg) from None + raise + + with warnings.catch_warnings(record=True) as w: + is_small = _is_wcs_correction_small(correctors, + use2dhist, + searchrad, + tolerance, + xoffset, + yoffset) + warning_msg = "".join([str(mess.message) for mess in w]) if not local_align_failed and not is_small: if align_to_abs_refcat: warning_msg += " Skipping relative alignment (stage 1)..." @@ -171,140 +120,170 @@ def tweakreg(images: ModelContainer, catalogs: list[Table], else: raise TweakregError(warning_msg) - # absolute alignment to the reference catalog - # can (and does) occur after alignment between groups - if align_to_abs_refcat: - - ref_cat = _parse_refcat(abs_refcat, - images, - correctors, - save_abs_catalog=save_abs_catalog, - output_dir=abs_catalog_output_dir) - - # Check that there are enough GAIA sources for a reliable/valid fit - num_ref = len(ref_cat) - if num_ref < abs_minobj: - msg = f"Not enough sources ({num_ref}) in the reference catalog \ - for the single-group alignment step to perform a fit. \ - Skipping alignment to the input reference catalog!" - raise TweakregError(msg) - - # align images: - # Update to separation needed to prevent confusion of sources - # from overlapping images where centering is not consistent or - # for the possibility that errors still exist in relative overlap. - xyxymatch_gaia = XYXYMatch( - searchrad=abs_searchrad, - separation=abs_separation, - use2dhist=abs_use2dhist, - tolerance=abs_tolerance, - xoffset=0.0, - yoffset=0.0 - ) + return correctors, local_align_failed + + +def absolute_align(correctors: list[JWSTWCSCorrector], + abs_refcat: str, + ref_image: DataModel, + save_abs_catalog: bool = False, + abs_catalog_output_dir: str | None = None, + abs_searchrad: float = 6.0, + abs_separation: float = 1.0, + abs_use2dhist: bool = True, + abs_tolerance: float = 0.7, + abs_minobj: int = 15, + abs_fitgeometry: str = "rshift", + abs_nclip: int = 3, + abs_sigma: float = 3.0, + n_groups: int = 1, + local_align_failed: bool = False,) -> list[JWSTWCSCorrector]: - # Set group_id to same value so all get fit as one observation - # The assigned value, 987654, has been hard-coded to make it - # easy to recognize when alignment to GAIA was being performed - # as opposed to the group_id values used for relative alignment - # earlier in this step. - for corrector in correctors: - corrector.meta["group_id"] = 987654 - if ("fit_info" in corrector.meta and - "REFERENCE" in corrector.meta["fit_info"]["status"]): - del corrector.meta["fit_info"] - - # Perform fit - try: - align_wcs( - correctors, - refcat=ref_cat, - enforce_user_order=True, - expand_refcat=False, - minobj=abs_minobj, - match=xyxymatch_gaia, - fitgeom=abs_fitgeometry, - nclip=abs_nclip, - sigma=(abs_sigma, "rmse") - ) - except ValueError as e: - msg = e.args[0] - if (msg == "Too few input images (or groups of images) with " - "non-empty catalogs."): - msg += "At least one exposure is required to align images \ - to an absolute reference catalog. Alignment to an \ - absolute reference catalog will not be performed." - if local_align_failed or n_groups == 1: - msg += " Nothing to do. Skipping 'TweakRegStep'..." - raise TweakregError(msg) from None - warnings.warn(msg) - else: - raise e - - except RuntimeError as e: - msg = e.args[0] - if msg.startswith("Number of output coordinates exceeded allocation"): - # we need at least two exposures to perform image alignment - msg += "Multiple sources within specified tolerance \ - matched to a single reference source. Try to \ - adjust 'tolerance' and/or 'separation' parameters. \ - Alignment to an absolute reference catalog will \ - not be performed." - if local_align_failed or n_groups == 1: - msg += "Skipping 'TweakRegStep'..." - raise TweakregError(msg) from None - else: - warnings.warn(msg) - else: - raise e + if abs_separation <= _SQRT2 * abs_tolerance: + msg = "Parameter 'abs_separation' must be larger than 'abs_tolerance' by at \ + least a factor of sqrt(2) to avoid source confusion." + raise TweakregError(msg) + + ref_cat = _parse_refcat(abs_refcat, + ref_image, + correctors, + save_abs_catalog=save_abs_catalog, + output_dir=abs_catalog_output_dir) + + # Check that there are enough GAIA sources for a reliable/valid fit + num_ref = len(ref_cat) + if num_ref < abs_minobj: + msg = f"Not enough sources ({num_ref}) in the reference catalog \ + for the single-group alignment step to perform a fit. \ + Skipping alignment to the input reference catalog!" + raise TweakregError(msg) - # one final pass through all the models to update them based - # on the results of this step - for (image_model, corrector) in zip(images, correctors): - image_model.meta.cal_step.tweakreg = "COMPLETE" + # align images: + # Update to separation needed to prevent confusion of sources + # from overlapping images where centering is not consistent or + # for the possibility that errors still exist in relative overlap. + xyxymatch_gaia = XYXYMatch( + searchrad=abs_searchrad, + separation=abs_separation, + use2dhist=abs_use2dhist, + tolerance=abs_tolerance, + xoffset=0.0, + yoffset=0.0 + ) - # retrieve fit status and update wcs if fit is successful: + # Set group_id to same value so all get fit as one observation + # The assigned value, 987654, has been hard-coded to make it + # easy to recognize when alignment to GAIA was being performed + # as opposed to the group_id values used for relative alignment + # earlier in this step. + for corrector in correctors: + corrector.meta["group_id"] = 987654 if ("fit_info" in corrector.meta and - "SUCCESS" in corrector.meta["fit_info"]["status"]): + "REFERENCE" in corrector.meta["fit_info"]["status"]): + del corrector.meta["fit_info"] + + # Perform fit + try: + align_wcs( + correctors, + refcat=ref_cat, + enforce_user_order=True, + expand_refcat=False, + minobj=abs_minobj, + match=xyxymatch_gaia, + fitgeom=abs_fitgeometry, + nclip=abs_nclip, + sigma=(abs_sigma, "rmse") + ) + except ValueError as e: + msg = e.args[0] + if (msg == "Too few input images (or groups of images) with " + "non-empty catalogs."): + msg += "At least one exposure is required to align images \ + to an absolute reference catalog. Alignment to an \ + absolute reference catalog will not be performed." + if local_align_failed or n_groups == 1: + msg += " Nothing to do. Skipping 'TweakRegStep'..." + raise TweakregError(msg) from None + warnings.warn(msg) + else: + raise e + + except RuntimeError as e: + msg = e.args[0] + if msg.startswith("Number of output coordinates exceeded allocation"): + # we need at least two exposures to perform image alignment + msg += "Multiple sources within specified tolerance \ + matched to a single reference source. Try to \ + adjust 'tolerance' and/or 'separation' parameters. \ + Alignment to an absolute reference catalog will \ + not be performed." + if local_align_failed or n_groups == 1: + msg += "Skipping 'TweakRegStep'..." + raise TweakregError(msg) from None + else: + warnings.warn(msg) + else: + raise e + + return correctors + + +def apply_tweakreg_solution(image_model: DataModel, + corrector: JWSTWCSCorrector, + abs_refcat: str, + align_to_abs_refcat: bool = False, + sip_approx: bool = True, + sip_max_pix_error: float = 0.01, + sip_degree: int | None = None, + sip_max_inv_pix_error: float = 0.01, + sip_inv_degree: int | None = None, + sip_npoints: int = 12, + ) -> DataModel: + + # retrieve fit status and update wcs if fit is successful: + if ("fit_info" in corrector.meta and + "SUCCESS" in corrector.meta["fit_info"]["status"]): + + # Update/create the WCS .name attribute with information + # on this astrometric fit as the only record that it was + # successful: + if align_to_abs_refcat: + # NOTE: This .name attrib agreed upon by the JWST Cal + # Working Group. + # Current value is merely a place-holder based + # on HST conventions. This value should also be + # translated to the FITS WCSNAME keyword + # IF that is what gets recorded in the archive + # for end-user searches. + corrector.wcs.name = f"FIT-LVL3-{abs_refcat}" + + image_model.meta.wcs = corrector.wcs + update_s_region_imaging(image_model) + + # Also update FITS representation in input exposures for + # subsequent reprocessing by the end-user. + if sip_approx: + try: + update_fits_wcsinfo( + image_model, + max_pix_error=sip_max_pix_error, + degree=sip_degree, + max_inv_pix_error=sip_max_inv_pix_error, + inv_degree=sip_inv_degree, + npoints=sip_npoints, + crpix=None + ) + except (ValueError, RuntimeError) as e: + msg = f"Failed to update 'meta.wcsinfo' with FITS SIP \ + approximation. Reported error is: \n {e.args[0]}" + warnings.warn(msg) - # Update/create the WCS .name attribute with information - # on this astrometric fit as the only record that it was - # successful: - if align_to_abs_refcat: - # NOTE: This .name attrib agreed upon by the JWST Cal - # Working Group. - # Current value is merely a place-holder based - # on HST conventions. This value should also be - # translated to the FITS WCSNAME keyword - # IF that is what gets recorded in the archive - # for end-user searches. - corrector.wcs.name = f"FIT-LVL3-{abs_refcat}" - - image_model.meta.wcs = corrector.wcs - update_s_region_imaging(image_model) - - # Also update FITS representation in input exposures for - # subsequent reprocessing by the end-user. - if sip_approx: - try: - update_fits_wcsinfo( - image_model, - max_pix_error=sip_max_pix_error, - degree=sip_degree, - max_inv_pix_error=sip_max_inv_pix_error, - inv_degree=sip_inv_degree, - npoints=sip_npoints, - crpix=None - ) - except (ValueError, RuntimeError) as e: - msg = f"Failed to update 'meta.wcsinfo' with FITS SIP \ - approximation. Reported error is: \n {e.args[0]}" - warnings.warn(msg) - - return images + return image_model def _parse_refcat(abs_refcat: str, - images: ModelContainer, + ref_model: DataModel, correctors: list, save_abs_catalog: bool = False, output_dir: str | None = None) -> Table: @@ -324,7 +303,6 @@ def _parse_refcat(abs_refcat: str, abs_refcat = abs_refcat.strip() gaia_cat_name = abs_refcat.upper() if gaia_cat_name in SINGLE_GROUP_REFCAT: - ref_model = images[0] epoch = Time(ref_model.meta.observation.date).decimalyear @@ -337,11 +315,9 @@ def _parse_refcat(abs_refcat: str, ) return create_astrometric_catalog( - None, - gaia_cat_name, - existing_wcs=combined_wcs, + combined_wcs, epoch, + catalog=gaia_cat_name, output=output_name, - epoch=epoch, ) if Path.isfile(abs_refcat): @@ -405,6 +381,11 @@ def _construct_wcs_corrector(image_model: DataModel, pre-compute skycoord here so we can later use it to check for a small wcs correction. """ + catalog = _filter_catalog_by_bounding_box( + catalog, + image_model.meta.wcs.bounding_box + ) + wcs = image_model.meta.wcs refang = image_model.meta.wcsinfo.instance return JWSTWCSCorrector( diff --git a/tests/test_tweakreg.py b/tests/test_tweakreg.py index 7d1a1d9c..8747b113 100644 --- a/tests/test_tweakreg.py +++ b/tests/test_tweakreg.py @@ -8,12 +8,12 @@ from astropy.modeling.models import Shift from astropy.wcs import WCS from gwcs.wcstools import grid_from_bounding_box -from jwst.datamodels import ModelContainer from photutils.segmentation import SourceCatalog, SourceFinder from stdatamodels.jwst.datamodels import ImageModel from stcal.tweakreg import astrometric_utils as amutils -from stcal.tweakreg.tweakreg import _is_wcs_correction_small, _parse_refcat, _wcs_to_skycoord, tweakreg +from stcal.tweakreg.tweakreg import _is_wcs_correction_small, _parse_refcat, _wcs_to_skycoord, _construct_wcs_corrector, \ + relative_align, absolute_align, apply_tweakreg_solution from stcal.tweakreg.utils import _wcsinfo_from_wcs_transform # Define input GWCS specification to be used for these tests @@ -26,7 +26,6 @@ # something BKG_LEVEL = 0.001 N_EXAMPLE_SOURCES = 21 -N_CUSTOM_SOURCES = 15 @pytest.fixture(scope="module") @@ -58,11 +57,9 @@ def test_get_catalog(wcsobj): def test_create_catalog(wcsobj): # Create catalog gcat = amutils.create_astrometric_catalog( - None, - existing_wcs=wcsobj, + wcsobj, "2016.0", catalog=TEST_CATALOG, output=None, - epoch="2016.0", ) # check that we got expected number of sources assert len(gcat) == EXPECTED_NUM_SOURCES @@ -77,11 +74,9 @@ def test_create_catalog_graceful_failure(wcsobj): # Create catalog gcat = amutils.create_astrometric_catalog( - None, - existing_wcs=wcsobj, + wcsobj, "2016.0", catalog=TEST_CATALOG, output=None, - epoch="2016.0", ) # check that we got expected number of sources assert len(gcat) == 0 @@ -157,12 +152,8 @@ def example_input(example_wcs): [0.1, 0.6, 0.1], ] m0.meta.observation.date = "2019-01-01T00:00:00" - - m1 = m0.copy() - # give each a unique filename m0.meta.filename = "some_file_0.fits" - m1.meta.filename = "some_file_1.fits" - return ModelContainer([m0, m1]) + return m0 @pytest.mark.usefixtures("_jail") @@ -199,87 +190,65 @@ def make_source_catalog(data): @pytest.mark.parametrize("with_shift", [True, False]) -def test_tweakreg_main(example_input, with_shift): +def test_relative_align(example_input, with_shift): """ A simplified unit test for basic operation of the TweakRegStep when run with or without a small shift in the input image sources """ + shifted = example_input.copy() + shifted.meta.filename = "some_file_1.fits" if with_shift: # shift 9 pixels so that the sources in one of the 2 images # appear at different locations (resulting in a correct wcs update) - example_input[1].data[:-9] = example_input[1].data[9:] - example_input[1].data[-9:] = BKG_LEVEL + shifted.data[:-9] = example_input.data[9:] + shifted.data[-9:] = BKG_LEVEL # assign images to different groups (so they are aligned to each other) - example_input[0].meta.group_id = "a" - example_input[1].meta.group_id = "b" + example_input.meta.group_id = "a" + shifted.meta.group_id = "b" # create source catalogs - source_catalogs = [make_source_catalog(m.data) for m in example_input] + models = [example_input, shifted] + source_catalogs = [make_source_catalog(m.data) for m in models] - # run the step on the example input modified above - result = tweakreg(example_input, source_catalogs) + # construct correctors from the catalogs + correctors = [_construct_wcs_corrector(m, cat) for m, cat in zip(models, source_catalogs)] - # check that step completed - for model in result: - assert model.meta.cal_step.tweakreg == "COMPLETE" + # relative alignment of images to each other (if more than one group) + correctors, local_align_failed = relative_align(correctors) - # and that the wcses differ by a small amount due to the shift above - # by projecting one point through each wcs and comparing the difference - abs_delta = abs(result[1].meta.wcs(0, 0)[0] - result[0].meta.wcs(0, 0)[0]) - if with_shift: - assert abs_delta > 1E-5 - else: - assert abs_delta < 1E-12 + # update the wcs in the models + for (model, corrector) in zip(models, correctors): + apply_tweakreg_solution(model, corrector, TEST_CATALOG, + sip_approx=True, sip_degree=3, sip_max_pix_error=0.1, + sip_max_inv_pix_error=0.1, sip_inv_degree=3, + sip_npoints=12) -@pytest.mark.parametrize("with_shift", [True, False]) -def test_sip_approx(example_input, with_shift): - """ - Test the output FITS WCS. - """ - if with_shift: - # shift 9 pixels so that the sources in one of the 2 images - # appear at different locations (resulting in a correct wcs update) - example_input[1].data[:-9] = example_input[1].data[9:] - example_input[1].data[-9:] = BKG_LEVEL - - # assign images to different groups (so they are aligned to each other) - example_input[0].meta.group_id = "a" - example_input[1].meta.group_id = "b" - - # create source catalogs - source_catalogs = [make_source_catalog(m.data) for m in example_input] - - # run the step on the example input modified above - result = tweakreg(example_input, source_catalogs, - sip_approx=True, sip_degree=3, sip_max_pix_error=0.1, - sip_max_inv_pix_error=0.1, sip_inv_degree=3, - sip_npoints=12) - - # output wcs differs by a small amount due to the shift above: - # project one point through each wcs and compare the difference - abs_delta = abs(result[1].meta.wcs(0, 0)[0] - result[0].meta.wcs(0, 0)[0]) + # and that the wcses differ by a small amount due to the shift above + # by projecting one point through each wcs and comparing the difference + abs_delta = abs(models[1].meta.wcs(0, 0)[0] - models[0].meta.wcs(0, 0)[0]) if with_shift: assert abs_delta > 1E-5 else: assert abs_delta < 1E-12 + # also test SIP approximation keywords # the first wcs is identical to the input and # does not have SIP approximation keywords -- # they are normally set by assign_wcs - assert np.allclose(result[0].meta.wcs(0, 0)[0], example_input[0].meta.wcs(0, 0)[0]) + assert np.allclose(models[0].meta.wcs(0, 0)[0], example_input.meta.wcs(0, 0)[0]) for key in ["ap_order", "bp_order"]: - assert key not in result[0].meta.wcsinfo.instance + assert key not in models[0].meta.wcsinfo.instance # for the second, SIP approximation should be present for key in ["ap_order", "bp_order"]: - assert result[1].meta.wcsinfo.instance[key] == 3 + assert models[1].meta.wcsinfo.instance[key] == 3 # evaluate fits wcs and gwcs for the approximation, make sure they agree - wcs_info = result[1].meta.wcsinfo.instance - grid = grid_from_bounding_box(result[1].meta.wcs.bounding_box) - gwcs_ra, gwcs_dec = result[1].meta.wcs(*grid) + wcs_info = models[1].meta.wcsinfo.instance + grid = grid_from_bounding_box(models[1].meta.wcs.bounding_box) + gwcs_ra, gwcs_dec = models[1].meta.wcs(*grid) fits_wcs = WCS(wcs_info) fitswcs_res = fits_wcs.pixel_to_world(*grid) From cabe85246ed155299c248c0efd690f4562350e4a Mon Sep 17 00:00:00 2001 From: Ned Molter Date: Wed, 3 Jul 2024 13:32:27 -0400 Subject: [PATCH 03/36] removed dependency on datamodels, plus bugfixes --- pyproject.toml | 1 + src/stcal/alignment/resample_utils.py | 1 - src/stcal/alignment/util.py | 206 +------------------------- src/stcal/tweakreg/tweakreg.py | 84 ++--------- tests/test_tweakreg.py | 182 +++++++---------------- 5 files changed, 71 insertions(+), 403 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a945de6e..c7751523 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ dependencies = [ 'opencv-python-headless >=4.6.0.66', 'asdf >=2.15.0', 'gwcs >= 0.18.1', + 'requests', ] dynamic = ['version'] diff --git a/src/stcal/alignment/resample_utils.py b/src/stcal/alignment/resample_utils.py index 80ef0cc4..213b5a3d 100644 --- a/src/stcal/alignment/resample_utils.py +++ b/src/stcal/alignment/resample_utils.py @@ -1,5 +1,4 @@ import logging -from copy import deepcopy import numpy as np from gwcs.wcstools import grid_from_bounding_box diff --git a/src/stcal/alignment/util.py b/src/stcal/alignment/util.py index 00121ec9..993207cb 100644 --- a/src/stcal/alignment/util.py +++ b/src/stcal/alignment/util.py @@ -20,16 +20,14 @@ log = logging.getLogger(__name__) log.setLevel(logging.DEBUG) -_MAX_SIP_DEGREE = 6 - __all__ = [ "compute_scale", "compute_fiducial", "calc_rotation_matrix", - "update_fits_wcsinfo", "update_s_region_imaging", "wcs_from_footprints", "reproject", + "SupportsDataWithWcs" ] @@ -144,7 +142,7 @@ def _generate_tranform( return transform -def _get_axis_min_and_bounding_box(ref_model, wcs_list, ref_wcs): +def _get_axis_min_and_bounding_box(ref_model: SupportsDataWithWcs, wcs_list, ref_wcs): """ Calculates axis minimum values and bounding box. @@ -264,7 +262,7 @@ def _calculate_offsets(fiducial, wcs, axis_min_values, crpix): return astmodels.Shift(-offset1, name="crpix1") & astmodels.Shift(-offset2, name="crpix2") -def _calculate_new_wcs(ref_model, shape, wcs_list, fiducial, crpix=None, transform=None): +def _calculate_new_wcs(ref_model: SupportsDataWithWcs, shape, wcs_list, fiducial, crpix=None, transform=None): """ Calculates a new WCS object based on the combined WCS objects provided. @@ -555,200 +553,8 @@ def calc_rotation_matrix(roll_ref: float, v3i_yangle: float, vparity: int = 1) - return [pc1_1, pc1_2, pc2_1, pc2_2] -def update_fits_wcsinfo(datamodel, max_pix_error=0.01, degree=None, - max_inv_pix_error=0.01, inv_degree=None, - npoints=12, crpix=None, projection='TAN', - imwcs=None, **kwargs): - """ - Update ``datamodel.meta.wcsinfo`` based on a FITS WCS + SIP approximation - of a GWCS object. By default, this function will approximate - the datamodel's GWCS object stored in ``datamodel.meta.wcs`` but it can - also approximate a user-supplied GWCS object when provided via - the ``imwcs`` parameter. - - The default mode in using this attempts to achieve roughly 0.01 pixel - accuracy over the entire image. - - This function uses the :py:meth:`~gwcs.wcs.WCS.to_fits_sip` to - create FITS WCS representations of GWCS objects. Only most important - :py:meth:`~gwcs.wcs.WCS.to_fits_sip` parameters are exposed here. Other - arguments to :py:meth:`~gwcs.wcs.WCS.to_fits_sip` can be passed via - ``kwargs`` - see "Other Parameters" section below. - Please refer to the documentation of :py:meth:`~gwcs.wcs.WCS.to_fits_sip` - for more details. - - .. warning:: - This function modifies input data model's ``datamodel.meta.wcsinfo`` - members. - - - Parameters - ---------- - datamodel : `ImageModel` - The input data model for imaging or WFSS mode whose ``meta.wcsinfo`` - field should be updated from GWCS. By default, ``datamodel.meta.wcs`` - is used to compute FITS WCS + SIP approximation. When ``imwcs`` is - not `None` then computed FITS WCS will be an approximation of the WCS - provided through the ``imwcs`` parameter. - - max_pix_error : float, optional - Maximum allowed error over the domain of the pixel array. This - error is the equivalent pixel error that corresponds to the maximum - error in the output coordinate resulting from the fit based on - a nominal plate scale. - - degree : int, iterable, None, optional - Degree of the SIP polynomial. Default value `None` indicates that - all allowed degree values (``[1...6]``) will be considered and - the lowest degree that meets accuracy requerements set by - ``max_pix_error`` will be returned. Alternatively, ``degree`` can be - an iterable containing allowed values for the SIP polynomial degree. - This option is similar to default `None` but it allows caller to - restrict the range of allowed SIP degrees used for fitting. - Finally, ``degree`` can be an integer indicating the exact SIP degree - to be fit to the WCS transformation. In this case - ``max_pixel_error`` is ignored. - - max_inv_pix_error : float, None, optional - Maximum allowed inverse error over the domain of the pixel array - in pixel units. With the default value of `None` no inverse - is generated. - - inv_degree : int, iterable, None, optional - Degree of the SIP polynomial. Default value `None` indicates that - all allowed degree values (``[1...6]``) will be considered and - the lowest degree that meets accuracy requerements set by - ``max_pix_error`` will be returned. Alternatively, ``degree`` can be - an iterable containing allowed values for the SIP polynomial degree. - This option is similar to default `None` but it allows caller to - restrict the range of allowed SIP degrees used for fitting. - Finally, ``degree`` can be an integer indicating the exact SIP degree - to be fit to the WCS transformation. In this case - ``max_inv_pixel_error`` is ignored. - - npoints : int, optional - The number of points in each dimension to sample the bounding box - for use in the SIP fit. Minimum number of points is 3. - - crpix : list of float, None, optional - Coordinates (1-based) of the reference point for the new FITS WCS. - When not provided, i.e., when set to `None` (default) the reference - pixel already specified in ``wcsinfo`` will be re-used. If - ``wcsinfo`` does not contain ``crpix`` information, then the - reference pixel will be chosen near the center of the bounding box - for axes corresponding to the celestial frame. - - projection : str, `~astropy.modeling.projections.Pix2SkyProjection`, optional - Projection to be used for the created FITS WCS. It can be specified - as a string of three characters specifying a FITS projection code - from Table 13 in - `Representations of World Coordinates in FITS \ - `_ - (Paper I), Greisen, E. W., and Calabretta, M. R., A & A, 395, - 1061-1075, 2002. Alternatively, it can be an instance of one of the - `astropy's Pix2Sky_* `_ - projection models inherited from - :py:class:`~astropy.modeling.projections.Pix2SkyProjection`. - - imwcs : `gwcs.WCS`, None, optional - Imaging GWCS object for WFSS mode whose FITS WCS approximation should - be computed and stored in the ``datamodel.meta.wcsinfo`` field. - When ``imwcs`` is `None` then WCS from ``datamodel.meta.wcs`` - will be used. - - .. warning:: - - Used with WFSS modes only. For other modes, supplying a different - WCS from ``datamodel.meta.wcs`` will result in the GWCS and - FITS WCS descriptions to diverge. - - Other Parameters - ---------------- - bounding_box : tuple, None, optional - A pair of tuples, each consisting of two numbers - Represents the range of pixel values in both dimensions - ((xmin, xmax), (ymin, ymax)) - - verbose : bool, optional - Print progress of fits. - - Returns - ------- - FITS header with all SIP WCS keywords - - Raises - ------ - ValueError - If the WCS is not at least 2D, an exception will be raised. If the - specified accuracy (both forward and inverse, both rms and maximum) - is not achieved an exception will be raised. - - Notes - ----- - Use of this requires a judicious choice of required accuracies. - Attempts to use higher degrees (~7 or higher) will typically fail due - to floating point problems that arise with high powers. - - For more details, see :py:meth:`~gwcs.wcs.WCS.to_fits_sip`. - - """ - if crpix is None: - crpix = [datamodel.meta.wcsinfo.crpix1, datamodel.meta.wcsinfo.crpix2] - if None in crpix: - crpix = None - - # For WFSS modes the imaging WCS is passed as an argument. - # For imaging modes it is retrieved from the datamodel. - if imwcs is None: - imwcs = datamodel.meta.wcs - - # make a copy of kwargs: - kwargs = {k: v for k, v in kwargs.items()} - - # limit default 'degree' ranges to _MAX_SIP_DEGREE: - if degree is None: - degree = range(1, _MAX_SIP_DEGREE) - if inv_degree is None: - inv_degree = range(1, _MAX_SIP_DEGREE) - - hdr = imwcs.to_fits_sip( - max_pix_error=max_pix_error, - degree=degree, - max_inv_pix_error=max_inv_pix_error, - inv_degree=inv_degree, - npoints=npoints, - crpix=crpix, - projection=projection, - **kwargs - ) - - # update meta.wcsinfo with FITS keywords except for naxis* - del hdr['naxis*'] - - # maintain convention of lowercase keys - hdr_dict = {k.lower(): v for k, v in hdr.items()} - - # delete naxis, cdelt, pc from wcsinfo - rm_keys = ['naxis', 'cdelt1', 'cdelt2', - 'pc1_1', 'pc1_2', 'pc2_1', 'pc2_2', - 'a_order', 'b_order', 'ap_order', 'bp_order'] - - rm_keys.extend(f"{s}_{i}_{j}" for i in range(10) for j in range(10) - for s in ['a', 'b', 'ap', 'bp']) - - for key in rm_keys: - if key in datamodel.meta.wcsinfo.instance: - del datamodel.meta.wcsinfo.instance[key] - - # update meta.wcs_info with fit keywords - datamodel.meta.wcsinfo.instance.update(hdr_dict) - - return hdr - - def wcs_from_footprints( - dmodels, + dmodels: list[SupportsDataWithWcs], refmodel=None, transform=None, bounding_box=None, @@ -863,7 +669,7 @@ def wcs_from_footprints( ) -def update_s_region_imaging(model, center=True): +def update_s_region_imaging(model: SupportsDataWithWcs, center=True): """ Update the ``S_REGION`` keyword using ``WCS.footprint``. @@ -918,7 +724,7 @@ def wcs_bbox_from_shape(shape): return (-0.5, shape[-1] - 0.5), (-0.5, shape[-2] - 0.5) -def update_s_region_keyword(model, footprint): +def update_s_region_keyword(model: SupportsDataWithWcs, footprint): """Update the S_REGION keyword. Parameters diff --git a/src/stcal/tweakreg/tweakreg.py b/src/stcal/tweakreg/tweakreg.py index f8227f14..28ac64b3 100644 --- a/src/stcal/tweakreg/tweakreg.py +++ b/src/stcal/tweakreg/tweakreg.py @@ -8,12 +8,11 @@ from astropy.coordinates import SkyCoord from astropy.table import Table from astropy.time import Time -from stdatamodels import DataModel from tweakwcs.correctors import JWSTWCSCorrector from tweakwcs.imalign import align_wcs from tweakwcs.matchutils import XYXYMatch -from stcal.alignment import update_fits_wcsinfo, update_s_region_imaging, wcs_from_footprints +from stcal.alignment import SupportsDataWithWcs, wcs_from_footprints from .astrometric_utils import create_astrometric_catalog @@ -116,7 +115,7 @@ def relative_align(correctors: list[JWSTWCSCorrector], if not local_align_failed and not is_small: if align_to_abs_refcat: warning_msg += " Skipping relative alignment (stage 1)..." - warnings.warn(warning_msg) + warnings.warn(warning_msg, stacklevel=1) else: raise TweakregError(warning_msg) @@ -125,7 +124,7 @@ def relative_align(correctors: list[JWSTWCSCorrector], def absolute_align(correctors: list[JWSTWCSCorrector], abs_refcat: str, - ref_image: DataModel, + ref_image: SupportsDataWithWcs, save_abs_catalog: bool = False, abs_catalog_output_dir: str | None = None, abs_searchrad: float = 6.0, @@ -136,7 +135,6 @@ def absolute_align(correctors: list[JWSTWCSCorrector], abs_fitgeometry: str = "rshift", abs_nclip: int = 3, abs_sigma: float = 3.0, - n_groups: int = 1, local_align_failed: bool = False,) -> list[JWSTWCSCorrector]: if abs_separation <= _SQRT2 * abs_tolerance: @@ -202,12 +200,12 @@ def absolute_align(correctors: list[JWSTWCSCorrector], msg += "At least one exposure is required to align images \ to an absolute reference catalog. Alignment to an \ absolute reference catalog will not be performed." - if local_align_failed or n_groups == 1: + if local_align_failed: msg += " Nothing to do. Skipping 'TweakRegStep'..." raise TweakregError(msg) from None - warnings.warn(msg) + warnings.warn(msg, stacklevel=1) else: - raise e + raise except RuntimeError as e: msg = e.args[0] @@ -218,72 +216,18 @@ def absolute_align(correctors: list[JWSTWCSCorrector], adjust 'tolerance' and/or 'separation' parameters. \ Alignment to an absolute reference catalog will \ not be performed." - if local_align_failed or n_groups == 1: + if local_align_failed: msg += "Skipping 'TweakRegStep'..." raise TweakregError(msg) from None - else: - warnings.warn(msg) + warnings.warn(msg, stacklevel=1) else: - raise e + raise return correctors -def apply_tweakreg_solution(image_model: DataModel, - corrector: JWSTWCSCorrector, - abs_refcat: str, - align_to_abs_refcat: bool = False, - sip_approx: bool = True, - sip_max_pix_error: float = 0.01, - sip_degree: int | None = None, - sip_max_inv_pix_error: float = 0.01, - sip_inv_degree: int | None = None, - sip_npoints: int = 12, - ) -> DataModel: - - # retrieve fit status and update wcs if fit is successful: - if ("fit_info" in corrector.meta and - "SUCCESS" in corrector.meta["fit_info"]["status"]): - - # Update/create the WCS .name attribute with information - # on this astrometric fit as the only record that it was - # successful: - if align_to_abs_refcat: - # NOTE: This .name attrib agreed upon by the JWST Cal - # Working Group. - # Current value is merely a place-holder based - # on HST conventions. This value should also be - # translated to the FITS WCSNAME keyword - # IF that is what gets recorded in the archive - # for end-user searches. - corrector.wcs.name = f"FIT-LVL3-{abs_refcat}" - - image_model.meta.wcs = corrector.wcs - update_s_region_imaging(image_model) - - # Also update FITS representation in input exposures for - # subsequent reprocessing by the end-user. - if sip_approx: - try: - update_fits_wcsinfo( - image_model, - max_pix_error=sip_max_pix_error, - degree=sip_degree, - max_inv_pix_error=sip_max_inv_pix_error, - inv_degree=sip_inv_degree, - npoints=sip_npoints, - crpix=None - ) - except (ValueError, RuntimeError) as e: - msg = f"Failed to update 'meta.wcsinfo' with FITS SIP \ - approximation. Reported error is: \n {e.args[0]}" - warnings.warn(msg) - - return image_model - - def _parse_refcat(abs_refcat: str, - ref_model: DataModel, + ref_model: SupportsDataWithWcs, correctors: list, save_abs_catalog: bool = False, output_dir: str | None = None) -> Table: @@ -320,7 +264,7 @@ def _parse_refcat(abs_refcat: str, output=output_name, ) - if Path.isfile(abs_refcat): + if Path.is_file(Path(abs_refcat)): return Table.read(abs_refcat) msg = f"Invalid 'abs_refcat' value: {abs_refcat}. 'abs_refcat' must be \ @@ -349,7 +293,7 @@ def _is_wcs_correction_small(correctors, # Large corrections are typically a result of source # mis-matching or poorly-conditioned fit. Skip such models. msg = f"WCS has been tweaked by more than {10 * tolerance} arcsec" - warnings.warn(msg) + warnings.warn(msg, stacklevel=1) return False return True @@ -375,8 +319,8 @@ def _filter_catalog_by_bounding_box(catalog: Table, bounding_box: list[float]) - return catalog[mask] -def _construct_wcs_corrector(image_model: DataModel, - catalog: Table) -> JWSTWCSCorrector: +def construct_wcs_corrector(image_model: SupportsDataWithWcs, + catalog: Table) -> JWSTWCSCorrector: """ pre-compute skycoord here so we can later use it to check for a small wcs correction. diff --git a/tests/test_tweakreg.py b/tests/test_tweakreg.py index 8747b113..69f5ea25 100644 --- a/tests/test_tweakreg.py +++ b/tests/test_tweakreg.py @@ -6,15 +6,12 @@ import numpy as np import pytest from astropy.modeling.models import Shift -from astropy.wcs import WCS -from gwcs.wcstools import grid_from_bounding_box -from photutils.segmentation import SourceCatalog, SourceFinder -from stdatamodels.jwst.datamodels import ImageModel from stcal.tweakreg import astrometric_utils as amutils -from stcal.tweakreg.tweakreg import _is_wcs_correction_small, _parse_refcat, _wcs_to_skycoord, _construct_wcs_corrector, \ - relative_align, absolute_align, apply_tweakreg_solution -from stcal.tweakreg.utils import _wcsinfo_from_wcs_transform +from stcal.tweakreg.tweakreg import ( + _is_wcs_correction_small, + _wcs_to_skycoord, +) # Define input GWCS specification to be used for these tests WCS_NAME = "mosaic_long_i2d_gwcs.asdf" # Derived using B7.5 Level 3 product @@ -129,128 +126,49 @@ def example_wcs(): return af.tree["wcs"] -@pytest.fixture() -def example_input(example_wcs): - m0 = ImageModel((512, 512)) - - # add a wcs and wcsinfo - m0.meta.wcs = example_wcs - m0.meta.wcsinfo = _wcsinfo_from_wcs_transform(example_wcs) - m0.meta.wcsinfo.v3yangle = 0.0 - m0.meta.wcsinfo.vparity = -1 - - # and a few 'sources' - m0.data[:] = BKG_LEVEL - n_sources = N_EXAMPLE_SOURCES # a few more than default minobj - rng = np.random.default_rng(26) - xs = rng.choice(50, n_sources, replace=False) * 8 + 10 - ys = rng.choice(50, n_sources, replace=False) * 8 + 10 - for y, x in zip(ys, xs): - m0.data[y-1:y+2, x-1:x+2] = [ - [0.1, 0.6, 0.1], - [0.6, 0.8, 0.6], - [0.1, 0.6, 0.1], - ] - m0.meta.observation.date = "2019-01-01T00:00:00" - m0.meta.filename = "some_file_0.fits" - return m0 - - -@pytest.mark.usefixtures("_jail") -def test_parse_refcat(example_input): - """ - Ensure absolute catalog write creates a file and respects self.output_dir - """ - - OUTDIR = Path("outdir") - Path.mkdir(OUTDIR) - - correctors = fake_correctors(0.0) - _parse_refcat(TEST_CATALOG, - example_input, - correctors, - save_abs_catalog=True, - output_dir=OUTDIR) - - expected_outfile = OUTDIR / "fit_gaiadr3_ref.ecsv" - - assert Path.exists(expected_outfile) - - -def make_source_catalog(data): - """ - Extremely lazy version of source detection step. - """ - finder = SourceFinder(npixels=5) - segment_map = finder(data, threshold=0.5) - sources = SourceCatalog(data, segment_map).to_table() - sources.rename_column("xcentroid", "x") - sources.rename_column("ycentroid", "y") - return sources - - -@pytest.mark.parametrize("with_shift", [True, False]) -def test_relative_align(example_input, with_shift): - """ - A simplified unit test for basic operation of the TweakRegStep - when run with or without a small shift in the input image sources - """ - shifted = example_input.copy() - shifted.meta.filename = "some_file_1.fits" - if with_shift: - # shift 9 pixels so that the sources in one of the 2 images - # appear at different locations (resulting in a correct wcs update) - shifted.data[:-9] = example_input.data[9:] - shifted.data[-9:] = BKG_LEVEL - - # assign images to different groups (so they are aligned to each other) - example_input.meta.group_id = "a" - shifted.meta.group_id = "b" - - # create source catalogs - models = [example_input, shifted] - source_catalogs = [make_source_catalog(m.data) for m in models] - - # construct correctors from the catalogs - correctors = [_construct_wcs_corrector(m, cat) for m, cat in zip(models, source_catalogs)] - - # relative alignment of images to each other (if more than one group) - correctors, local_align_failed = relative_align(correctors) - - # update the wcs in the models - for (model, corrector) in zip(models, correctors): - - apply_tweakreg_solution(model, corrector, TEST_CATALOG, - sip_approx=True, sip_degree=3, sip_max_pix_error=0.1, - sip_max_inv_pix_error=0.1, sip_inv_degree=3, - sip_npoints=12) - - # and that the wcses differ by a small amount due to the shift above - # by projecting one point through each wcs and comparing the difference - abs_delta = abs(models[1].meta.wcs(0, 0)[0] - models[0].meta.wcs(0, 0)[0]) - if with_shift: - assert abs_delta > 1E-5 - else: - assert abs_delta < 1E-12 - - # also test SIP approximation keywords - # the first wcs is identical to the input and - # does not have SIP approximation keywords -- - # they are normally set by assign_wcs - assert np.allclose(models[0].meta.wcs(0, 0)[0], example_input.meta.wcs(0, 0)[0]) - for key in ["ap_order", "bp_order"]: - assert key not in models[0].meta.wcsinfo.instance - - # for the second, SIP approximation should be present - for key in ["ap_order", "bp_order"]: - assert models[1].meta.wcsinfo.instance[key] == 3 - - # evaluate fits wcs and gwcs for the approximation, make sure they agree - wcs_info = models[1].meta.wcsinfo.instance - grid = grid_from_bounding_box(models[1].meta.wcs.bounding_box) - gwcs_ra, gwcs_dec = models[1].meta.wcs(*grid) - fits_wcs = WCS(wcs_info) - fitswcs_res = fits_wcs.pixel_to_world(*grid) - - assert np.allclose(fitswcs_res.ra.deg, gwcs_ra) - assert np.allclose(fitswcs_res.dec.deg, gwcs_dec) +# @pytest.fixture() +# def example_input(example_wcs): +# m0 = ImageModel((512, 512)) + +# # add a wcs and wcsinfo +# m0.meta.wcs = example_wcs +# m0.meta.wcsinfo = _wcsinfo_from_wcs_transform(example_wcs) +# m0.meta.wcsinfo.v3yangle = 0.0 +# m0.meta.wcsinfo.vparity = -1 + +# # and a few 'sources' +# m0.data[:] = BKG_LEVEL +# n_sources = N_EXAMPLE_SOURCES # a few more than default minobj +# rng = np.random.default_rng(26) +# xs = rng.choice(50, n_sources, replace=False) * 8 + 10 +# ys = rng.choice(50, n_sources, replace=False) * 8 + 10 +# for y, x in zip(ys, xs): +# m0.data[y-1:y+2, x-1:x+2] = [ +# [0.1, 0.6, 0.1], +# [0.6, 0.8, 0.6], +# [0.1, 0.6, 0.1], +# ] +# m0.meta.observation.date = "2019-01-01T00:00:00" +# m0.meta.filename = "some_file_0.fits" +# return m0 + + +# @pytest.mark.usefixtures("_jail") +# def test_parse_refcat(example_input): +# """ +# Ensure absolute catalog write creates a file and respects self.output_dir +# """ + +# OUTDIR = Path("outdir") +# Path.mkdir(OUTDIR) + +# correctors = fake_correctors(0.0) +# _parse_refcat(TEST_CATALOG, +# example_input, +# correctors, +# save_abs_catalog=True, +# output_dir=OUTDIR) + +# expected_outfile = OUTDIR / "fit_gaiadr3_ref.ecsv" + +# assert Path.exists(expected_outfile) From a771e7c7c2ef446496b114dc67727bf303e863eb Mon Sep 17 00:00:00 2001 From: Ned Molter Date: Wed, 3 Jul 2024 13:52:46 -0400 Subject: [PATCH 04/36] pinned dependency for requests, removed commented tests --- pyproject.toml | 6 +- src/stcal/alignment/resample_utils.py | 86 ------ tests/data/nrcb1-wcs.asdf | 383 -------------------------- tests/test_tweakreg.py | 57 +--- 4 files changed, 5 insertions(+), 527 deletions(-) delete mode 100644 tests/data/nrcb1-wcs.asdf diff --git a/pyproject.toml b/pyproject.toml index c7751523..79e604ad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,9 +18,11 @@ dependencies = [ 'opencv-python-headless >=4.6.0.66', 'asdf >=2.15.0', 'gwcs >= 0.18.1', - 'requests', + 'requests >=2.32.2', +] +dynamic = [ + "version" ] -dynamic = ['version'] [project.optional-dependencies] docs = [ diff --git a/src/stcal/alignment/resample_utils.py b/src/stcal/alignment/resample_utils.py index 213b5a3d..9de7c305 100644 --- a/src/stcal/alignment/resample_utils.py +++ b/src/stcal/alignment/resample_utils.py @@ -39,89 +39,3 @@ def calc_pixmap(in_wcs, out_wcs, shape=None): grid = grid_from_bounding_box(bb) transform_function = util.reproject(in_wcs, out_wcs) return np.dstack(transform_function(grid[0], grid[1])) - - -# def make_output_wcs(input_models, ref_wcs=None, -# pscale_ratio=None, pscale=None, rotation=None, shape=None, -# crpix=None, crval=None): -# """Generate output WCS here based on footprints of all input WCS objects. - -# Parameters -# ---------- -# input_models : list of `DataModel objects` -# Each datamodel must have a ~gwcs.WCS object. - -# pscale_ratio : float, optional -# Ratio of input to output pixel scale. Ignored when ``pscale`` -# is provided. - -# pscale : float, None, optional -# Absolute pixel scale in degrees. When provided, overrides -# ``pscale_ratio``. - -# rotation : float, None, optional -# Position angle of output image Y-axis relative to North. -# A value of 0.0 would orient the final output image to be North up. -# The default of `None` specifies that the images will not be rotated, -# 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. - -# shape : tuple of int, None, optional -# Shape of the image (data array) using ``numpy.ndarray`` convention -# (``ny`` first and ``nx`` second). This value will be assigned to -# ``pixel_shape`` and ``array_shape`` properties of the returned -# WCS object. - -# crpix : tuple of float, None, optional -# Position of the reference pixel in the image array. If ``crpix`` is not -# specified, it will be set to the center of the bounding box of the -# returned WCS object. - -# crval : tuple of float, None, optional -# Right ascension and declination of the reference pixel. Automatically -# computed if not provided. - -# Returns -# ------- -# output_wcs : object -# WCS object, with defined domain, covering entire set of input frames -# """ -# if ref_wcs is None: -# wcslist = [i.meta.wcs for i in input_models] -# for w, i in zip(wcslist, input_models): -# if w.bounding_box is None: -# w.bounding_box = util.wcs_bbox_from_shape(i.data.shape) -# naxes = wcslist[0].output_frame.naxes - -# if naxes != 2: -# msg = f"Output WCS needs 2 spatial axes \ -# but the supplied WCS has {naxes} axes." -# raise RuntimeError(msg) - -# output_wcs = util.wcs_from_footprints( -# input_models, -# pscale_ratio=pscale_ratio, -# pscale=pscale, -# rotation=rotation, -# shape=shape, -# crpix=crpix, -# crval=crval -# ) - -# else: -# naxes = ref_wcs.output_frame.naxes -# if naxes != 2: -# msg = f"Output WCS needs 2 spatial axes \ -# but the supplied WCS has {naxes} axes." -# raise RuntimeError(msg) -# output_wcs = deepcopy(ref_wcs) -# if shape is not None: -# output_wcs.array_shape = shape - -# # Check that the output data shape has no zero length dimensions -# if not np.prod(output_wcs.array_shape): -# msg = f"Invalid output frame shape: {tuple(output_wcs.array_shape)}" -# raise ValueError(msg) - -# return output_wcs diff --git a/tests/data/nrcb1-wcs.asdf b/tests/data/nrcb1-wcs.asdf deleted file mode 100644 index 0588294e..00000000 --- a/tests/data/nrcb1-wcs.asdf +++ /dev/null @@ -1,383 +0,0 @@ -#ASDF 1.0.0 -#ASDF_STANDARD 1.5.0 -%YAML 1.1 -%TAG ! tag:stsci.edu:asdf/ ---- !core/asdf-1.1.0 -asdf_library: !core/software-1.0.0 {author: The ASDF Developers, homepage: 'http://github.com/asdf-format/asdf', - name: asdf, version: 2.14.1} -history: - extensions: - - !core/extension_metadata-1.0.0 - extension_class: asdf.extension._manifest.ManifestExtension - extension_uri: asdf://asdf-format.org/transform/extensions/transform-1.5.0 - software: !core/software-1.0.0 {name: asdf-astropy, version: 0.3.0} - - !core/extension_metadata-1.0.0 - extension_class: asdf.extension.BuiltinExtension - software: !core/software-1.0.0 {name: asdf, version: 2.14.1} - - !core/extension_metadata-1.0.0 - extension_class: asdf.extension._manifest.ManifestExtension - extension_uri: asdf://asdf-format.org/astronomy/gwcs/extensions/gwcs-1.0.0 - software: !core/software-1.0.0 {name: gwcs, version: 0.18.3} - - !core/extension_metadata-1.0.0 - extension_class: asdf.extension._manifest.ManifestExtension - extension_uri: asdf://asdf-format.org/core/extensions/core-1.5.0 - software: !core/software-1.0.0 {name: asdf-astropy, version: 0.3.0} - - !core/extension_metadata-1.0.0 - extension_class: asdf.extension._manifest.ManifestExtension - extension_uri: asdf://asdf-format.org/astronomy/coordinates/extensions/coordinates-1.0.0 - software: !core/software-1.0.0 {name: asdf-astropy, version: 0.3.0} -wcs: ! - name: '' - steps: - - ! - frame: ! - axes_names: [x, y] - axes_order: [0, 1] - axis_physical_types: ['custom:x', 'custom:y'] - name: detector - unit: [!unit/unit-1.0.0 pixel, !unit/unit-1.0.0 pixel] - transform: !transform/compose-1.2.0 - bounding_box: !transform/property/bounding_box-1.0.0 - ignore: [] - intervals: - x0: [-0.5, 2047.5] - x1: [-0.5, 2047.5] - order: C - forward: - - !transform/concatenate-1.2.0 - forward: - - !transform/shift-1.2.0 - inputs: [x] - offset: 1.016 - outputs: [y] - - !transform/shift-1.2.0 - inputs: [x] - offset: 1.625 - outputs: [y] - inputs: [x0, x1] - outputs: [y0, y1] - - !transform/compose-1.2.0 - forward: - - !transform/compose-1.2.0 - forward: - - !transform/compose-1.2.0 - forward: - - !transform/concatenate-1.2.0 - forward: - - &id001 !transform/shift-1.2.0 - inputs: [x] - offset: 1.0 - outputs: [y] - - *id001 - inputs: [x0, x1] - outputs: [y0, y1] - - !transform/concatenate-1.2.0 - forward: - - !transform/shift-1.2.0 - inputs: [x] - offset: -1024.5 - outputs: [y] - - !transform/shift-1.2.0 - inputs: [x] - offset: -1024.5 - outputs: [y] - inputs: [x0, x1] - outputs: [y0, y1] - inputs: [x0, x1] - outputs: [y0, y1] - - !transform/compose-1.2.0 - forward: - - !transform/compose-1.2.0 - forward: - - !transform/remap_axes-1.3.0 - inputs: [x0, x1] - mapping: [0, 1, 0, 1] - outputs: [x0, x1, x2, x3] - - !transform/concatenate-1.2.0 - forward: - - !transform/polynomial-1.2.0 - coefficients: !core/ndarray-1.0.0 - source: 0 - datatype: float64 - byteorder: little - shape: [6, 6] - domain: - - [-1, 1] - - [-1, 1] - inputs: [x, y] - outputs: [z] - window: - - [-1, 1] - - [-1, 1] - - !transform/polynomial-1.2.0 - coefficients: !core/ndarray-1.0.0 - source: 1 - datatype: float64 - byteorder: little - shape: [6, 6] - domain: - - [-1, 1] - - [-1, 1] - inputs: [x, y] - outputs: [z] - window: - - [-1, 1] - - [-1, 1] - inputs: [x0, y0, x1, y1] - outputs: [z0, z1] - inputs: [x0, x1] - inverse: !transform/compose-1.2.0 - forward: - - !transform/remap_axes-1.3.0 - inputs: [x0, x1] - mapping: [0, 1, 0, 1] - outputs: [x0, x1, x2, x3] - - !transform/concatenate-1.2.0 - forward: - - !transform/polynomial-1.2.0 - coefficients: !core/ndarray-1.0.0 - source: 2 - datatype: float64 - byteorder: little - shape: [6, 6] - domain: - - [-1, 1] - - [-1, 1] - inputs: [x, y] - outputs: [z] - window: - - [-1, 1] - - [-1, 1] - - !transform/polynomial-1.2.0 - coefficients: !core/ndarray-1.0.0 - source: 3 - datatype: float64 - byteorder: little - shape: [6, 6] - domain: - - [-1, 1] - - [-1, 1] - inputs: [x, y] - outputs: [z] - window: - - [-1, 1] - - [-1, 1] - inputs: [x0, y0, x1, y1] - outputs: [z0, z1] - inputs: [x0, x1] - outputs: [z0, z1] - outputs: [z0, z1] - - !transform/compose-1.2.0 - forward: - - !transform/remap_axes-1.3.0 - inputs: [x0, x1] - mapping: [0, 1, 0, 1] - outputs: [x0, x1, x2, x3] - - !transform/concatenate-1.2.0 - forward: - - !transform/polynomial-1.2.0 - coefficients: !core/ndarray-1.0.0 - source: 4 - datatype: float64 - byteorder: little - shape: [2, 2] - domain: - - [-1, 1] - - [-1, 1] - inputs: [x, y] - outputs: [z] - window: - - [-1, 1] - - [-1, 1] - - !transform/polynomial-1.2.0 - coefficients: !core/ndarray-1.0.0 - source: 5 - datatype: float64 - byteorder: little - shape: [2, 2] - domain: - - [-1, 1] - - [-1, 1] - inputs: [x, y] - outputs: [z] - window: - - [-1, 1] - - [-1, 1] - inputs: [x0, y0, x1, y1] - outputs: [z0, z1] - inputs: [x0, x1] - inverse: !transform/compose-1.2.0 - forward: - - !transform/remap_axes-1.3.0 - inputs: [x0, x1] - mapping: [0, 1, 0, 1] - outputs: [x0, x1, x2, x3] - - !transform/concatenate-1.2.0 - forward: - - !transform/polynomial-1.2.0 - coefficients: !core/ndarray-1.0.0 - source: 6 - datatype: float64 - byteorder: little - shape: [2, 2] - domain: - - [-1, 1] - - [-1, 1] - inputs: [x, y] - outputs: [z] - window: - - [-1, 1] - - [-1, 1] - - !transform/polynomial-1.2.0 - coefficients: !core/ndarray-1.0.0 - source: 7 - datatype: float64 - byteorder: little - shape: [2, 2] - domain: - - [-1, 1] - - [-1, 1] - inputs: [x, y] - outputs: [z] - window: - - [-1, 1] - - [-1, 1] - inputs: [x0, y0, x1, y1] - outputs: [z0, z1] - inputs: [x0, x1] - outputs: [z0, z1] - outputs: [z0, z1] - inputs: [x0, x1] - outputs: [z0, z1] - inputs: [x0, x1] - outputs: [z0, z1] - - !transform/concatenate-1.2.0 - forward: - - !transform/shift-1.2.0 - inputs: [x] - offset: -122.37890283 - outputs: [y] - - !transform/shift-1.2.0 - inputs: [x] - offset: -457.7308175 - outputs: [y] - inputs: [x0, x1] - outputs: [y0, y1] - inputs: [x0, x1] - outputs: [y0, y1] - inputs: [x0, x1] - outputs: [y0, y1] - - ! - frame: ! - axes_names: [v2, v3] - axes_order: [0, 1] - axis_physical_types: ['custom:v2', 'custom:v3'] - name: v2v3 - unit: [!unit/unit-1.0.0 arcsec, !unit/unit-1.0.0 arcsec] - transform: !transform/compose-1.2.0 - forward: - - !transform/concatenate-1.2.0 - forward: - - !transform/scale-1.2.0 - factor: 0.9999940844823835 - inputs: [x] - name: dva_scale_v2 - outputs: [y] - - !transform/scale-1.2.0 - factor: 0.9999940844823835 - inputs: [x] - name: dva_scale_v3 - outputs: [y] - inputs: [x0, x1] - outputs: [y0, y1] - - !transform/concatenate-1.2.0 - forward: - - !transform/shift-1.2.0 - inputs: [x] - name: dva_v2_shift - offset: -0.000725459878720835 - outputs: [y] - - !transform/shift-1.2.0 - inputs: [x] - name: dva_v3_shift - offset: -0.002707956537951509 - outputs: [y] - inputs: [x0, x1] - outputs: [y0, y1] - inputs: [x0, x1] - name: DVA_Correction - outputs: [y0, y1] - - ! - frame: ! - axes_names: [v2, v3] - axes_order: [0, 1] - axis_physical_types: ['custom:v2', 'custom:v3'] - name: v2v3vacorr - unit: [!unit/unit-1.0.0 arcsec, !unit/unit-1.0.0 arcsec] - transform: !transform/compose-1.2.0 - forward: - - !transform/compose-1.2.0 - forward: - - !transform/compose-1.2.0 - forward: - - !transform/concatenate-1.2.0 - forward: - - !transform/scale-1.2.0 - factor: 0.0002777777777777778 - inputs: [x] - outputs: [y] - - !transform/scale-1.2.0 - factor: 0.0002777777777777778 - inputs: [x] - outputs: [y] - inputs: [x0, x1] - outputs: [y0, y1] - - ! - inputs: [lon, lat] - outputs: [x, y, z] - transform_type: spherical_to_cartesian - wrap_lon_at: 180 - inputs: [x0, x1] - outputs: [x, y, z] - - !transform/rotate_sequence_3d-1.0.0 - angles: [-0.034065765, 0.12715880472222224, 144.7989166584132, -73.45620084868634, - -110.8732066925156] - axes_order: zyxyz - inputs: [x, y, z] - outputs: [x, y, z] - rotation_type: cartesian - inputs: [x0, x1] - outputs: [x, y, z] - - ! - inputs: [x, y, z] - outputs: [lon, lat] - transform_type: cartesian_to_spherical - wrap_lon_at: 360 - inputs: [x0, x1] - name: v23tosky - outputs: [lon, lat] - - ! - frame: ! - axes_names: [lon, lat] - axes_order: [0, 1] - axis_physical_types: [pos.eq.ra, pos.eq.dec] - name: world - reference_frame: ! - frame_attributes: {} - unit: [!unit/unit-1.0.0 deg, !unit/unit-1.0.0 deg] - transform: null -... -BLK0   Rq1Ve#.H$I;|tc>lGk-N<1Kk;xo?ܜ;؅[=&'ը<B +9$|->`+d=-}|<W'<e<=n\Z).9<hH̯< a m0OqOBLK0   SnE*듰bnv?St %=[X7}{oK8T ?kyc#Lsn=xJ<3m6w>0pOϣ=SN-<%<<L2 -z=%6<8!yBZɰ:ԬsH<Tz;8BLK0   'b4EA6NI$I2<(SÄo݈"Cl%xؽ|dI@@Jv?]辈`lQQ^l:t>ɂ:jx$S*r(9JHy뇎 C[ZᄆrQK>5ɌgeSd;> g1>BLK0   FP@Wϭ3] -Jq<@@`'De?JȘK㾕Gxcgt=,]͑T>T?z5Ȇ" ת5Q~ =q'aXhK_OGa*Y墾7}}dHT/=}OB"Mj8>BLK0 :9Sӽ7Ӓ*x?BBLK0 g@R Date: Wed, 3 Jul 2024 13:56:15 -0400 Subject: [PATCH 05/36] fix typo from conflict merge --- pyproject.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 79e604ad..51815090 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,9 @@ dynamic = [ "version" ] +[project.license] +file = "LICENSE" + [project.optional-dependencies] docs = [ 'numpydoc', From f6c15c4626f8d3e00cd1c1e17a2c2fabbe497508 Mon Sep 17 00:00:00 2001 From: Ned Molter Date: Wed, 3 Jul 2024 14:02:19 -0400 Subject: [PATCH 06/36] added tweakwcs dependency --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 51815090..51200f80 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ dependencies = [ 'opencv-python-headless >=4.6.0.66', 'asdf >=2.15.0', 'gwcs >= 0.18.1', + 'tweakwcs >=0.8.7', 'requests >=2.32.2', ] dynamic = [ From 7b235adad9f197c3793aa83e26930a7dcc48859c Mon Sep 17 00:00:00 2001 From: Ned Molter Date: Mon, 8 Jul 2024 11:46:17 -0400 Subject: [PATCH 07/36] pinning dependencies --- pyproject.toml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 51200f80..b1ab3fa8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,8 +2,7 @@ name = 'stcal' description = 'STScI tools and algorithms used in calibration pipelines' readme = 'README.md' -requires-python = '>=3.9' -license = { file = 'LICENSE' } +requires-python = '>=3.10' authors = [{ name = 'STScI', email = 'help@stsci.edu' }] classifiers = [ 'Intended Audience :: Science/Research', @@ -14,7 +13,7 @@ classifiers = [ dependencies = [ 'astropy >=5.0.4', 'scipy >=1.6.0', - 'numpy >=1.20', + 'numpy >=1.21.2', 'opencv-python-headless >=4.6.0.66', 'asdf >=2.15.0', 'gwcs >= 0.18.1', From 84bdebafb1521ec60f07e34e59a5b268e5b992f3 Mon Sep 17 00:00:00 2001 From: Ned Molter Date: Mon, 8 Jul 2024 15:24:43 -0400 Subject: [PATCH 08/36] added docs to tweakreg --- .../stcal.alignment.SupportsDataWithWcs.rst | 17 ++ .../stcal.alignment.calc_rotation_matrix.rst | 6 + docs/api/stcal.alignment.compute_fiducial.rst | 6 + docs/api/stcal.alignment.compute_scale.rst | 6 + docs/api/stcal.alignment.reproject.rst | 6 + ...tcal.alignment.update_s_region_imaging.rst | 6 + .../stcal.alignment.wcs_from_footprints.rst | 6 + ...cal.tweakreg.astrometric_utils.TIMEOUT.rst | 6 + ...akreg.astrometric_utils.compute_radius.rst | 6 + ...etric_utils.create_astrometric_catalog.rst | 6 + ...tweakreg.astrometric_utils.get_catalog.rst | 6 + ...stcal.tweakreg.tweakreg.absolute_align.rst | 6 + ...stcal.tweakreg.tweakreg.relative_align.rst | 6 + ...kreg.utils._wcsinfo_from_wcs_transform.rst | 6 + docs/conf.py | 1 + docs/stcal/package_index.rst | 1 + docs/stcal/tweakreg/astrometric_utils.rst | 12 + docs/stcal/tweakreg/description.rst | 228 ++++++++++++++++++ docs/stcal/tweakreg/index.rst | 20 ++ docs/stcal/tweakreg/utils.rst | 11 + pyproject.toml | 4 +- src/stcal/alignment/util.py | 5 +- src/stcal/tweakreg/tweakreg.py | 2 + 23 files changed, 375 insertions(+), 4 deletions(-) create mode 100644 docs/api/stcal.alignment.SupportsDataWithWcs.rst create mode 100644 docs/api/stcal.alignment.calc_rotation_matrix.rst create mode 100644 docs/api/stcal.alignment.compute_fiducial.rst create mode 100644 docs/api/stcal.alignment.compute_scale.rst create mode 100644 docs/api/stcal.alignment.reproject.rst create mode 100644 docs/api/stcal.alignment.update_s_region_imaging.rst create mode 100644 docs/api/stcal.alignment.wcs_from_footprints.rst create mode 100644 docs/api/stcal.tweakreg.astrometric_utils.TIMEOUT.rst create mode 100644 docs/api/stcal.tweakreg.astrometric_utils.compute_radius.rst create mode 100644 docs/api/stcal.tweakreg.astrometric_utils.create_astrometric_catalog.rst create mode 100644 docs/api/stcal.tweakreg.astrometric_utils.get_catalog.rst create mode 100644 docs/api/stcal.tweakreg.tweakreg.absolute_align.rst create mode 100644 docs/api/stcal.tweakreg.tweakreg.relative_align.rst create mode 100644 docs/api/stcal.tweakreg.utils._wcsinfo_from_wcs_transform.rst create mode 100644 docs/stcal/tweakreg/astrometric_utils.rst create mode 100644 docs/stcal/tweakreg/description.rst create mode 100644 docs/stcal/tweakreg/index.rst create mode 100644 docs/stcal/tweakreg/utils.rst diff --git a/docs/api/stcal.alignment.SupportsDataWithWcs.rst b/docs/api/stcal.alignment.SupportsDataWithWcs.rst new file mode 100644 index 00000000..ccbb0f04 --- /dev/null +++ b/docs/api/stcal.alignment.SupportsDataWithWcs.rst @@ -0,0 +1,17 @@ +SupportsDataWithWcs +=================== + +.. currentmodule:: stcal.alignment + +.. autoclass:: SupportsDataWithWcs + :show-inheritance: + + .. rubric:: Methods Summary + + .. autosummary:: + + ~SupportsDataWithWcs.to_flat_dict + + .. rubric:: Methods Documentation + + .. automethod:: to_flat_dict diff --git a/docs/api/stcal.alignment.calc_rotation_matrix.rst b/docs/api/stcal.alignment.calc_rotation_matrix.rst new file mode 100644 index 00000000..f3dedfb0 --- /dev/null +++ b/docs/api/stcal.alignment.calc_rotation_matrix.rst @@ -0,0 +1,6 @@ +calc_rotation_matrix +==================== + +.. currentmodule:: stcal.alignment + +.. autofunction:: calc_rotation_matrix diff --git a/docs/api/stcal.alignment.compute_fiducial.rst b/docs/api/stcal.alignment.compute_fiducial.rst new file mode 100644 index 00000000..6419e034 --- /dev/null +++ b/docs/api/stcal.alignment.compute_fiducial.rst @@ -0,0 +1,6 @@ +compute_fiducial +================ + +.. currentmodule:: stcal.alignment + +.. autofunction:: compute_fiducial diff --git a/docs/api/stcal.alignment.compute_scale.rst b/docs/api/stcal.alignment.compute_scale.rst new file mode 100644 index 00000000..3c2ae099 --- /dev/null +++ b/docs/api/stcal.alignment.compute_scale.rst @@ -0,0 +1,6 @@ +compute_scale +============= + +.. currentmodule:: stcal.alignment + +.. autofunction:: compute_scale diff --git a/docs/api/stcal.alignment.reproject.rst b/docs/api/stcal.alignment.reproject.rst new file mode 100644 index 00000000..fddad980 --- /dev/null +++ b/docs/api/stcal.alignment.reproject.rst @@ -0,0 +1,6 @@ +reproject +========= + +.. currentmodule:: stcal.alignment + +.. autofunction:: reproject diff --git a/docs/api/stcal.alignment.update_s_region_imaging.rst b/docs/api/stcal.alignment.update_s_region_imaging.rst new file mode 100644 index 00000000..a36b9cdb --- /dev/null +++ b/docs/api/stcal.alignment.update_s_region_imaging.rst @@ -0,0 +1,6 @@ +update_s_region_imaging +======================= + +.. currentmodule:: stcal.alignment + +.. autofunction:: update_s_region_imaging diff --git a/docs/api/stcal.alignment.wcs_from_footprints.rst b/docs/api/stcal.alignment.wcs_from_footprints.rst new file mode 100644 index 00000000..d7c22e4f --- /dev/null +++ b/docs/api/stcal.alignment.wcs_from_footprints.rst @@ -0,0 +1,6 @@ +wcs_from_footprints +=================== + +.. currentmodule:: stcal.alignment + +.. autofunction:: wcs_from_footprints diff --git a/docs/api/stcal.tweakreg.astrometric_utils.TIMEOUT.rst b/docs/api/stcal.tweakreg.astrometric_utils.TIMEOUT.rst new file mode 100644 index 00000000..4fb96d58 --- /dev/null +++ b/docs/api/stcal.tweakreg.astrometric_utils.TIMEOUT.rst @@ -0,0 +1,6 @@ +TIMEOUT +======= + +.. currentmodule:: stcal.tweakreg.astrometric_utils + +.. autodata:: TIMEOUT diff --git a/docs/api/stcal.tweakreg.astrometric_utils.compute_radius.rst b/docs/api/stcal.tweakreg.astrometric_utils.compute_radius.rst new file mode 100644 index 00000000..7f62bdf4 --- /dev/null +++ b/docs/api/stcal.tweakreg.astrometric_utils.compute_radius.rst @@ -0,0 +1,6 @@ +compute_radius +============== + +.. currentmodule:: stcal.tweakreg.astrometric_utils + +.. autofunction:: compute_radius diff --git a/docs/api/stcal.tweakreg.astrometric_utils.create_astrometric_catalog.rst b/docs/api/stcal.tweakreg.astrometric_utils.create_astrometric_catalog.rst new file mode 100644 index 00000000..dc1fc120 --- /dev/null +++ b/docs/api/stcal.tweakreg.astrometric_utils.create_astrometric_catalog.rst @@ -0,0 +1,6 @@ +create_astrometric_catalog +========================== + +.. currentmodule:: stcal.tweakreg.astrometric_utils + +.. autofunction:: create_astrometric_catalog diff --git a/docs/api/stcal.tweakreg.astrometric_utils.get_catalog.rst b/docs/api/stcal.tweakreg.astrometric_utils.get_catalog.rst new file mode 100644 index 00000000..88016fd7 --- /dev/null +++ b/docs/api/stcal.tweakreg.astrometric_utils.get_catalog.rst @@ -0,0 +1,6 @@ +get_catalog +=========== + +.. currentmodule:: stcal.tweakreg.astrometric_utils + +.. autofunction:: get_catalog diff --git a/docs/api/stcal.tweakreg.tweakreg.absolute_align.rst b/docs/api/stcal.tweakreg.tweakreg.absolute_align.rst new file mode 100644 index 00000000..a57dfe3b --- /dev/null +++ b/docs/api/stcal.tweakreg.tweakreg.absolute_align.rst @@ -0,0 +1,6 @@ +absolute_align +============== + +.. currentmodule:: stcal.tweakreg.tweakreg + +.. autofunction:: absolute_align diff --git a/docs/api/stcal.tweakreg.tweakreg.relative_align.rst b/docs/api/stcal.tweakreg.tweakreg.relative_align.rst new file mode 100644 index 00000000..2a307688 --- /dev/null +++ b/docs/api/stcal.tweakreg.tweakreg.relative_align.rst @@ -0,0 +1,6 @@ +relative_align +============== + +.. currentmodule:: stcal.tweakreg.tweakreg + +.. autofunction:: relative_align diff --git a/docs/api/stcal.tweakreg.utils._wcsinfo_from_wcs_transform.rst b/docs/api/stcal.tweakreg.utils._wcsinfo_from_wcs_transform.rst new file mode 100644 index 00000000..51f5370c --- /dev/null +++ b/docs/api/stcal.tweakreg.utils._wcsinfo_from_wcs_transform.rst @@ -0,0 +1,6 @@ +_wcsinfo_from_wcs_transform +=========================== + +.. currentmodule:: stcal.tweakreg.utils + +.. autofunction:: _wcsinfo_from_wcs_transform diff --git a/docs/conf.py b/docs/conf.py index 2f891aba..2ee837e5 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -47,6 +47,7 @@ def setup(app): "matplotlib": ("https://matplotlib.org/stable", None), "gwcs": ("https://gwcs.readthedocs.io/en/latest/", None), "astropy": ("https://docs.astropy.org/en/stable/", None), + "tweakwcs": ("https://tweakwcs.readthedocs.io/en/latest/", None), } extensions = [ diff --git a/docs/stcal/package_index.rst b/docs/stcal/package_index.rst index e47e63c7..5af815e2 100644 --- a/docs/stcal/package_index.rst +++ b/docs/stcal/package_index.rst @@ -7,3 +7,4 @@ Package Index jump/index.rst ramp_fitting/index.rst alignment/index.rst + tweakreg/index.rst diff --git a/docs/stcal/tweakreg/astrometric_utils.rst b/docs/stcal/tweakreg/astrometric_utils.rst new file mode 100644 index 00000000..90d5f6f2 --- /dev/null +++ b/docs/stcal/tweakreg/astrometric_utils.rst @@ -0,0 +1,12 @@ +================= +astrometric_utils +================= + +The ``astrometric_utils`` module provides functions for generating +astrometric catalogs of sources for the field-of-view covered by a +set of images. + +.. currentmodule:: stcal.tweakreg.astrometric_utils + +.. automodapi:: stcal.tweakreg.astrometric_utils + :include-all-objects: diff --git a/docs/stcal/tweakreg/description.rst b/docs/stcal/tweakreg/description.rst new file mode 100644 index 00000000..c766f131 --- /dev/null +++ b/docs/stcal/tweakreg/description.rst @@ -0,0 +1,228 @@ +Description +=========== + +Overview +-------- +This step takes as input image catalogs of point-like sources, +which are used to compute corrections to the WCS of +the input images such that sky catalogs obtained from the +image catalogs using the corrected WCS will align on the sky. + +Source Catalogs +--------------- +The input catalog must be in a format automatically recognized by +:py:meth:`~astropy.table.Table.read`. The catalog must contain +either ``'x'`` and ``'y'`` or ``'xcentroid'`` and ``'ycentroid'`` columns which +indicate source *image* coordinates (in pixels). Pixel coordinates are +0-indexed. An optional column in the catalog is the ``'weight'`` column, +which when present, will be used in fitting. + +Relative Alignment +------------------ +Relative alignment is performed by +the `~stcal.tweakreg.relative_align` function. +The source catalogs for each input image are compared to each other +and linear (affine) coordinate transformations that align these +catalogs are derived. This fit ensures that all the input images +are aligned relative to each other. This step produces a combined +source catalog for the entire set of input images as if they were +combined into a single mosaic. + +Absolute Alignment +------------------ + +Absolute alignment is performed by +the `~stcal.tweakreg.absolute_align` function. +If the parameter ``abs_refcat`` is set to 'GAIADR3', 'GAIADR2', or 'GAIADR1', +an astrometric reference catalog then gets generated by querying +a GAIA-based astrometric catalog web service for all astrometrically +measured sources in the combined field-of-view of the set of input +images. This catalog is generated from the catalogs available +through the `STScI MAST Catalogs`_ and has the ability to account +for proper motion to a given epoch. The epoch is computed from the observation date and time +of the input data. + +.. _STScI MAST Catalogs: https://outerspace.stsci.edu/display/MASTDATA/Catalog+Access + +The combined source catalog +then gets cross-matched and fit to this astrometric reference catalog. +The pipeline initially supports fitting to the +GAIADR3 catalog, with the option to select the GAIADR2 or GAIADR1 instead. +The results of this one fit then gets back-propagated to all the +input images to align them all to the astrometric reference frame while +maintaining the relative alignment between the images. + +For this part of alignment, instead of 'GAIADR1', 'GAIADR2', or 'GAIADR3', users can +supply an external reference catalog by providing a path to an existing +file. A user-supplied catalog must contain ``'RA'`` and ``'DEC'`` columns +indicating reference source world coordinates (in degrees). An optional column +in the catalog is the ``'weight'`` column, which when present, will be used +in fitting. The catalog must be in a format automatically recognized by +:py:meth:`~astropy.table.Table.read`. + +Grouping +-------- + +Images taken at the same time (e.g., NIRCam images from all short-wave +detectors) can be aligned together; that is, a single correction +can be computed and applied to all these images because any error in +telescope pointing will be identical in all these images and it is assumed +that the relative positions of (e.g., NIRCam) detectors do not change. +Identification of images that belong to the same "exposure" and therefore +can be grouped together is based on several attributes +*change from here to end of section* described in +`~jwst.datamodels.ModelContainer`. This grouping is performed automatically +in the ``tweakreg`` step using the +`~jwst.datamodels.ModelContainer.models_grouped` property, which assigns +a group ID to each input image model in ``meta.group_id``. + +However, when detector calibrations are not accurate, alignment of groups +of images may fail (or result in poor alignment). In this case, it may be +desirable to align each image independently. This can be achieved either by +setting the ``image_model.meta.group_id`` attribute to a unique string or integer +value for each image, or by adding the ``group_id`` attribute to the ``members`` of the input ASN +table - see `~jwst.datamodels.ModelContainer` for more details. + +.. note:: + Group ID (``group_id``) is used by both ``tweakreg`` and ``skymatch`` steps + and so modifying it for one step will affect the results in another step. + If it is desirable to apply different grouping strategies to the ``tweakreg`` + and ``skymatch`` steps, one may need to run each step individually and + provide a different ASN as input to each step. + +WCS Correction +-------------- +The linear coordinate transformation computed in the previous step +is used to define tangent-plane corrections that need to be applied +to the GWCS pipeline in order to correct input image WCS. +This correction is implemented by inserting a ``v2v3corr`` frame with +tangent plane corrections into the GWCS pipeline of the image's WCS. + +Arguments +--------- +The ``tweakreg`` step has the following optional arguments: + + +**Relative alignment parameters:** + +Parameters used for relative alignment between input images. These parameters +are passed into the `~stcal.tweakreg.relative_align` function. + +* ``expand_refcat``: A boolean indicating whether or not to expand reference + catalog with new sources from other input images that have been already + aligned to the reference image. (Default=False) + +* ``minobj``: A positive `int` indicating minimum number of objects acceptable + for matching. (Default=15) + +* ``searchrad``: A `float` indicating the search radius in arcsec for a match. + (Default=2.0) + +* ``use2dhist``: A boolean indicating whether to use 2D histogram to find + initial offset. (Default=True) + +* ``separation``: Minimum object separation in arcsec. It **must be** at least + ``sqrt(2)`` times larger than ``tolerance``. (Default=1.0) + +* ``tolerance``: Matching tolerance for ``xyxymatch`` in arcsec. (Default=0.7) + +* ``xoffset``: Initial guess for X offset in arcsec. (Default=0.0) + +* ``yoffset``: Initial guess for Y offset in arcsec. (Default=0.0) + +* ``fitgeometry``: A `str` value indicating the type of affine transformation + to be considered when fitting catalogs. Allowed values: + + - ``'shift'``: x/y shifts only + - ``'rshift'``: rotation and shifts + - ``'rscale'``: rotation and scale + - ``'general'``: shift, rotation, and scale + + The default value is "rshift". + + .. note:: + Mathematically, alignment of images observed in different tangent planes + requires ``fitgeometry='general'`` in order to fit source catalogs + in the different images even if mis-alignment is caused only by a shift + or rotation in the tangent plane of one of the images. + + However, under certain circumstances, such as small alignment errors or + minimal dithering during observations that keep tangent planes of the + images to be aligned almost parallel, then it may be more robust to + use a ``fitgeometry`` setting with fewer degrees of freedom such as + ``'rshift'``, especially for "ill-conditioned" source catalogs such as + catalogs with very few sources, or large errors in source positions, or + sources placed along a line or bunched in a corner of the image (not + spread across/covering the entire image). + +* ``nclip``: A non-negative integer number of clipping iterations + to use in the fit. (Default=3) + +* ``sigma``: A positive `float` indicating the clipping limit, in sigma units, + used when performing fit. (Default=3.0) + +**Absolute Astrometric fitting parameters:** + +Parameters used for absolute astrometry to a reference catalog. +These parameters +are passed into the `~stcal.tweakreg.absolute_align` function. + +* ``abs_refcat``: String indicating what astrometric catalog should be used. + Currently supported options: 'GAIADR1', 'GAIADR2', 'GAIADR3', a path to an existing + reference catalog, `None`, or `''`. See + :py:data:`stcal.tweakreg.tweakreg.SINGLE_GROUP_REFCAT` + for an up-to-date list of supported built-in reference catalogs. + + When ``abs_refcat`` is `None` or an empty string, alignment to the + absolute astrometry catalog will be turned off. + (Default= `''`) + +* ``abs_minobj``: A positive `int` indicating minimum number of objects + acceptable for matching. (Default=15) + +* ``abs_searchrad``: A `float` indicating the search radius in arcsec for + a match. It is recommended that a value larger than ``searchrad`` be used for + this parameter (e.g. 3 times larger) (Default=6.0) + +* ``abs_use2dhist``: A boolean indicating whether to use 2D histogram to find + initial offset. It is strongly recommended setting this parameter to `True`. + Otherwise the initial guess for the offsets will be set to zero + (Default=True) + +* ``abs_separation``: Minimum object separation in arcsec. It **must be** at + least ``sqrt(2)`` times larger than ``abs_tolerance``. (Default=1.0) + +* ``abs_tolerance``: Matching tolerance for ``xyxymatch`` in arcsec. + (Default=0.7) + +* ``abs_fitgeometry``: A `str` value indicating the type of affine + transformation to be considered when fitting catalogs. Allowed values: + + - ``'shift'``: x/y shifts only + - ``'rshift'``: rotation and shifts + - ``'rscale'``: rotation and scale + - ``'general'``: shift, rotation, and scale + + The default value is "rshift". Note that the same conditions/restrictions + that apply to ``fitgeometry`` also apply to ``abs_fitgeometry``. + +* ``abs_nclip``: A non-negative integer number of clipping iterations + to use in the fit. (Default=3) + +* ``abs_sigma``: A positive `float` indicating the clipping limit, in sigma + units, used when performing fit. (Default=3.0) + +* ``save_abs_catalog``: A boolean specifying whether or not to write out the + astrometric catalog used for the fit as a separate product. (Default=False) + +Further Documentation +--------------------- +The underlying algorithms as well as formats of source catalogs are described +in more detail at + +https://tweakwcs.readthedocs.io/en/latest/ + +.. currentmodule:: stcal.tweakreg.tweakreg + +.. automodapi:: stcal.tweakreg.tweakreg + :noindex: diff --git a/docs/stcal/tweakreg/index.rst b/docs/stcal/tweakreg/index.rst new file mode 100644 index 00000000..ce401fe1 --- /dev/null +++ b/docs/stcal/tweakreg/index.rst @@ -0,0 +1,20 @@ +.. _tweakreg_step: + +======== +TweakReg +======== + +.. toctree:: + :maxdepth: 2 + + description.rst + +**Also See:** + +.. toctree:: + :maxdepth: 1 + + utils.rst + astrometric_utils.rst + +.. automodapi:: stcal.tweakreg \ No newline at end of file diff --git a/docs/stcal/tweakreg/utils.rst b/docs/stcal/tweakreg/utils.rst new file mode 100644 index 00000000..8b18a758 --- /dev/null +++ b/docs/stcal/tweakreg/utils.rst @@ -0,0 +1,11 @@ +================= +Utility Functions +================= + +Currently, the ``utils`` module provides helpful functions for +manually applying corrections to an imaging WCS. + +.. currentmodule:: stcal.tweakreg.utils + +.. automodapi:: stcal.tweakreg.utils + :noindex: diff --git a/pyproject.toml b/pyproject.toml index 1bdd2b2d..528898fe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,8 +19,8 @@ dependencies = [ "opencv-python-headless >=4.6.0.66", "asdf >=2.15.0", "gwcs >= 0.18.1", - "tweakwcs >=0.8.7", - "requests >=2.32.2", + "tweakwcs >=0.8.6", + "requests >=2.22", ] dynamic = [ "version", diff --git a/src/stcal/alignment/util.py b/src/stcal/alignment/util.py index 993207cb..ede5e29a 100644 --- a/src/stcal/alignment/util.py +++ b/src/stcal/alignment/util.py @@ -3,7 +3,7 @@ import functools import logging -from typing import TYPE_CHECKING, Protocol +from typing import TYPE_CHECKING, Protocol, runtime_checkable import gwcs import numpy as np @@ -31,6 +31,7 @@ ] +@runtime_checkable class SupportsDataWithWcs(Protocol): _asdf: AsdfFile @@ -671,7 +672,7 @@ def wcs_from_footprints( def update_s_region_imaging(model: SupportsDataWithWcs, center=True): """ - Update the ``S_REGION`` keyword using ``WCS.footprint``. + Update the ``S_REGION`` keyword using the WCS footprint. Parameters ---------- diff --git a/src/stcal/tweakreg/tweakreg.py b/src/stcal/tweakreg/tweakreg.py index 28ac64b3..f2c13dda 100644 --- a/src/stcal/tweakreg/tweakreg.py +++ b/src/stcal/tweakreg/tweakreg.py @@ -33,6 +33,8 @@ def _oxford_or_str_join(str_list): SINGLE_GROUP_REFCAT = ["GAIADR3", "GAIADR2", "GAIADR1"] _SINGLE_GROUP_REFCAT_STR = _oxford_or_str_join(SINGLE_GROUP_REFCAT) +__all__ = ["relative_align", "absolute_align", "SINGLE_GROUP_REFCAT"] + class TweakregError(BaseException): pass From da9c4ba60a1b9e6db2dd8a30f36038318d682b3f Mon Sep 17 00:00:00 2001 From: Ned Molter Date: Mon, 8 Jul 2024 15:28:42 -0400 Subject: [PATCH 09/36] add changelog entry --- CHANGES.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 57c7545a..b6fbe842 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -4,7 +4,7 @@ General ------- -- +- Add TweakReg submodule. [#267] Changes to API -------------- From c3d54f3c1ad625a426571d1ec0d0fb6b2af7e826 Mon Sep 17 00:00:00 2001 From: Ned Molter Date: Tue, 9 Jul 2024 13:16:09 -0400 Subject: [PATCH 10/36] added more tests of tweakreg --- src/stcal/tweakreg/tweakreg.py | 4 +- tests/data/nrcb1-wcs.asdf | 383 +++++++++++++++++++++++++++++++++ tests/test_tweakreg.py | 175 ++++++++++++++- 3 files changed, 556 insertions(+), 6 deletions(-) create mode 100644 tests/data/nrcb1-wcs.asdf diff --git a/src/stcal/tweakreg/tweakreg.py b/src/stcal/tweakreg/tweakreg.py index f2c13dda..b935a9f0 100644 --- a/src/stcal/tweakreg/tweakreg.py +++ b/src/stcal/tweakreg/tweakreg.py @@ -228,7 +228,7 @@ def absolute_align(correctors: list[JWSTWCSCorrector], return correctors -def _parse_refcat(abs_refcat: str, +def _parse_refcat(abs_refcat: str | Path, ref_model: SupportsDataWithWcs, correctors: list, save_abs_catalog: bool = False, @@ -246,7 +246,7 @@ def _parse_refcat(abs_refcat: str, else: output_name = None - abs_refcat = abs_refcat.strip() + abs_refcat = str(abs_refcat).strip() gaia_cat_name = abs_refcat.upper() if gaia_cat_name in SINGLE_GROUP_REFCAT: diff --git a/tests/data/nrcb1-wcs.asdf b/tests/data/nrcb1-wcs.asdf new file mode 100644 index 00000000..0588294e --- /dev/null +++ b/tests/data/nrcb1-wcs.asdf @@ -0,0 +1,383 @@ +#ASDF 1.0.0 +#ASDF_STANDARD 1.5.0 +%YAML 1.1 +%TAG ! tag:stsci.edu:asdf/ +--- !core/asdf-1.1.0 +asdf_library: !core/software-1.0.0 {author: The ASDF Developers, homepage: 'http://github.com/asdf-format/asdf', + name: asdf, version: 2.14.1} +history: + extensions: + - !core/extension_metadata-1.0.0 + extension_class: asdf.extension._manifest.ManifestExtension + extension_uri: asdf://asdf-format.org/transform/extensions/transform-1.5.0 + software: !core/software-1.0.0 {name: asdf-astropy, version: 0.3.0} + - !core/extension_metadata-1.0.0 + extension_class: asdf.extension.BuiltinExtension + software: !core/software-1.0.0 {name: asdf, version: 2.14.1} + - !core/extension_metadata-1.0.0 + extension_class: asdf.extension._manifest.ManifestExtension + extension_uri: asdf://asdf-format.org/astronomy/gwcs/extensions/gwcs-1.0.0 + software: !core/software-1.0.0 {name: gwcs, version: 0.18.3} + - !core/extension_metadata-1.0.0 + extension_class: asdf.extension._manifest.ManifestExtension + extension_uri: asdf://asdf-format.org/core/extensions/core-1.5.0 + software: !core/software-1.0.0 {name: asdf-astropy, version: 0.3.0} + - !core/extension_metadata-1.0.0 + extension_class: asdf.extension._manifest.ManifestExtension + extension_uri: asdf://asdf-format.org/astronomy/coordinates/extensions/coordinates-1.0.0 + software: !core/software-1.0.0 {name: asdf-astropy, version: 0.3.0} +wcs: ! + name: '' + steps: + - ! + frame: ! + axes_names: [x, y] + axes_order: [0, 1] + axis_physical_types: ['custom:x', 'custom:y'] + name: detector + unit: [!unit/unit-1.0.0 pixel, !unit/unit-1.0.0 pixel] + transform: !transform/compose-1.2.0 + bounding_box: !transform/property/bounding_box-1.0.0 + ignore: [] + intervals: + x0: [-0.5, 2047.5] + x1: [-0.5, 2047.5] + order: C + forward: + - !transform/concatenate-1.2.0 + forward: + - !transform/shift-1.2.0 + inputs: [x] + offset: 1.016 + outputs: [y] + - !transform/shift-1.2.0 + inputs: [x] + offset: 1.625 + outputs: [y] + inputs: [x0, x1] + outputs: [y0, y1] + - !transform/compose-1.2.0 + forward: + - !transform/compose-1.2.0 + forward: + - !transform/compose-1.2.0 + forward: + - !transform/concatenate-1.2.0 + forward: + - &id001 !transform/shift-1.2.0 + inputs: [x] + offset: 1.0 + outputs: [y] + - *id001 + inputs: [x0, x1] + outputs: [y0, y1] + - !transform/concatenate-1.2.0 + forward: + - !transform/shift-1.2.0 + inputs: [x] + offset: -1024.5 + outputs: [y] + - !transform/shift-1.2.0 + inputs: [x] + offset: -1024.5 + outputs: [y] + inputs: [x0, x1] + outputs: [y0, y1] + inputs: [x0, x1] + outputs: [y0, y1] + - !transform/compose-1.2.0 + forward: + - !transform/compose-1.2.0 + forward: + - !transform/remap_axes-1.3.0 + inputs: [x0, x1] + mapping: [0, 1, 0, 1] + outputs: [x0, x1, x2, x3] + - !transform/concatenate-1.2.0 + forward: + - !transform/polynomial-1.2.0 + coefficients: !core/ndarray-1.0.0 + source: 0 + datatype: float64 + byteorder: little + shape: [6, 6] + domain: + - [-1, 1] + - [-1, 1] + inputs: [x, y] + outputs: [z] + window: + - [-1, 1] + - [-1, 1] + - !transform/polynomial-1.2.0 + coefficients: !core/ndarray-1.0.0 + source: 1 + datatype: float64 + byteorder: little + shape: [6, 6] + domain: + - [-1, 1] + - [-1, 1] + inputs: [x, y] + outputs: [z] + window: + - [-1, 1] + - [-1, 1] + inputs: [x0, y0, x1, y1] + outputs: [z0, z1] + inputs: [x0, x1] + inverse: !transform/compose-1.2.0 + forward: + - !transform/remap_axes-1.3.0 + inputs: [x0, x1] + mapping: [0, 1, 0, 1] + outputs: [x0, x1, x2, x3] + - !transform/concatenate-1.2.0 + forward: + - !transform/polynomial-1.2.0 + coefficients: !core/ndarray-1.0.0 + source: 2 + datatype: float64 + byteorder: little + shape: [6, 6] + domain: + - [-1, 1] + - [-1, 1] + inputs: [x, y] + outputs: [z] + window: + - [-1, 1] + - [-1, 1] + - !transform/polynomial-1.2.0 + coefficients: !core/ndarray-1.0.0 + source: 3 + datatype: float64 + byteorder: little + shape: [6, 6] + domain: + - [-1, 1] + - [-1, 1] + inputs: [x, y] + outputs: [z] + window: + - [-1, 1] + - [-1, 1] + inputs: [x0, y0, x1, y1] + outputs: [z0, z1] + inputs: [x0, x1] + outputs: [z0, z1] + outputs: [z0, z1] + - !transform/compose-1.2.0 + forward: + - !transform/remap_axes-1.3.0 + inputs: [x0, x1] + mapping: [0, 1, 0, 1] + outputs: [x0, x1, x2, x3] + - !transform/concatenate-1.2.0 + forward: + - !transform/polynomial-1.2.0 + coefficients: !core/ndarray-1.0.0 + source: 4 + datatype: float64 + byteorder: little + shape: [2, 2] + domain: + - [-1, 1] + - [-1, 1] + inputs: [x, y] + outputs: [z] + window: + - [-1, 1] + - [-1, 1] + - !transform/polynomial-1.2.0 + coefficients: !core/ndarray-1.0.0 + source: 5 + datatype: float64 + byteorder: little + shape: [2, 2] + domain: + - [-1, 1] + - [-1, 1] + inputs: [x, y] + outputs: [z] + window: + - [-1, 1] + - [-1, 1] + inputs: [x0, y0, x1, y1] + outputs: [z0, z1] + inputs: [x0, x1] + inverse: !transform/compose-1.2.0 + forward: + - !transform/remap_axes-1.3.0 + inputs: [x0, x1] + mapping: [0, 1, 0, 1] + outputs: [x0, x1, x2, x3] + - !transform/concatenate-1.2.0 + forward: + - !transform/polynomial-1.2.0 + coefficients: !core/ndarray-1.0.0 + source: 6 + datatype: float64 + byteorder: little + shape: [2, 2] + domain: + - [-1, 1] + - [-1, 1] + inputs: [x, y] + outputs: [z] + window: + - [-1, 1] + - [-1, 1] + - !transform/polynomial-1.2.0 + coefficients: !core/ndarray-1.0.0 + source: 7 + datatype: float64 + byteorder: little + shape: [2, 2] + domain: + - [-1, 1] + - [-1, 1] + inputs: [x, y] + outputs: [z] + window: + - [-1, 1] + - [-1, 1] + inputs: [x0, y0, x1, y1] + outputs: [z0, z1] + inputs: [x0, x1] + outputs: [z0, z1] + outputs: [z0, z1] + inputs: [x0, x1] + outputs: [z0, z1] + inputs: [x0, x1] + outputs: [z0, z1] + - !transform/concatenate-1.2.0 + forward: + - !transform/shift-1.2.0 + inputs: [x] + offset: -122.37890283 + outputs: [y] + - !transform/shift-1.2.0 + inputs: [x] + offset: -457.7308175 + outputs: [y] + inputs: [x0, x1] + outputs: [y0, y1] + inputs: [x0, x1] + outputs: [y0, y1] + inputs: [x0, x1] + outputs: [y0, y1] + - ! + frame: ! + axes_names: [v2, v3] + axes_order: [0, 1] + axis_physical_types: ['custom:v2', 'custom:v3'] + name: v2v3 + unit: [!unit/unit-1.0.0 arcsec, !unit/unit-1.0.0 arcsec] + transform: !transform/compose-1.2.0 + forward: + - !transform/concatenate-1.2.0 + forward: + - !transform/scale-1.2.0 + factor: 0.9999940844823835 + inputs: [x] + name: dva_scale_v2 + outputs: [y] + - !transform/scale-1.2.0 + factor: 0.9999940844823835 + inputs: [x] + name: dva_scale_v3 + outputs: [y] + inputs: [x0, x1] + outputs: [y0, y1] + - !transform/concatenate-1.2.0 + forward: + - !transform/shift-1.2.0 + inputs: [x] + name: dva_v2_shift + offset: -0.000725459878720835 + outputs: [y] + - !transform/shift-1.2.0 + inputs: [x] + name: dva_v3_shift + offset: -0.002707956537951509 + outputs: [y] + inputs: [x0, x1] + outputs: [y0, y1] + inputs: [x0, x1] + name: DVA_Correction + outputs: [y0, y1] + - ! + frame: ! + axes_names: [v2, v3] + axes_order: [0, 1] + axis_physical_types: ['custom:v2', 'custom:v3'] + name: v2v3vacorr + unit: [!unit/unit-1.0.0 arcsec, !unit/unit-1.0.0 arcsec] + transform: !transform/compose-1.2.0 + forward: + - !transform/compose-1.2.0 + forward: + - !transform/compose-1.2.0 + forward: + - !transform/concatenate-1.2.0 + forward: + - !transform/scale-1.2.0 + factor: 0.0002777777777777778 + inputs: [x] + outputs: [y] + - !transform/scale-1.2.0 + factor: 0.0002777777777777778 + inputs: [x] + outputs: [y] + inputs: [x0, x1] + outputs: [y0, y1] + - ! + inputs: [lon, lat] + outputs: [x, y, z] + transform_type: spherical_to_cartesian + wrap_lon_at: 180 + inputs: [x0, x1] + outputs: [x, y, z] + - !transform/rotate_sequence_3d-1.0.0 + angles: [-0.034065765, 0.12715880472222224, 144.7989166584132, -73.45620084868634, + -110.8732066925156] + axes_order: zyxyz + inputs: [x, y, z] + outputs: [x, y, z] + rotation_type: cartesian + inputs: [x0, x1] + outputs: [x, y, z] + - ! + inputs: [x, y, z] + outputs: [lon, lat] + transform_type: cartesian_to_spherical + wrap_lon_at: 360 + inputs: [x0, x1] + name: v23tosky + outputs: [lon, lat] + - ! + frame: ! + axes_names: [lon, lat] + axes_order: [0, 1] + axis_physical_types: [pos.eq.ra, pos.eq.dec] + name: world + reference_frame: ! + frame_attributes: {} + unit: [!unit/unit-1.0.0 deg, !unit/unit-1.0.0 deg] + transform: null +... +BLK0   Rq1Ve#.H$I;|tc>lGk-N<1Kk;xo?ܜ;؅[=&'ը<B +9$|->`+d=-}|<W'<e<=n\Z).9<hH̯< a m0OqOBLK0   SnE*듰bnv?St %=[X7}{oK8T ?kyc#Lsn=xJ<3m6w>0pOϣ=SN-<%<<L2 +z=%6<8!yBZɰ:ԬsH<Tz;8BLK0   'b4EA6NI$I2<(SÄo݈"Cl%xؽ|dI@@Jv?]辈`lQQ^l:t>ɂ:jx$S*r(9JHy뇎 C[ZᄆrQK>5ɌgeSd;> g1>BLK0   FP@Wϭ3] +Jq<@@`'De?JȘK㾕Gxcgt=,]͑T>T?z5Ȇ" ת5Q~ =q'aXhK_OGa*Y墾7}}dHT/=}OB"Mj8>BLK0 :9Sӽ7Ӓ*x?BBLK0 g@R 1E-5 + else: + assert abs_delta < 1E-12 + + +def test_absolute_align(example_input, input_catalog): + + correctors = [construct_wcs_corrector(dm, input_catalog) for dm in example_input] + + result = absolute_align(correctors, TEST_CATALOG, example_input[0], abs_minobj=5) + for res in result: + assert res.meta["group_id"] == 987654 + + abs_delta = abs(result[1].wcs(0, 0)[0] - result[0].wcs(0, 0)[0]) + assert abs_delta < 1E-12 \ No newline at end of file From 7c818c644d920b688f986e15273b066cf2bda3d3 Mon Sep 17 00:00:00 2001 From: Ned Molter Date: Tue, 9 Jul 2024 14:34:28 -0400 Subject: [PATCH 11/36] fixing failed unit tests --- src/stcal/tweakreg/tweakreg.py | 8 ++++---- tests/test_tweakreg.py | 24 +++++++++++++++++------- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/src/stcal/tweakreg/tweakreg.py b/src/stcal/tweakreg/tweakreg.py index b935a9f0..2b6cab2d 100644 --- a/src/stcal/tweakreg/tweakreg.py +++ b/src/stcal/tweakreg/tweakreg.py @@ -40,7 +40,7 @@ class TweakregError(BaseException): pass -def relative_align(correctors: list[JWSTWCSCorrector], +def relative_align(correctors: list, searchrad: float = 2.0, separation: float = 1.0, use2dhist: bool = True, @@ -54,7 +54,7 @@ def relative_align(correctors: list[JWSTWCSCorrector], nclip: int = 3, sigma: float = 3.0, align_to_abs_refcat: bool = False, - ) -> tuple[list[JWSTWCSCorrector], bool]: + ) -> tuple[list, bool]: if separation <= _SQRT2 * tolerance: msg = "Parameter 'separation' must be larger than 'tolerance' by at \ @@ -124,7 +124,7 @@ def relative_align(correctors: list[JWSTWCSCorrector], return correctors, local_align_failed -def absolute_align(correctors: list[JWSTWCSCorrector], +def absolute_align(correctors: list, abs_refcat: str, ref_image: SupportsDataWithWcs, save_abs_catalog: bool = False, @@ -137,7 +137,7 @@ def absolute_align(correctors: list[JWSTWCSCorrector], abs_fitgeometry: str = "rshift", abs_nclip: int = 3, abs_sigma: float = 3.0, - local_align_failed: bool = False,) -> list[JWSTWCSCorrector]: + local_align_failed: bool = False,) -> list: if abs_separation <= _SQRT2 * abs_tolerance: msg = "Parameter 'abs_separation' must be larger than 'abs_tolerance' by at \ diff --git a/tests/test_tweakreg.py b/tests/test_tweakreg.py index 681dcb2c..9a7adb69 100644 --- a/tests/test_tweakreg.py +++ b/tests/test_tweakreg.py @@ -24,10 +24,13 @@ ) # Define input GWCS specification to be used for these tests -#WCS_NAME = "mosaic_long_i2d_gwcs.asdf" # Derived using B7.5 Level 3 product -WCS_NAME = "nrcb1-wcs.asdf" +WCS_NAME = "mosaic_long_i2d_gwcs.asdf" # Derived using B7.5 Level 3 product EXPECTED_NUM_SOURCES = 2469 EXPECTED_RADIUS = 0.02564497890604383 + +# more recent WCS with a defined input frame is necessary for some tests +WCS_NAME_2 = "nrcb1-wcs.asdf" + TEST_CATALOG = "GAIADR3" CATALOG_FNAME = "ref_cat.ecsv" DATADIR = "data" @@ -44,6 +47,13 @@ def wcsobj(): return asdf_file.tree["wcs"] +@pytest.fixture(scope="module") +def wcsobj2(): + path = Path(__file__).parent / DATADIR / WCS_NAME_2 + with asdf.open(path, lazy_load=False) as asdf_file: + return asdf_file.tree["wcs"] + + def test_radius(wcsobj): # compute radius radius, fiducial = amutils.compute_radius(wcsobj) @@ -102,7 +112,7 @@ def fake_correctors(offset): step.transform = step.transform | Shift(offset) & Shift(offset) twcs.bounding_box = wcs.bounding_box - class FakeCorrector(WCSCorrector): + class FakeCorrector: def __init__(self, wcs, original_skycoord): self.wcs = wcs self._original_skycoord = original_skycoord @@ -182,8 +192,8 @@ def to_flat_dict(): @pytest.fixture(scope="module") -def datamodel(wcsobj, group_id=None): - return MinimalDataWithWCS(wcsobj, group_id=group_id) +def datamodel(wcsobj2, group_id=None): + return MinimalDataWithWCS(wcsobj2, group_id=group_id) def test_parse_refcat(datamodel): @@ -227,9 +237,9 @@ def input_catalog(datamodel): @pytest.fixture(scope="module") -def example_input(wcsobj): +def example_input(wcsobj2): - m0 = MinimalDataWithWCS(wcsobj) + m0 = MinimalDataWithWCS(wcsobj2) m0.data[:] = BKG_LEVEL n_sources = N_EXAMPLE_SOURCES rng = np.random.default_rng(26) From ce4fb465024c4db02aa9115caa4f96fd3ac5e334 Mon Sep 17 00:00:00 2001 From: Ned Molter Date: Tue, 9 Jul 2024 18:01:39 -0400 Subject: [PATCH 12/36] refactor to remove local_align_failed parameter --- src/stcal/alignment/util.py | 1 + src/stcal/tweakreg/tweakreg.py | 83 ++++++++++++++-------------------- tests/test_tweakreg.py | 3 +- 3 files changed, 36 insertions(+), 51 deletions(-) diff --git a/src/stcal/alignment/util.py b/src/stcal/alignment/util.py index ede5e29a..def6a0eb 100644 --- a/src/stcal/alignment/util.py +++ b/src/stcal/alignment/util.py @@ -25,6 +25,7 @@ "compute_fiducial", "calc_rotation_matrix", "update_s_region_imaging", + "update_s_region_keyword", "wcs_from_footprints", "reproject", "SupportsDataWithWcs" diff --git a/src/stcal/tweakreg/tweakreg.py b/src/stcal/tweakreg/tweakreg.py index 2b6cab2d..082b6e51 100644 --- a/src/stcal/tweakreg/tweakreg.py +++ b/src/stcal/tweakreg/tweakreg.py @@ -53,12 +53,11 @@ def relative_align(correctors: list, fitgeometry: str = "rshift", nclip: int = 3, sigma: float = 3.0, - align_to_abs_refcat: bool = False, - ) -> tuple[list, bool]: + ) -> list: if separation <= _SQRT2 * tolerance: - msg = "Parameter 'separation' must be larger than 'tolerance' by at \ - least a factor of sqrt(2) to avoid source confusion." + msg = ("Parameter 'separation' must be larger than 'tolerance' by at " + "least a factor of sqrt(2) to avoid source confusion.") raise TweakregError(msg) # align images: @@ -83,18 +82,15 @@ def relative_align(correctors: list, nclip=nclip, sigma=(sigma, "rmse") ) - local_align_failed = False except ValueError as e: msg = e.args[0] if (msg == "Too few input images (or groups of images) with " "non-empty catalogs."): - local_align_failed = True - if not align_to_abs_refcat: - msg += "At least two exposures are required for image alignment. Nothing to do." - raise TweakregError(msg) from None - else: - raise + msg += ("At least two exposures are required for " + "image alignment. Nothing to do.") + raise TweakregError(msg) from None + raise except RuntimeError as e: msg = e.args[0] @@ -113,15 +109,12 @@ def relative_align(correctors: list, tolerance, xoffset, yoffset) - warning_msg = "".join([str(mess.message) for mess in w]) - if not local_align_failed and not is_small: - if align_to_abs_refcat: - warning_msg += " Skipping relative alignment (stage 1)..." - warnings.warn(warning_msg, stacklevel=1) - else: - raise TweakregError(warning_msg) + msg = "".join([str(mess.message) for mess in w]) + if not is_small: + msg += " Skipping relative alignment..." + raise TweakregError(msg) - return correctors, local_align_failed + return correctors def absolute_align(correctors: list, @@ -136,12 +129,12 @@ def absolute_align(correctors: list, abs_minobj: int = 15, abs_fitgeometry: str = "rshift", abs_nclip: int = 3, - abs_sigma: float = 3.0, - local_align_failed: bool = False,) -> list: + abs_sigma: float = 3.0,) -> list: if abs_separation <= _SQRT2 * abs_tolerance: - msg = "Parameter 'abs_separation' must be larger than 'abs_tolerance' by at \ - least a factor of sqrt(2) to avoid source confusion." + msg = ("Parameter 'abs_separation' must be larger than " + "'abs_tolerance' " + "by at least a factor of sqrt(2) to avoid source confusion.") raise TweakregError(msg) ref_cat = _parse_refcat(abs_refcat, @@ -153,9 +146,9 @@ def absolute_align(correctors: list, # Check that there are enough GAIA sources for a reliable/valid fit num_ref = len(ref_cat) if num_ref < abs_minobj: - msg = f"Not enough sources ({num_ref}) in the reference catalog \ - for the single-group alignment step to perform a fit. \ - Skipping alignment to the input reference catalog!" + msg = (f"Not enough sources ({num_ref}) in the reference catalog " + "for the single-group alignment step to perform a fit. " + "Skipping alignment to the input reference catalog!") raise TweakregError(msg) # align images: @@ -199,31 +192,23 @@ def absolute_align(correctors: list, msg = e.args[0] if (msg == "Too few input images (or groups of images) with " "non-empty catalogs."): - msg += "At least one exposure is required to align images \ - to an absolute reference catalog. Alignment to an \ - absolute reference catalog will not be performed." - if local_align_failed: - msg += " Nothing to do. Skipping 'TweakRegStep'..." - raise TweakregError(msg) from None - warnings.warn(msg, stacklevel=1) - else: - raise + msg += ("At least one exposure is required to align images " + "to an absolute reference catalog. Alignment to an " + "absolute reference catalog will not be performed.") + raise TweakregError(msg) from None + raise except RuntimeError as e: msg = e.args[0] if msg.startswith("Number of output coordinates exceeded allocation"): # we need at least two exposures to perform image alignment - msg += "Multiple sources within specified tolerance \ - matched to a single reference source. Try to \ - adjust 'tolerance' and/or 'separation' parameters. \ - Alignment to an absolute reference catalog will \ - not be performed." - if local_align_failed: - msg += "Skipping 'TweakRegStep'..." - raise TweakregError(msg) from None - warnings.warn(msg, stacklevel=1) - else: - raise + msg += ("Multiple sources within specified tolerance " + "matched to a single reference source. Try to " + "adjust 'tolerance' and/or 'separation' parameters. " + "Alignment to an absolute reference catalog will " + "not be performed.") + raise TweakregError(msg) from None + raise return correctors @@ -269,9 +254,9 @@ def _parse_refcat(abs_refcat: str | Path, if Path.is_file(Path(abs_refcat)): return Table.read(abs_refcat) - msg = f"Invalid 'abs_refcat' value: {abs_refcat}. 'abs_refcat' must be \ - a path to an existing file name or one of the supported \ - reference catalogs: {_SINGLE_GROUP_REFCAT_STR}." + msg = (f"Invalid 'abs_refcat' value: {abs_refcat}. 'abs_refcat' must be " + "a path to an existing file name or one of the supported " + f"reference catalogs: {_SINGLE_GROUP_REFCAT_STR}.") raise ValueError(msg) diff --git a/tests/test_tweakreg.py b/tests/test_tweakreg.py index 9a7adb69..ec9feb41 100644 --- a/tests/test_tweakreg.py +++ b/tests/test_tweakreg.py @@ -272,8 +272,7 @@ def test_relative_align(example_input, input_catalog, with_shift): cat1["y"] -= 9 correctors = [construct_wcs_corrector(dm, cat) for (dm, cat) in zip([m0, m1], [input_catalog, cat1], strict=True)] - result, local_align_failed = relative_align(correctors, minobj=5) - assert not local_align_failed + result = relative_align(correctors, minobj=5) # ensure wcses differ by a small amount due to the shift above # by projecting one point through each wcs and comparing the difference From 1786e93ad7ee68e39f970abf40124fd9ea85ae83 Mon Sep 17 00:00:00 2001 From: Ned Molter Date: Wed, 10 Jul 2024 12:37:27 -0400 Subject: [PATCH 13/36] restoring bounding box check to construct_wcs_corrector --- src/stcal/tweakreg/tweakreg.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/stcal/tweakreg/tweakreg.py b/src/stcal/tweakreg/tweakreg.py index 082b6e51..0c2ef665 100644 --- a/src/stcal/tweakreg/tweakreg.py +++ b/src/stcal/tweakreg/tweakreg.py @@ -33,7 +33,8 @@ def _oxford_or_str_join(str_list): SINGLE_GROUP_REFCAT = ["GAIADR3", "GAIADR2", "GAIADR1"] _SINGLE_GROUP_REFCAT_STR = _oxford_or_str_join(SINGLE_GROUP_REFCAT) -__all__ = ["relative_align", "absolute_align", "SINGLE_GROUP_REFCAT"] +__all__ = ["relative_align", "absolute_align", "SINGLE_GROUP_REFCAT", + "filter_catalog_by_bounding_box"] class TweakregError(BaseException): @@ -290,7 +291,7 @@ def _wcs_to_skycoord(wcs): return SkyCoord(ra=ra, dec=dec, unit="deg") -def _filter_catalog_by_bounding_box(catalog: Table, bounding_box: list[float]) -> Table: +def filter_catalog_by_bounding_box(catalog: Table, bounding_box: list[float]) -> Table: """ Given a catalog of x,y positions, only return sources that fall inside the bounding box. @@ -312,10 +313,8 @@ def construct_wcs_corrector(image_model: SupportsDataWithWcs, pre-compute skycoord here so we can later use it to check for a small wcs correction. """ - catalog = _filter_catalog_by_bounding_box( - catalog, - image_model.meta.wcs.bounding_box - ) + catalog = filter_catalog_by_bounding_box( + catalog, image_model.meta.wcs.bounding_box) wcs = image_model.meta.wcs refang = image_model.meta.wcsinfo.instance From 1a18c065657980dafecf0cec32296f4edbb21833 Mon Sep 17 00:00:00 2001 From: Ned Molter Date: Wed, 10 Jul 2024 13:37:53 -0400 Subject: [PATCH 14/36] fixing doc build --- docs/api/stcal.alignment.update_s_region_keyword.rst | 6 ++++++ ...cal.tweakreg.tweakreg.filter_catalog_by_bounding_box.rst | 6 ++++++ docs/conf.py | 2 ++ docs/stcal/tweakreg/description.rst | 6 +++--- src/stcal/alignment/util.py | 2 +- src/stcal/tweakreg/astrometric_utils.py | 2 +- 6 files changed, 19 insertions(+), 5 deletions(-) create mode 100644 docs/api/stcal.alignment.update_s_region_keyword.rst create mode 100644 docs/api/stcal.tweakreg.tweakreg.filter_catalog_by_bounding_box.rst diff --git a/docs/api/stcal.alignment.update_s_region_keyword.rst b/docs/api/stcal.alignment.update_s_region_keyword.rst new file mode 100644 index 00000000..579da75d --- /dev/null +++ b/docs/api/stcal.alignment.update_s_region_keyword.rst @@ -0,0 +1,6 @@ +update_s_region_keyword +======================= + +.. currentmodule:: stcal.alignment + +.. autofunction:: update_s_region_keyword diff --git a/docs/api/stcal.tweakreg.tweakreg.filter_catalog_by_bounding_box.rst b/docs/api/stcal.tweakreg.tweakreg.filter_catalog_by_bounding_box.rst new file mode 100644 index 00000000..b5be8e0f --- /dev/null +++ b/docs/api/stcal.tweakreg.tweakreg.filter_catalog_by_bounding_box.rst @@ -0,0 +1,6 @@ +filter_catalog_by_bounding_box +============================== + +.. currentmodule:: stcal.tweakreg.tweakreg + +.. autofunction:: filter_catalog_by_bounding_box diff --git a/docs/conf.py b/docs/conf.py index 2ee837e5..37082df6 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -50,6 +50,8 @@ def setup(app): "tweakwcs": ("https://tweakwcs.readthedocs.io/en/latest/", None), } +nitpick_ignore = [("py:class", "optional")] + extensions = [ "pytest_doctestplus.sphinx.doctestplus", "sphinx.ext.autodoc", diff --git a/docs/stcal/tweakreg/description.rst b/docs/stcal/tweakreg/description.rst index c766f131..8c1551f1 100644 --- a/docs/stcal/tweakreg/description.rst +++ b/docs/stcal/tweakreg/description.rst @@ -11,7 +11,7 @@ image catalogs using the corrected WCS will align on the sky. Source Catalogs --------------- The input catalog must be in a format automatically recognized by -:py:meth:`~astropy.table.Table.read`. The catalog must contain +`~astropy.table.Table.read`. The catalog must contain either ``'x'`` and ``'y'`` or ``'xcentroid'`` and ``'ycentroid'`` columns which indicate source *image* coordinates (in pixels). Pixel coordinates are 0-indexed. An optional column in the catalog is the ``'weight'`` column, @@ -58,7 +58,7 @@ file. A user-supplied catalog must contain ``'RA'`` and ``'DEC'`` columns indicating reference source world coordinates (in degrees). An optional column in the catalog is the ``'weight'`` column, which when present, will be used in fitting. The catalog must be in a format automatically recognized by -:py:meth:`~astropy.table.Table.read`. +`~astropy.table.Table.read`. Grouping -------- @@ -170,7 +170,7 @@ are passed into the `~stcal.tweakreg.absolute_align` function. * ``abs_refcat``: String indicating what astrometric catalog should be used. Currently supported options: 'GAIADR1', 'GAIADR2', 'GAIADR3', a path to an existing reference catalog, `None`, or `''`. See - :py:data:`stcal.tweakreg.tweakreg.SINGLE_GROUP_REFCAT` + `stcal.tweakreg.tweakreg.SINGLE_GROUP_REFCAT` for an up-to-date list of supported built-in reference catalogs. When ``abs_refcat`` is `None` or an empty string, alignment to the diff --git a/src/stcal/alignment/util.py b/src/stcal/alignment/util.py index def6a0eb..ae7d698a 100644 --- a/src/stcal/alignment/util.py +++ b/src/stcal/alignment/util.py @@ -733,7 +733,7 @@ def update_s_region_keyword(model: SupportsDataWithWcs, footprint): ---------- model : The input model - footprint : numpy.array + footprint : A 4x2 numpy array containing the coordinates of the vertices of the footprint. Returns diff --git a/src/stcal/tweakreg/astrometric_utils.py b/src/stcal/tweakreg/astrometric_utils.py index 45125060..507bfb95 100644 --- a/src/stcal/tweakreg/astrometric_utils.py +++ b/src/stcal/tweakreg/astrometric_utils.py @@ -182,7 +182,7 @@ def get_catalog(ra, dec, epoch=2016.0, sr=0.1, catalog="GAIADR3"): Returns ------- - csv : CSV object + csv : `~astropy.table.Table` CSV object of returned sources with all columns as provided by catalog """ From 04acbf3d75f0fb0289805cdbe1d491a8667b114b Mon Sep 17 00:00:00 2001 From: Ned Molter Date: Thu, 11 Jul 2024 09:57:10 -0400 Subject: [PATCH 15/36] attempted fix of jwst regtest failures --- src/stcal/alignment/util.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/stcal/alignment/util.py b/src/stcal/alignment/util.py index ae7d698a..8b3bc34b 100644 --- a/src/stcal/alignment/util.py +++ b/src/stcal/alignment/util.py @@ -687,6 +687,7 @@ def update_s_region_imaging(model: SupportsDataWithWcs, center=True): if bbox is None: bbox = wcs_bbox_from_shape(model.data.shape) + model.meta.wcs.bounding_box = bbox # footprint is an array of shape (2, 4) as we # are interested only in the footprint on the sky From f6377b4cff92fb1312950c84804500ebeaaa0bfb Mon Sep 17 00:00:00 2001 From: Ned Molter Date: Mon, 15 Jul 2024 13:40:18 -0400 Subject: [PATCH 16/36] refactor to remove datamodel dependencies --- src/stcal/alignment/util.py | 139 +++++++++++++++++---------------- src/stcal/tweakreg/tweakreg.py | 32 +++++--- tests/test_alignment.py | 7 +- tests/test_tweakreg.py | 25 ++++-- 4 files changed, 114 insertions(+), 89 deletions(-) diff --git a/src/stcal/alignment/util.py b/src/stcal/alignment/util.py index 8b3bc34b..45696ef3 100644 --- a/src/stcal/alignment/util.py +++ b/src/stcal/alignment/util.py @@ -3,7 +3,6 @@ import functools import logging -from typing import TYPE_CHECKING, Protocol, runtime_checkable import gwcs import numpy as np @@ -14,9 +13,6 @@ from astropy.utils.misc import isiterable from gwcs.wcstools import wcs_from_fiducial -if TYPE_CHECKING: - from asdf import AsdfFile - log = logging.getLogger(__name__) log.setLevel(logging.DEBUG) @@ -28,18 +24,9 @@ "update_s_region_keyword", "wcs_from_footprints", "reproject", - "SupportsDataWithWcs" ] -@runtime_checkable -class SupportsDataWithWcs(Protocol): - _asdf: AsdfFile - - def to_flat_dict(): - ... - - def _calculate_fiducial_from_spatial_footprint( spatial_footprint: np.ndarray, ) -> np.ndarray: @@ -72,7 +59,8 @@ def _calculate_fiducial_from_spatial_footprint( def _generate_tranform( - refmodel: SupportsDataWithWcs, + wcs: gwcs.WCS, + wcsinfo: dict, ref_fiducial: np.array, pscale_ratio: int | None = None, pscale: float | None = None, @@ -85,9 +73,11 @@ def _generate_tranform( Parameters ---------- - refmodel : - The datamodel that should be used as reference for calculating the - transform parameters. + wcs : ~gwcs.wcs.WCS + The WCS object. + + wcsinfo : dict + A dictionary containing the WCS FITS keywords and corresponding values. pscale_ratio : int, None Ratio of input to output pixel scale. This parameter is only used when @@ -119,11 +109,11 @@ def _generate_tranform( An :py:mod:`~astropy` model containing the transform between frames. """ if transform is None: - sky_axes = refmodel.meta.wcs._get_axes_indices().tolist() # noqa: SLF001 - v3yangle = np.deg2rad(refmodel.meta.wcsinfo.v3yangle) - vparity = refmodel.meta.wcsinfo.vparity + sky_axes = wcs._get_axes_indices().tolist() # noqa: SLF001 + v3yangle = np.deg2rad(wcsinfo.v3yangle) + vparity = wcsinfo.vparity if rotation is None: - roll_ref = np.deg2rad(refmodel.meta.wcsinfo.roll_ref) + roll_ref = np.deg2rad(wcsinfo.roll_ref) else: roll_ref = np.deg2rad(rotation) + (vparity * v3yangle) @@ -135,7 +125,7 @@ def _generate_tranform( transform = [rotation] if sky_axes: if not pscale: - pscale = compute_scale(refmodel.meta.wcs, ref_fiducial, pscale_ratio=pscale_ratio) + pscale = compute_scale(wcs, ref_fiducial, pscale_ratio=pscale_ratio) transform.append(astmodels.Scale(pscale, name="cdelt1") & astmodels.Scale(pscale, name="cdelt2")) if transform: @@ -144,16 +134,12 @@ def _generate_tranform( return transform -def _get_axis_min_and_bounding_box(ref_model: SupportsDataWithWcs, wcs_list, ref_wcs): +def _get_axis_min_and_bounding_box(wcs_list, ref_wcs): """ Calculates axis minimum values and bounding box. Parameters ---------- - ref_model : - The reference datamodel for which to determine the minimum axis values and - bounding box. - wcs_list : list The list of WCS objects. @@ -174,7 +160,7 @@ def _get_axis_min_and_bounding_box(ref_model: SupportsDataWithWcs, wcs_list, ref domain_bounds = (domain_bounds.T - axis_min_values).T output_bounding_box = [] - for axis in ref_model.meta.wcs.output_frame.axes_order: + for axis in ref_wcs.output_frame.axes_order: axis_min, axis_max = ( domain_bounds[axis].min(), domain_bounds[axis].max(), @@ -264,14 +250,14 @@ def _calculate_offsets(fiducial, wcs, axis_min_values, crpix): return astmodels.Shift(-offset1, name="crpix1") & astmodels.Shift(-offset2, name="crpix2") -def _calculate_new_wcs(ref_model: SupportsDataWithWcs, shape, wcs_list, fiducial, crpix=None, transform=None): +def _calculate_new_wcs(wcs, shape, wcs_list, fiducial, crpix=None, transform=None): """ Calculates a new WCS object based on the combined WCS objects provided. Parameters ---------- - ref_model : - The reference model to be used when extracting metadata. + wcs : ~gwcs.wcs.WCS + The reference WCS object. shape : list The shape of the new WCS's pixel grid. If `None`, then the output bounding box @@ -299,12 +285,12 @@ def _calculate_new_wcs(ref_model: SupportsDataWithWcs, shape, wcs_list, fiducial """ wcs_new = wcs_from_fiducial( fiducial, - coordinate_frame=ref_model.meta.wcs.output_frame, + coordinate_frame=wcs.output_frame, projection=astmodels.Pix2Sky_TAN(), transform=transform, - input_frame=ref_model.meta.wcs.input_frame, + input_frame=wcs.input_frame, ) - axis_min_values, output_bounding_box = _get_axis_min_and_bounding_box(ref_model, wcs_list, wcs_new) + axis_min_values, output_bounding_box = _get_axis_min_and_bounding_box(wcs_list, wcs_new) offsets = _calculate_offsets( fiducial=fiducial, wcs=wcs_new, @@ -360,14 +346,21 @@ def _validate_wcs_list(wcs_list): return True -def wcsinfo_from_model(input_model: SupportsDataWithWcs) -> dict[str, np.ndarray | str | bool]: +def wcsinfo_from_model(wcsinfo: dict, reference_frame: str) -> dict[str, np.ndarray | str | bool]: """ Creates a dict {wcs_keyword: array_of_values} pairs from a datamodel. + What is this actually doing? it looks badly named, as it requires the input + model to have its own wcsinfo already. It seems to just be setting defaults + in wcsinfo if they are None, then making the wcsinfo["PC"] matrix + Parameters ---------- - input_model : - The input datamodel. + wcsinfo : dict + The input wcsinfo dict. + + reference_frame : str + The reference frame of the input model. Returns ------- @@ -382,22 +375,22 @@ def wcsinfo_from_model(input_model: SupportsDataWithWcs) -> dict[str, np.ndarray "CTYPE": "", "CUNIT": u.Unit(""), } - wcsaxes = input_model.meta.wcsinfo.wcsaxes + wcsaxes = wcsinfo.wcsaxes wcsinfo = {"WCSAXES": wcsaxes} for key in ["CRPIX", "CRVAL", "CDELT", "CTYPE", "CUNIT"]: val = [] for ax in range(1, wcsaxes + 1): k = (key + f"{ax}").lower() - v = getattr(input_model.meta.wcsinfo, k, defaults[key]) + v = getattr(wcsinfo, k, defaults[key]) val.append(v) wcsinfo[key] = np.array(val) pc = np.zeros((wcsaxes, wcsaxes), dtype=np.float32) for i in range(1, wcsaxes + 1): for j in range(1, wcsaxes + 1): - pc[i - 1, j - 1] = getattr(input_model.meta.wcsinfo, f"pc{i}_{j}", 1) + pc[i - 1, j - 1] = getattr(wcsinfo, f"pc{i}_{j}", 1) wcsinfo["PC"] = pc - wcsinfo["RADESYS"] = input_model.meta.coordinates.reference_frame + wcsinfo["RADESYS"] = reference_frame wcsinfo["has_cd"] = False return wcsinfo @@ -556,8 +549,9 @@ def calc_rotation_matrix(roll_ref: float, v3i_yangle: float, vparity: int = 1) - def wcs_from_footprints( - dmodels: list[SupportsDataWithWcs], - refmodel=None, + wcs_list: list[gwcs.WCS], + ref_wcs: gwcs.WCS, + ref_wcsinfo: dict, transform=None, bounding_box=None, pscale_ratio=None, @@ -566,7 +560,6 @@ def wcs_from_footprints( shape=None, crpix=None, crval=None, - wcs_list=None, ): """ Create a WCS from a list of input datamodels. @@ -585,14 +578,17 @@ def wcs_from_footprints( Parameters ---------- - dmodels : list + wcs_list : list A list of valid datamodels. - refmodel : + ref_wcs : A valid datamodel whose WCS is used as reference for the creation of the output coordinate frame, projection, and scaling and rotation transforms. If not supplied the first model in the list is used as ``refmodel``. + ref_wcsinfo : dict + A dictionary containing the WCS FITS keywords and corresponding values. + transform : ~astropy.modeling.Model A transform, passed to :py:func:`gwcs.wcstools.wcs_from_fiducial` If not supplied `Scaling | Rotation` is computed from ``refmodel``. @@ -643,26 +639,24 @@ def wcs_from_footprints( The WCS object corresponding to the combined input footprints. """ - if wcs_list is None: - wcs_list = [im.meta.wcs for im in dmodels] - _validate_wcs_list(wcs_list) fiducial = _calculate_fiducial(wcs_list=wcs_list, bounding_box=bounding_box, crval=crval) - refmodel = dmodels[0] if refmodel is None else refmodel + ref_wcs = wcs_list[0] if ref_wcs is None else ref_wcs transform = _generate_tranform( - refmodel=refmodel, + ref_wcs, + wcsinfo=ref_wcsinfo, pscale_ratio=pscale_ratio, pscale=pscale, rotation=rotation, - ref_fiducial=np.array([refmodel.meta.wcsinfo.ra_ref, refmodel.meta.wcsinfo.dec_ref]), + ref_fiducial=np.array([ref_wcsinfo.ra_ref, ref_wcsinfo.dec_ref]), transform=transform, ) return _calculate_new_wcs( - ref_model=refmodel, + wcs=ref_wcs, shape=shape, crpix=crpix, wcs_list=wcs_list, @@ -671,23 +665,34 @@ def wcs_from_footprints( ) -def update_s_region_imaging(model: SupportsDataWithWcs, center=True): +def update_s_region_imaging(wcs, wcsinfo, shape=None, center=True): """ Update the ``S_REGION`` keyword using the WCS footprint. Parameters ---------- - model : - The input datamodel. + wcs : ~gwcs.wcs.WCS + The WCS object. + + wcsinfo : dict + A dictionary containing the WCS FITS keywords and corresponding values. + + shape : tuple, optional + Shape of input model data array. Used to compute the bounding box if not + provided in the WCS object, and required in that case. The default is None. + center : bool, optional Whether or not to use the center of the pixel as reference for the coordinates, by default True """ - bbox = model.meta.wcs.bounding_box + bbox = wcs.bounding_box + if shape is None and bbox is None: + msg = "If wcs.bounding_box is not specified, shape must be provided." + raise ValueError(msg) if bbox is None: - bbox = wcs_bbox_from_shape(model.data.shape) - model.meta.wcs.bounding_box = bbox + bbox = wcs_bbox_from_shape(shape) + wcs.bounding_box = bbox # footprint is an array of shape (2, 4) as we # are interested only in the footprint on the sky @@ -696,7 +701,7 @@ def update_s_region_imaging(model: SupportsDataWithWcs, center=True): ### which means we are interested in each pixel's vertice, not its center. ### By using center=True, a difference of 0.5 pixel should be accounted for ### when comparing the world coordinates of the bounding box and the footprint. - footprint = model.meta.wcs.footprint(bbox, center=center, axis_type="spatial").T + footprint = wcs.footprint(bbox, center=center, axis_type="spatial").T # take only imaging footprint footprint = footprint[:2, :] @@ -706,7 +711,8 @@ def update_s_region_imaging(model: SupportsDataWithWcs, center=True): footprint[0][negative_ind] = 360 + footprint[0][negative_ind] footprint = footprint.T - update_s_region_keyword(model, footprint) + update_s_region_keyword(wcsinfo, footprint) + return wcsinfo def wcs_bbox_from_shape(shape): @@ -727,13 +733,14 @@ def wcs_bbox_from_shape(shape): return (-0.5, shape[-1] - 0.5), (-0.5, shape[-2] - 0.5) -def update_s_region_keyword(model: SupportsDataWithWcs, footprint): +def update_s_region_keyword(wcsinfo, footprint): """Update the S_REGION keyword. Parameters ---------- - model : - The input model + wcsinfo : dict + A dictionary containing the WCS FITS keywords and corresponding values. + footprint : A 4x2 numpy array containing the coordinates of the vertices of the footprint. @@ -749,8 +756,8 @@ def update_s_region_keyword(model: SupportsDataWithWcs, footprint): # do not update s_region if there are NaNs. log.info("There are NaNs in s_region, S_REGION not updated.") else: - model.meta.wcsinfo.s_region = s_region - log.info("Update S_REGION to %s", model.meta.wcsinfo.s_region) + wcsinfo.s_region = s_region + log.info("Update S_REGION to %s", wcsinfo.s_region) def reproject(wcs1, wcs2): diff --git a/src/stcal/tweakreg/tweakreg.py b/src/stcal/tweakreg/tweakreg.py index 0c2ef665..42ab9f3b 100644 --- a/src/stcal/tweakreg/tweakreg.py +++ b/src/stcal/tweakreg/tweakreg.py @@ -3,16 +3,20 @@ import math import warnings from pathlib import Path +from typing import TYPE_CHECKING import astropy.units as u + +if TYPE_CHECKING: + import gwcs + from astropy.time import Time from astropy.coordinates import SkyCoord from astropy.table import Table -from astropy.time import Time from tweakwcs.correctors import JWSTWCSCorrector from tweakwcs.imalign import align_wcs from tweakwcs.matchutils import XYXYMatch -from stcal.alignment import SupportsDataWithWcs, wcs_from_footprints +from stcal.alignment import wcs_from_footprints from .astrometric_utils import create_astrometric_catalog @@ -120,7 +124,9 @@ def relative_align(correctors: list, def absolute_align(correctors: list, abs_refcat: str, - ref_image: SupportsDataWithWcs, + ref_wcs: gwcs.WCS, + ref_wcsinfo: dict, + epoch: str | Time, save_abs_catalog: bool = False, abs_catalog_output_dir: str | None = None, abs_searchrad: float = 6.0, @@ -139,8 +145,10 @@ def absolute_align(correctors: list, raise TweakregError(msg) ref_cat = _parse_refcat(abs_refcat, - ref_image, correctors, + ref_wcs, + ref_wcsinfo, + epoch, save_abs_catalog=save_abs_catalog, output_dir=abs_catalog_output_dir) @@ -215,8 +223,10 @@ def absolute_align(correctors: list, def _parse_refcat(abs_refcat: str | Path, - ref_model: SupportsDataWithWcs, correctors: list, + wcs: gwcs.WCS, + wcsinfo: dict, + epoch: str | Time, save_abs_catalog: bool = False, output_dir: str | None = None) -> Table: """ @@ -236,14 +246,12 @@ def _parse_refcat(abs_refcat: str | Path, gaia_cat_name = abs_refcat.upper() if gaia_cat_name in SINGLE_GROUP_REFCAT: - epoch = Time(ref_model.meta.observation.date).decimalyear - # combine all aligned wcs to compute a new footprint to # filter the absolute catalog sources combined_wcs = wcs_from_footprints( - None, - refmodel=ref_model, - wcs_list=[corrector.wcs for corrector in correctors], + [corrector.wcs for corrector in correctors], + ref_wcs=wcs, + ref_wcsinfo=wcsinfo, ) return create_astrometric_catalog( @@ -256,8 +264,8 @@ def _parse_refcat(abs_refcat: str | Path, return Table.read(abs_refcat) msg = (f"Invalid 'abs_refcat' value: {abs_refcat}. 'abs_refcat' must be " - "a path to an existing file name or one of the supported " - f"reference catalogs: {_SINGLE_GROUP_REFCAT_STR}.") + "a path to an existing file name or one of the supported " + f"reference catalogs: {_SINGLE_GROUP_REFCAT_STR}.") raise ValueError(msg) diff --git a/tests/test_alignment.py b/tests/test_alignment.py index 7c586ce8..9b249d0f 100644 --- a/tests/test_alignment.py +++ b/tests/test_alignment.py @@ -159,7 +159,7 @@ def test_wcs_from_footprints(): dm_2 = _create_wcs_and_datamodel(fiducial_world, shape, pscale) wcs_2 = dm_2.meta.wcs - wcs = wcs_from_footprints([dm_1, dm_2]) + wcs = wcs_from_footprints([wcs_1, wcs_2], wcs_1, dm_1.meta.wcsinfo) # check that all elements of footprint match the *vertices* of the new combined WCS assert all(np.isclose(wcs.footprint()[0], wcs(0, 0))) @@ -312,7 +312,7 @@ def test_update_s_region_keyword(model, footprint, expected_s_region, expected_l """ Test that S_REGION keyword is being properly populated with the coordinate values. """ - update_s_region_keyword(model, footprint) + update_s_region_keyword(model.meta.wcsinfo, footprint) assert model.meta.wcsinfo.s_region == expected_s_region assert expected_log_info in caplog.text @@ -361,6 +361,7 @@ def test_update_s_region_imaging(model, bounding_box, data): *model.meta.wcs(2.5, 2.5), *model.meta.wcs(2.5, -0.5), ] - update_s_region_imaging(model, center=False) + shape = data.shape if data is not None else None + update_s_region_imaging(model.meta.wcs, model.meta.wcsinfo, shape=shape, center=False) updated_s_region_coords = [float(x) for x in model.meta.wcsinfo.s_region.split(" ")[3:]] assert all(np.isclose(x, y) for x, y in zip(updated_s_region_coords, expected_s_region_coords)) diff --git a/tests/test_tweakreg.py b/tests/test_tweakreg.py index ec9feb41..a37c5c6c 100644 --- a/tests/test_tweakreg.py +++ b/tests/test_tweakreg.py @@ -8,11 +8,10 @@ import pytest from astropy.modeling.models import Shift from astropy.table import Table -from tweakwcs.correctors import WCSCorrector +from astropy.time import Time from stcal.tweakreg import astrometric_utils as amutils from stcal.tweakreg.utils import _wcsinfo_from_wcs_transform -from stcal.alignment.util import SupportsDataWithWcs from stcal.tweakreg.tweakreg import ( _is_wcs_correction_small, construct_wcs_corrector, @@ -56,7 +55,7 @@ def wcsobj2(): def test_radius(wcsobj): # compute radius - radius, fiducial = amutils.compute_radius(wcsobj) + radius, _ = amutils.compute_radius(wcsobj) # check results np.testing.assert_allclose(radius, EXPECTED_RADIUS, rtol=1e-6) @@ -152,7 +151,10 @@ def test_expected_fails_bad_separation(): tolerance=tolerance) with pytest.raises(TweakregError): - absolute_align(correctors, "GAIADR3", None, + absolute_align(correctors, "GAIADR3", + None, + None, + None, abs_separation=separation, abs_tolerance=tolerance) @@ -181,7 +183,7 @@ def __init__(self, wcs, epoch, group_id=None): self.group_id = group_id -class MinimalDataWithWCS(SupportsDataWithWcs): +class MinimalDataWithWCS: def __init__(self, wcs, epoch="2016-01-01T00:00:00.0", group_id=None): self.meta = Metadata(wcs, epoch, group_id=group_id) @@ -212,11 +214,12 @@ def test_parse_refcat(datamodel): cat.write(Path.cwd() / CATALOG_FNAME, format="ascii.ecsv", overwrite=True) # parse refcat from file - refcat = _parse_refcat(Path.cwd() / CATALOG_FNAME, datamodel, correctors) + epoch = Time(datamodel.meta.observation.date).decimalyear + refcat = _parse_refcat(Path.cwd() / CATALOG_FNAME, correctors, datamodel.meta.wcs, datamodel.meta.wcsinfo, epoch) assert isinstance(refcat, Table) # find refcat from web - refcat = _parse_refcat(TEST_CATALOG, datamodel, correctors) + refcat = _parse_refcat(TEST_CATALOG, correctors, datamodel.meta.wcs, datamodel.meta.wcsinfo, epoch) assert isinstance(refcat, Table) @@ -287,7 +290,13 @@ def test_absolute_align(example_input, input_catalog): correctors = [construct_wcs_corrector(dm, input_catalog) for dm in example_input] - result = absolute_align(correctors, TEST_CATALOG, example_input[0], abs_minobj=5) + ref_model = example_input[0] + result = absolute_align(correctors, + TEST_CATALOG, + ref_wcs=ref_model.meta.wcs, + ref_wcsinfo=ref_model.meta.wcsinfo, + epoch=Time(ref_model.meta.observation.date).decimalyear, + abs_minobj=5) for res in result: assert res.meta["group_id"] == 987654 From fe50a52eefbef457744709f4b3d01178dab93d11 Mon Sep 17 00:00:00 2001 From: Ned Molter Date: Mon, 15 Jul 2024 13:46:19 -0400 Subject: [PATCH 17/36] attempting to fix doc issue --- docs/stcal/tweakreg/astrometric_utils.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/stcal/tweakreg/astrometric_utils.rst b/docs/stcal/tweakreg/astrometric_utils.rst index 90d5f6f2..4d49fa26 100644 --- a/docs/stcal/tweakreg/astrometric_utils.rst +++ b/docs/stcal/tweakreg/astrometric_utils.rst @@ -9,4 +9,4 @@ set of images. .. currentmodule:: stcal.tweakreg.astrometric_utils .. automodapi:: stcal.tweakreg.astrometric_utils - :include-all-objects: + :noindex: From f6d8409e80c6944559ad9600fc89523e54cd7bdc Mon Sep 17 00:00:00 2001 From: Ned Molter Date: Mon, 15 Jul 2024 13:59:45 -0400 Subject: [PATCH 18/36] change gitignore to ignore autogenerated api docs --- .gitignore | 9 +++++---- .../api/stcal.alignment.SupportsDataWithWcs.rst | 17 ----------------- ...stcal.tweakreg.astrometric_utils.TIMEOUT.rst | 6 ------ 3 files changed, 5 insertions(+), 27 deletions(-) delete mode 100644 docs/api/stcal.alignment.SupportsDataWithWcs.rst delete mode 100644 docs/api/stcal.tweakreg.astrometric_utils.TIMEOUT.rst diff --git a/.gitignore b/.gitignore index d35d7af2..cce7ccbe 100644 --- a/.gitignore +++ b/.gitignore @@ -74,6 +74,11 @@ instance/ # Sphinx documentation docs/_build/ +# auto-generated API docs +docs/source/api +docs/api +.DS_Store + # PyBuilder .pybuilder/ target/ @@ -146,9 +151,5 @@ src/stcal/ramp_fitting/ols_cas22/*.html # setuptools-scm generated module src/stcal/_version.py -# auto-generated API docs -docs/source/api -.DS_Store - # VSCode stuff .vscode diff --git a/docs/api/stcal.alignment.SupportsDataWithWcs.rst b/docs/api/stcal.alignment.SupportsDataWithWcs.rst deleted file mode 100644 index ccbb0f04..00000000 --- a/docs/api/stcal.alignment.SupportsDataWithWcs.rst +++ /dev/null @@ -1,17 +0,0 @@ -SupportsDataWithWcs -=================== - -.. currentmodule:: stcal.alignment - -.. autoclass:: SupportsDataWithWcs - :show-inheritance: - - .. rubric:: Methods Summary - - .. autosummary:: - - ~SupportsDataWithWcs.to_flat_dict - - .. rubric:: Methods Documentation - - .. automethod:: to_flat_dict diff --git a/docs/api/stcal.tweakreg.astrometric_utils.TIMEOUT.rst b/docs/api/stcal.tweakreg.astrometric_utils.TIMEOUT.rst deleted file mode 100644 index 4fb96d58..00000000 --- a/docs/api/stcal.tweakreg.astrometric_utils.TIMEOUT.rst +++ /dev/null @@ -1,6 +0,0 @@ -TIMEOUT -======= - -.. currentmodule:: stcal.tweakreg.astrometric_utils - -.. autodata:: TIMEOUT From b6a9aeccadb348d90672cea5317c3a1d725dce43 Mon Sep 17 00:00:00 2001 From: Ned Molter Date: Mon, 15 Jul 2024 14:00:46 -0400 Subject: [PATCH 19/36] forgot to remove the folder --- docs/api/stcal.alignment.calc_rotation_matrix.rst | 6 ------ docs/api/stcal.alignment.compute_fiducial.rst | 6 ------ docs/api/stcal.alignment.compute_scale.rst | 6 ------ docs/api/stcal.alignment.reproject.rst | 6 ------ docs/api/stcal.alignment.update_s_region_imaging.rst | 6 ------ docs/api/stcal.alignment.update_s_region_keyword.rst | 6 ------ docs/api/stcal.alignment.wcs_from_footprints.rst | 6 ------ .../api/stcal.tweakreg.astrometric_utils.compute_radius.rst | 6 ------ ...weakreg.astrometric_utils.create_astrometric_catalog.rst | 6 ------ docs/api/stcal.tweakreg.astrometric_utils.get_catalog.rst | 6 ------ docs/api/stcal.tweakreg.tweakreg.absolute_align.rst | 6 ------ ...cal.tweakreg.tweakreg.filter_catalog_by_bounding_box.rst | 6 ------ docs/api/stcal.tweakreg.tweakreg.relative_align.rst | 6 ------ .../stcal.tweakreg.utils._wcsinfo_from_wcs_transform.rst | 6 ------ 14 files changed, 84 deletions(-) delete mode 100644 docs/api/stcal.alignment.calc_rotation_matrix.rst delete mode 100644 docs/api/stcal.alignment.compute_fiducial.rst delete mode 100644 docs/api/stcal.alignment.compute_scale.rst delete mode 100644 docs/api/stcal.alignment.reproject.rst delete mode 100644 docs/api/stcal.alignment.update_s_region_imaging.rst delete mode 100644 docs/api/stcal.alignment.update_s_region_keyword.rst delete mode 100644 docs/api/stcal.alignment.wcs_from_footprints.rst delete mode 100644 docs/api/stcal.tweakreg.astrometric_utils.compute_radius.rst delete mode 100644 docs/api/stcal.tweakreg.astrometric_utils.create_astrometric_catalog.rst delete mode 100644 docs/api/stcal.tweakreg.astrometric_utils.get_catalog.rst delete mode 100644 docs/api/stcal.tweakreg.tweakreg.absolute_align.rst delete mode 100644 docs/api/stcal.tweakreg.tweakreg.filter_catalog_by_bounding_box.rst delete mode 100644 docs/api/stcal.tweakreg.tweakreg.relative_align.rst delete mode 100644 docs/api/stcal.tweakreg.utils._wcsinfo_from_wcs_transform.rst diff --git a/docs/api/stcal.alignment.calc_rotation_matrix.rst b/docs/api/stcal.alignment.calc_rotation_matrix.rst deleted file mode 100644 index f3dedfb0..00000000 --- a/docs/api/stcal.alignment.calc_rotation_matrix.rst +++ /dev/null @@ -1,6 +0,0 @@ -calc_rotation_matrix -==================== - -.. currentmodule:: stcal.alignment - -.. autofunction:: calc_rotation_matrix diff --git a/docs/api/stcal.alignment.compute_fiducial.rst b/docs/api/stcal.alignment.compute_fiducial.rst deleted file mode 100644 index 6419e034..00000000 --- a/docs/api/stcal.alignment.compute_fiducial.rst +++ /dev/null @@ -1,6 +0,0 @@ -compute_fiducial -================ - -.. currentmodule:: stcal.alignment - -.. autofunction:: compute_fiducial diff --git a/docs/api/stcal.alignment.compute_scale.rst b/docs/api/stcal.alignment.compute_scale.rst deleted file mode 100644 index 3c2ae099..00000000 --- a/docs/api/stcal.alignment.compute_scale.rst +++ /dev/null @@ -1,6 +0,0 @@ -compute_scale -============= - -.. currentmodule:: stcal.alignment - -.. autofunction:: compute_scale diff --git a/docs/api/stcal.alignment.reproject.rst b/docs/api/stcal.alignment.reproject.rst deleted file mode 100644 index fddad980..00000000 --- a/docs/api/stcal.alignment.reproject.rst +++ /dev/null @@ -1,6 +0,0 @@ -reproject -========= - -.. currentmodule:: stcal.alignment - -.. autofunction:: reproject diff --git a/docs/api/stcal.alignment.update_s_region_imaging.rst b/docs/api/stcal.alignment.update_s_region_imaging.rst deleted file mode 100644 index a36b9cdb..00000000 --- a/docs/api/stcal.alignment.update_s_region_imaging.rst +++ /dev/null @@ -1,6 +0,0 @@ -update_s_region_imaging -======================= - -.. currentmodule:: stcal.alignment - -.. autofunction:: update_s_region_imaging diff --git a/docs/api/stcal.alignment.update_s_region_keyword.rst b/docs/api/stcal.alignment.update_s_region_keyword.rst deleted file mode 100644 index 579da75d..00000000 --- a/docs/api/stcal.alignment.update_s_region_keyword.rst +++ /dev/null @@ -1,6 +0,0 @@ -update_s_region_keyword -======================= - -.. currentmodule:: stcal.alignment - -.. autofunction:: update_s_region_keyword diff --git a/docs/api/stcal.alignment.wcs_from_footprints.rst b/docs/api/stcal.alignment.wcs_from_footprints.rst deleted file mode 100644 index d7c22e4f..00000000 --- a/docs/api/stcal.alignment.wcs_from_footprints.rst +++ /dev/null @@ -1,6 +0,0 @@ -wcs_from_footprints -=================== - -.. currentmodule:: stcal.alignment - -.. autofunction:: wcs_from_footprints diff --git a/docs/api/stcal.tweakreg.astrometric_utils.compute_radius.rst b/docs/api/stcal.tweakreg.astrometric_utils.compute_radius.rst deleted file mode 100644 index 7f62bdf4..00000000 --- a/docs/api/stcal.tweakreg.astrometric_utils.compute_radius.rst +++ /dev/null @@ -1,6 +0,0 @@ -compute_radius -============== - -.. currentmodule:: stcal.tweakreg.astrometric_utils - -.. autofunction:: compute_radius diff --git a/docs/api/stcal.tweakreg.astrometric_utils.create_astrometric_catalog.rst b/docs/api/stcal.tweakreg.astrometric_utils.create_astrometric_catalog.rst deleted file mode 100644 index dc1fc120..00000000 --- a/docs/api/stcal.tweakreg.astrometric_utils.create_astrometric_catalog.rst +++ /dev/null @@ -1,6 +0,0 @@ -create_astrometric_catalog -========================== - -.. currentmodule:: stcal.tweakreg.astrometric_utils - -.. autofunction:: create_astrometric_catalog diff --git a/docs/api/stcal.tweakreg.astrometric_utils.get_catalog.rst b/docs/api/stcal.tweakreg.astrometric_utils.get_catalog.rst deleted file mode 100644 index 88016fd7..00000000 --- a/docs/api/stcal.tweakreg.astrometric_utils.get_catalog.rst +++ /dev/null @@ -1,6 +0,0 @@ -get_catalog -=========== - -.. currentmodule:: stcal.tweakreg.astrometric_utils - -.. autofunction:: get_catalog diff --git a/docs/api/stcal.tweakreg.tweakreg.absolute_align.rst b/docs/api/stcal.tweakreg.tweakreg.absolute_align.rst deleted file mode 100644 index a57dfe3b..00000000 --- a/docs/api/stcal.tweakreg.tweakreg.absolute_align.rst +++ /dev/null @@ -1,6 +0,0 @@ -absolute_align -============== - -.. currentmodule:: stcal.tweakreg.tweakreg - -.. autofunction:: absolute_align diff --git a/docs/api/stcal.tweakreg.tweakreg.filter_catalog_by_bounding_box.rst b/docs/api/stcal.tweakreg.tweakreg.filter_catalog_by_bounding_box.rst deleted file mode 100644 index b5be8e0f..00000000 --- a/docs/api/stcal.tweakreg.tweakreg.filter_catalog_by_bounding_box.rst +++ /dev/null @@ -1,6 +0,0 @@ -filter_catalog_by_bounding_box -============================== - -.. currentmodule:: stcal.tweakreg.tweakreg - -.. autofunction:: filter_catalog_by_bounding_box diff --git a/docs/api/stcal.tweakreg.tweakreg.relative_align.rst b/docs/api/stcal.tweakreg.tweakreg.relative_align.rst deleted file mode 100644 index 2a307688..00000000 --- a/docs/api/stcal.tweakreg.tweakreg.relative_align.rst +++ /dev/null @@ -1,6 +0,0 @@ -relative_align -============== - -.. currentmodule:: stcal.tweakreg.tweakreg - -.. autofunction:: relative_align diff --git a/docs/api/stcal.tweakreg.utils._wcsinfo_from_wcs_transform.rst b/docs/api/stcal.tweakreg.utils._wcsinfo_from_wcs_transform.rst deleted file mode 100644 index 51f5370c..00000000 --- a/docs/api/stcal.tweakreg.utils._wcsinfo_from_wcs_transform.rst +++ /dev/null @@ -1,6 +0,0 @@ -_wcsinfo_from_wcs_transform -=========================== - -.. currentmodule:: stcal.tweakreg.utils - -.. autofunction:: _wcsinfo_from_wcs_transform From 649fcde9788d9a2d1360c5b9f2be037a4f07ec64 Mon Sep 17 00:00:00 2001 From: Ned Molter Date: Wed, 17 Jul 2024 10:14:13 -0400 Subject: [PATCH 20/36] changes per @kmacdonald-stsci, attempted fix docs build --- src/stcal/tweakreg/astrometric_utils.py | 25 +++++++++++---- src/stcal/tweakreg/tweakreg.py | 25 +++++++-------- tests/.gitignore | 4 +++ tests/test_tweakreg.py | 41 ++++++++++++++++--------- 4 files changed, 62 insertions(+), 33 deletions(-) create mode 100644 tests/.gitignore diff --git a/src/stcal/tweakreg/astrometric_utils.py b/src/stcal/tweakreg/astrometric_utils.py index 507bfb95..a5c38f8e 100644 --- a/src/stcal/tweakreg/astrometric_utils.py +++ b/src/stcal/tweakreg/astrometric_utils.py @@ -89,7 +89,7 @@ def create_astrometric_catalog( fiducial[0], fiducial[1], epoch=epoch, - sr=radius, + search_radius=radius, catalog=catalog) if len(ref_dict) == 0: return ref_dict @@ -158,22 +158,28 @@ def compute_radius(wcs): return radius, fiducial -def get_catalog(ra, dec, epoch=2016.0, sr=0.1, catalog="GAIADR3"): +def get_catalog( + right_ascension, + declination, + epoch=2016.0, + search_radius=0.1, + catalog="GAIADR3" +): """Extract catalog from VO web service. Parameters ---------- - ra : float + right_ascension : float Right Ascension (RA) of center of field-of-view (in decimal degrees) - dec : float + declination : float Declination (Dec) of center of field-of-view (in decimal degrees) epoch : float, optional Reference epoch used to update the coordinates for proper motion (in decimal year). Default: 2016.0 - sr : float, optional + search_radius : float, optional Search radius (in decimal degrees) from field-of-view center to use for sources from catalog. Default: 0.1 degrees @@ -191,7 +197,14 @@ def get_catalog(ra, dec, epoch=2016.0, sr=0.1, catalog="GAIADR3"): headers = {"Content-Type": "text/csv"} fmt = "CSV" - spec = spec_str.format(ra, dec, epoch, sr, fmt, catalog) + spec = spec_str.format( + right_ascension, + declination, + epoch, + search_radius, + fmt, + catalog + ) service_url = f"{SERVICELOCATION}/{service_type}?{spec}" rawcat = requests.get(service_url, headers=headers, timeout=TIMEOUT) r_contents = rawcat.content.decode() # convert from bytes to a String diff --git a/src/stcal/tweakreg/tweakreg.py b/src/stcal/tweakreg/tweakreg.py index 42ab9f3b..f66c8e49 100644 --- a/src/stcal/tweakreg/tweakreg.py +++ b/src/stcal/tweakreg/tweakreg.py @@ -8,8 +8,8 @@ import astropy.units as u if TYPE_CHECKING: + import astropy import gwcs - from astropy.time import Time from astropy.coordinates import SkyCoord from astropy.table import Table from tweakwcs.correctors import JWSTWCSCorrector @@ -124,9 +124,9 @@ def relative_align(correctors: list, def absolute_align(correctors: list, abs_refcat: str, - ref_wcs: gwcs.WCS, + ref_wcs: gwcs.wcs.WCS, ref_wcsinfo: dict, - epoch: str | Time, + epoch: str | astropy.time.Time, save_abs_catalog: bool = False, abs_catalog_output_dir: str | None = None, abs_searchrad: float = 6.0, @@ -224,9 +224,9 @@ def absolute_align(correctors: list, def _parse_refcat(abs_refcat: str | Path, correctors: list, - wcs: gwcs.WCS, + wcs: gwcs.wcs.WCS, wcsinfo: dict, - epoch: str | Time, + epoch: str | astropy.time.Time, save_abs_catalog: bool = False, output_dir: str | None = None) -> Table: """ @@ -315,19 +315,20 @@ def filter_catalog_by_bounding_box(catalog: Table, bounding_box: list[float]) -> return catalog[mask] -def construct_wcs_corrector(image_model: SupportsDataWithWcs, - catalog: Table) -> JWSTWCSCorrector: +def construct_wcs_corrector(wcs: gwcs.WCS, + wcsinfo: dict, + catalog: Table, + group_id: str,) -> JWSTWCSCorrector: """ pre-compute skycoord here so we can later use it to check for a small wcs correction. """ catalog = filter_catalog_by_bounding_box( - catalog, image_model.meta.wcs.bounding_box) + catalog, wcs.bounding_box) - wcs = image_model.meta.wcs - refang = image_model.meta.wcsinfo.instance + refang = wcsinfo.instance return JWSTWCSCorrector( - wcs=image_model.meta.wcs, + wcs=wcs, wcsinfo={"roll_ref": refang["roll_ref"], "v2_ref": refang["v2_ref"], "v3_ref": refang["v3_ref"]}, @@ -335,7 +336,7 @@ def construct_wcs_corrector(image_model: SupportsDataWithWcs, meta={ "catalog": catalog, "name": catalog.meta.get("name"), - "group_id": image_model.meta.group_id, + "group_id": group_id, "original_skycoord": _wcs_to_skycoord(wcs), } ) diff --git a/tests/.gitignore b/tests/.gitignore new file mode 100644 index 00000000..bfaaa8f7 --- /dev/null +++ b/tests/.gitignore @@ -0,0 +1,4 @@ +/*.fits +/*.asdf +/*.csv +/*.ecsv \ No newline at end of file diff --git a/tests/test_tweakreg.py b/tests/test_tweakreg.py index a37c5c6c..05bde8b7 100644 --- a/tests/test_tweakreg.py +++ b/tests/test_tweakreg.py @@ -1,6 +1,6 @@ """Test astrometric utility functions for alignment""" -from copy import deepcopy import copy +from copy import deepcopy from pathlib import Path import asdf @@ -11,21 +11,20 @@ from astropy.time import Time from stcal.tweakreg import astrometric_utils as amutils -from stcal.tweakreg.utils import _wcsinfo_from_wcs_transform from stcal.tweakreg.tweakreg import ( + TweakregError, _is_wcs_correction_small, - construct_wcs_corrector, + _parse_refcat, _wcs_to_skycoord, - relative_align, absolute_align, - TweakregError, - _parse_refcat + construct_wcs_corrector, + relative_align, ) +from stcal.tweakreg.utils import _wcsinfo_from_wcs_transform # Define input GWCS specification to be used for these tests WCS_NAME = "mosaic_long_i2d_gwcs.asdf" # Derived using B7.5 Level 3 product EXPECTED_NUM_SOURCES = 2469 -EXPECTED_RADIUS = 0.02564497890604383 # more recent WCS with a defined input frame is necessary for some tests WCS_NAME_2 = "nrcb1-wcs.asdf" @@ -58,6 +57,7 @@ def test_radius(wcsobj): radius, _ = amutils.compute_radius(wcsobj) # check results + EXPECTED_RADIUS = 0.02564497890604383 np.testing.assert_allclose(radius, EXPECTED_RADIUS, rtol=1e-6) @@ -66,7 +66,7 @@ def test_get_catalog(wcsobj): radius, fiducial = amutils.compute_radius(wcsobj) # Get the catalog - cat = amutils.get_catalog(fiducial[0], fiducial[1], sr=radius, + cat = amutils.get_catalog(fiducial[0], fiducial[1], search_radius=radius, catalog=TEST_CATALOG) assert len(cat) == EXPECTED_NUM_SOURCES @@ -207,7 +207,7 @@ def test_parse_refcat(datamodel): radius, fiducial = amutils.compute_radius(wcsobj) # Get the catalog - cat = amutils.get_catalog(fiducial[0], fiducial[1], sr=radius, + cat = amutils.get_catalog(fiducial[0], fiducial[1], search_radius=radius, catalog=TEST_CATALOG) # save refcat to file @@ -215,7 +215,11 @@ def test_parse_refcat(datamodel): # parse refcat from file epoch = Time(datamodel.meta.observation.date).decimalyear - refcat = _parse_refcat(Path.cwd() / CATALOG_FNAME, correctors, datamodel.meta.wcs, datamodel.meta.wcsinfo, epoch) + refcat = _parse_refcat(Path.cwd() / CATALOG_FNAME, + correctors, + datamodel.meta.wcs, + datamodel.meta.wcsinfo, + epoch) assert isinstance(refcat, Table) # find refcat from web @@ -232,7 +236,7 @@ def input_catalog(datamodel): radius, fiducial = amutils.compute_radius(w) # Get the catalog - cat = amutils.get_catalog(fiducial[0], fiducial[1], sr=radius, + cat = amutils.get_catalog(fiducial[0], fiducial[1], search_radius=radius, catalog=TEST_CATALOG) x, y = w.world_to_pixel(cat["ra"], cat["dec"]) @@ -248,7 +252,7 @@ def example_input(wcsobj2): rng = np.random.default_rng(26) xs = rng.choice(50, n_sources, replace=False) * 8 + 10 ys = rng.choice(50, n_sources, replace=False) * 8 + 10 - for y, x in zip(ys, xs): + for y, x in zip(ys, xs, strict=False): m0.data[y-1:y+2, x-1:x+2] = [ [0.1, 0.6, 0.1], [0.6, 0.8, 0.6], @@ -274,7 +278,11 @@ def test_relative_align(example_input, input_catalog, with_shift): m1.data[-9:] = BKG_LEVEL cat1["y"] -= 9 - correctors = [construct_wcs_corrector(dm, cat) for (dm, cat) in zip([m0, m1], [input_catalog, cat1], strict=True)] + correctors = [construct_wcs_corrector(dm.meta.wcs, + dm.meta.wcsinfo, + cat, + dm.meta.group_id) for (dm, cat) in \ + zip([m0, m1], [input_catalog, cat1], strict=True)] result = relative_align(correctors, minobj=5) # ensure wcses differ by a small amount due to the shift above @@ -288,7 +296,10 @@ def test_relative_align(example_input, input_catalog, with_shift): def test_absolute_align(example_input, input_catalog): - correctors = [construct_wcs_corrector(dm, input_catalog) for dm in example_input] + correctors = [construct_wcs_corrector(dm.meta.wcs, + dm.meta.wcsinfo, + input_catalog, + dm.meta.group_id) for dm in example_input] ref_model = example_input[0] result = absolute_align(correctors, @@ -301,4 +312,4 @@ def test_absolute_align(example_input, input_catalog): assert res.meta["group_id"] == 987654 abs_delta = abs(result[1].wcs(0, 0)[0] - result[0].wcs(0, 0)[0]) - assert abs_delta < 1E-12 \ No newline at end of file + assert abs_delta < 1E-12 From b45b81a40bec0cbe3c96d9a227faad0dd28df5b1 Mon Sep 17 00:00:00 2001 From: Ned Molter Date: Wed, 17 Jul 2024 15:10:25 -0400 Subject: [PATCH 21/36] fixes per @mairanteodoro comments, plus better typing of wcsinfo --- src/stcal/alignment/util.py | 119 +++++++++++++++++++++++---------- src/stcal/tweakreg/tweakreg.py | 38 +++++------ tests/test_alignment.py | 23 ++++++- tests/test_tweakreg.py | 7 +- 4 files changed, 125 insertions(+), 62 deletions(-) diff --git a/src/stcal/alignment/util.py b/src/stcal/alignment/util.py index 45696ef3..d9e1ba3d 100644 --- a/src/stcal/alignment/util.py +++ b/src/stcal/alignment/util.py @@ -3,6 +3,10 @@ import functools import logging +from typing import TYPE_CHECKING, Protocol, runtime_checkable + +if TYPE_CHECKING: + from collections.abc import Callable, Sequence import gwcs import numpy as np @@ -24,12 +28,28 @@ "update_s_region_keyword", "wcs_from_footprints", "reproject", + "Wcsinfo" ] +@runtime_checkable +class Wcsinfo(Protocol): + wcsaxes: int + ra_ref: float + dec_ref: float + v3yangle: float + vparity: int + roll_ref: float + s_region: str + + @property + def instance(self): + ... + + def _calculate_fiducial_from_spatial_footprint( spatial_footprint: np.ndarray, -) -> np.ndarray: +) -> tuple: """ Calculates the fiducial coordinates from a given spatial footprint. @@ -59,10 +79,10 @@ def _calculate_fiducial_from_spatial_footprint( def _generate_tranform( - wcs: gwcs.WCS, - wcsinfo: dict, - ref_fiducial: np.array, - pscale_ratio: int | None = None, + wcs: gwcs.wcs.WCS, + wcsinfo: dict | Wcsinfo, + ref_fiducial: np.ndarray, + pscale_ratio: float | None = None, pscale: float | None = None, rotation: float | None = None, transform: astmodels.Model | None = None, @@ -108,12 +128,14 @@ def _generate_tranform( transform : ~astropy.modeling.Model An :py:mod:`~astropy` model containing the transform between frames. """ + if not isinstance(wcsinfo, dict): + wcsinfo = wcsinfo.instance if transform is None: sky_axes = wcs._get_axes_indices().tolist() # noqa: SLF001 - v3yangle = np.deg2rad(wcsinfo.v3yangle) - vparity = wcsinfo.vparity + v3yangle = np.deg2rad(wcsinfo["v3yangle"]) + vparity = wcsinfo["vparity"] if rotation is None: - roll_ref = np.deg2rad(wcsinfo.roll_ref) + roll_ref = np.deg2rad(wcsinfo["roll_ref"]) else: roll_ref = np.deg2rad(rotation) + (vparity * v3yangle) @@ -134,7 +156,8 @@ def _generate_tranform( return transform -def _get_axis_min_and_bounding_box(wcs_list, ref_wcs): +def _get_axis_min_and_bounding_box(wcs_list: list[gwcs.wcs.WCS], + ref_wcs: gwcs.wcs.WCS) -> tuple: """ Calculates axis minimum values and bounding box. @@ -172,7 +195,9 @@ def _get_axis_min_and_bounding_box(wcs_list, ref_wcs): return (axis_min_values, output_bounding_box) -def _calculate_fiducial(wcs_list, bounding_box, crval=None): +def _calculate_fiducial(wcs_list: list[gwcs.wcs.WCS], + bounding_box: Sequence | None, + crval: list | None = None) -> np.ndarray: """ Calculates the coordinates of the fiducial point and, if necessary, updates it with the values in CRVAL (the update is applied to spatial axes only). @@ -182,7 +207,7 @@ def _calculate_fiducial(wcs_list, bounding_box, crval=None): wcs_list : list A list of WCS objects. - bounding_box : tuple, or list, optional + bounding_box : tuple, or list The bounding box over which the WCS is valid. It can be a either tuple of tuples or a list of lists of size 2 where each element represents a range of (low, high) values. The bounding_box is in the order of the axes, axes_order. @@ -210,7 +235,10 @@ def _calculate_fiducial(wcs_list, bounding_box, crval=None): return fiducial -def _calculate_offsets(fiducial, wcs, axis_min_values, crpix): +def _calculate_offsets(fiducial: np.ndarray, + wcs: gwcs.wcs.WCS | None, + axis_min_values: np.ndarray | None, + crpix: Sequence | None) -> astmodels.Model: """ Calculates the offsets to the transform. @@ -250,7 +278,13 @@ def _calculate_offsets(fiducial, wcs, axis_min_values, crpix): return astmodels.Shift(-offset1, name="crpix1") & astmodels.Shift(-offset2, name="crpix2") -def _calculate_new_wcs(wcs, shape, wcs_list, fiducial, crpix=None, transform=None): +def _calculate_new_wcs(wcs: gwcs.wcs.WCS, + shape: list | None, + wcs_list: list[gwcs.wcs.WCS], + fiducial: np.ndarray, + crpix: tuple | None = None, + transform: astmodels.Model | None = None, + ) -> gwcs.wcs.WCS: """ Calculates a new WCS object based on the combined WCS objects provided. @@ -309,7 +343,7 @@ def _calculate_new_wcs(wcs, shape, wcs_list, fiducial, crpix=None, transform=Non return wcs_new -def _validate_wcs_list(wcs_list): +def _validate_wcs_list(wcs_list: list[gwcs.wcs.WCS]) -> bool: """ Validates wcs_list. @@ -346,11 +380,13 @@ def _validate_wcs_list(wcs_list): return True -def wcsinfo_from_model(wcsinfo: dict, reference_frame: str) -> dict[str, np.ndarray | str | bool]: +def wcsinfo_from_model(wcsinfo: dict | Wcsinfo, + reference_frame: str, + ) -> dict[str, np.ndarray | str | bool]: """ Creates a dict {wcs_keyword: array_of_values} pairs from a datamodel. - What is this actually doing? it looks badly named, as it requires the input + What is this actually doing? it looks badly named, as it requires the input model to have its own wcsinfo already. It seems to just be setting defaults in wcsinfo if they are None, then making the wcsinfo["PC"] matrix @@ -368,6 +404,8 @@ def wcsinfo_from_model(wcsinfo: dict, reference_frame: str) -> dict[str, np.ndar A dict containing the WCS FITS keywords and corresponding values. """ + if not isinstance(wcsinfo, dict): + wcsinfo = wcsinfo.instance defaults = { "CRPIX": 0, "CRVAL": 0, @@ -375,7 +413,7 @@ def wcsinfo_from_model(wcsinfo: dict, reference_frame: str) -> dict[str, np.ndar "CTYPE": "", "CUNIT": u.Unit(""), } - wcsaxes = wcsinfo.wcsaxes + wcsaxes = wcsinfo["wcsaxes"] wcsinfo = {"WCSAXES": wcsaxes} for key in ["CRPIX", "CRVAL", "CDELT", "CTYPE", "CUNIT"]: val = [] @@ -460,7 +498,7 @@ def compute_scale( return np.sqrt(xscale * yscale) -def compute_fiducial(wcslist: list, bounding_box: tuple | list | None = None) -> np.ndarray: +def compute_fiducial(wcslist: list, bounding_box: Sequence | None = None) -> np.ndarray: """ Calculates the world coordinates of the fiducial point of a list of WCS objects. For a celestial footprint this is the center. For a spectral footprint, it is the @@ -551,16 +589,16 @@ def calc_rotation_matrix(roll_ref: float, v3i_yangle: float, vparity: int = 1) - def wcs_from_footprints( wcs_list: list[gwcs.WCS], ref_wcs: gwcs.WCS, - ref_wcsinfo: dict, - transform=None, - bounding_box=None, - pscale_ratio=None, - pscale=None, - rotation=None, - shape=None, - crpix=None, - crval=None, -): + ref_wcsinfo: dict | Wcsinfo, + transform: astmodels.Model | None = None, + bounding_box: Sequence | None = None, + pscale_ratio: float | None = None, + pscale: float | None = None, + rotation: float | None = None, + shape: Sequence | None = None, + crpix: Sequence| None = None, + crval: Sequence | None = None, +) -> gwcs.WCS: """ Create a WCS from a list of input datamodels. @@ -639,6 +677,8 @@ def wcs_from_footprints( The WCS object corresponding to the combined input footprints. """ + if not isinstance(ref_wcsinfo, dict): + ref_wcsinfo = ref_wcsinfo.instance _validate_wcs_list(wcs_list) fiducial = _calculate_fiducial(wcs_list=wcs_list, bounding_box=bounding_box, crval=crval) @@ -651,7 +691,7 @@ def wcs_from_footprints( pscale_ratio=pscale_ratio, pscale=pscale, rotation=rotation, - ref_fiducial=np.array([ref_wcsinfo.ra_ref, ref_wcsinfo.dec_ref]), + ref_fiducial=np.array([ref_wcsinfo["ra_ref"], ref_wcsinfo["dec_ref"]]), transform=transform, ) @@ -665,7 +705,10 @@ def wcs_from_footprints( ) -def update_s_region_imaging(wcs, wcsinfo, shape=None, center=True): +def update_s_region_imaging(wcs: gwcs.wcs.WCS, + wcsinfo: dict | Wcsinfo, + shape: Sequence | None = None, + center: bool | None = None) -> dict: """ Update the ``S_REGION`` keyword using the WCS footprint. @@ -685,6 +728,8 @@ def update_s_region_imaging(wcs, wcsinfo, shape=None, center=True): Whether or not to use the center of the pixel as reference for the coordinates, by default True """ + if not isinstance(wcsinfo, dict): + wcsinfo = wcsinfo.instance bbox = wcs.bounding_box if shape is None and bbox is None: msg = "If wcs.bounding_box is not specified, shape must be provided." @@ -715,7 +760,7 @@ def update_s_region_imaging(wcs, wcsinfo, shape=None, center=True): return wcsinfo -def wcs_bbox_from_shape(shape): +def wcs_bbox_from_shape(shape: Sequence) -> tuple: """Create a bounding box from the shape of the data. This is appropriate to attach to a wcs object @@ -733,7 +778,8 @@ def wcs_bbox_from_shape(shape): return (-0.5, shape[-1] - 0.5), (-0.5, shape[-2] - 0.5) -def update_s_region_keyword(wcsinfo, footprint): +def update_s_region_keyword(wcsinfo: dict | Wcsinfo, + footprint: np.ndarray) -> None: """Update the S_REGION keyword. Parameters @@ -755,12 +801,15 @@ def update_s_region_keyword(wcsinfo, footprint): if "nan" in s_region: # do not update s_region if there are NaNs. log.info("There are NaNs in s_region, S_REGION not updated.") - else: + return + if not isinstance(wcsinfo, dict): wcsinfo.s_region = s_region - log.info("Update S_REGION to %s", wcsinfo.s_region) + else: + wcsinfo["s_region"] = s_region + log.info("Update S_REGION to %s", s_region) -def reproject(wcs1, wcs2): +def reproject(wcs1: gwcs.wcs.WCS, wcs2: gwcs.wcs.WCS) -> Callable: """ Given two WCSs or transforms return a function which takes pixel coordinates in the first WCS or transform and computes them in pixel coordinates diff --git a/src/stcal/tweakreg/tweakreg.py b/src/stcal/tweakreg/tweakreg.py index f66c8e49..aa8c6166 100644 --- a/src/stcal/tweakreg/tweakreg.py +++ b/src/stcal/tweakreg/tweakreg.py @@ -10,6 +10,8 @@ if TYPE_CHECKING: import astropy import gwcs + + from stcal.alignment import Wcsinfo from astropy.coordinates import SkyCoord from astropy.table import Table from tweakwcs.correctors import JWSTWCSCorrector @@ -20,22 +22,9 @@ from .astrometric_utils import create_astrometric_catalog - -def _oxford_or_str_join(str_list): - nelem = len(str_list) - if not nelem: - return "N/A" - str_list = list(map(repr, str_list)) - if nelem == 1: - return str_list - if nelem == 2: - return str_list[0] + " or " + str_list[1] - return ", ".join(map(repr, str_list[:-1])) + ", or " + repr(str_list[-1]) - - _SQRT2 = math.sqrt(2.0) SINGLE_GROUP_REFCAT = ["GAIADR3", "GAIADR2", "GAIADR1"] -_SINGLE_GROUP_REFCAT_STR = _oxford_or_str_join(SINGLE_GROUP_REFCAT) +_SINGLE_GROUP_REFCAT_STR = ",".join(SINGLE_GROUP_REFCAT) __all__ = ["relative_align", "absolute_align", "SINGLE_GROUP_REFCAT", "filter_catalog_by_bounding_box"] @@ -125,7 +114,7 @@ def relative_align(correctors: list, def absolute_align(correctors: list, abs_refcat: str, ref_wcs: gwcs.wcs.WCS, - ref_wcsinfo: dict, + ref_wcsinfo: dict | Wcsinfo, epoch: str | astropy.time.Time, save_abs_catalog: bool = False, abs_catalog_output_dir: str | None = None, @@ -235,6 +224,7 @@ def _parse_refcat(abs_refcat: str | Path, and if the latter, retrieve that catalog from the Web. If desired, save the reference catalog in the specified directory. """ + abs_refcat = str(abs_refcat).strip() if save_abs_catalog: root = f"fit_{abs_refcat.lower()}_ref.ecsv" output_name = Path(root) if output_dir is None \ @@ -242,7 +232,6 @@ def _parse_refcat(abs_refcat: str | Path, else: output_name = None - abs_refcat = str(abs_refcat).strip() gaia_cat_name = abs_refcat.upper() if gaia_cat_name in SINGLE_GROUP_REFCAT: @@ -260,7 +249,7 @@ def _parse_refcat(abs_refcat: str | Path, output=output_name, ) - if Path.is_file(Path(abs_refcat)): + if Path(abs_refcat).is_file(): return Table.read(abs_refcat) msg = (f"Invalid 'abs_refcat' value: {abs_refcat}. 'abs_refcat' must be " @@ -299,7 +288,7 @@ def _wcs_to_skycoord(wcs): return SkyCoord(ra=ra, dec=dec, unit="deg") -def filter_catalog_by_bounding_box(catalog: Table, bounding_box: list[float]) -> Table: +def filter_catalog_by_bounding_box(catalog: Table, bounding_box: list[tuple]) -> Table: """ Given a catalog of x,y positions, only return sources that fall inside the bounding box. @@ -316,17 +305,26 @@ def filter_catalog_by_bounding_box(catalog: Table, bounding_box: list[float]) -> def construct_wcs_corrector(wcs: gwcs.WCS, - wcsinfo: dict, + refang: dict | Wcsinfo, catalog: Table, group_id: str,) -> JWSTWCSCorrector: """ pre-compute skycoord here so we can later use it to check for a small wcs correction. + + Parameters + ---------- + wcs : `gwcs.WCS` + WCS object to be corrected. + + refang : dict + Dictionary containing WCSreference angles. """ + if not isinstance(refang, dict): + refang = refang.instance catalog = filter_catalog_by_bounding_box( catalog, wcs.bounding_box) - refang = wcsinfo.instance return JWSTWCSCorrector( wcs=wcs, wcsinfo={"roll_ref": refang["roll_ref"], diff --git a/tests/test_alignment.py b/tests/test_alignment.py index 9b249d0f..d7530bec 100644 --- a/tests/test_alignment.py +++ b/tests/test_alignment.py @@ -10,6 +10,7 @@ from stcal.alignment import resample_utils from stcal.alignment.util import ( + Wcsinfo, _validate_wcs_list, compute_fiducial, compute_scale, @@ -70,7 +71,7 @@ def _create_wcs_and_datamodel(fiducial_world, shape, pscale): ) -class WcsInfo: +class WcsInfo(Wcsinfo): def __init__(self, ra_ref, dec_ref, roll_ref, v2_ref, v3_ref, v3yangle): self.ra_ref = ra_ref self.dec_ref = dec_ref @@ -83,6 +84,22 @@ def __init__(self, ra_ref, dec_ref, roll_ref, v2_ref, v3_ref, v3yangle): self.vparity = -1 self.wcsaxes = 2 self.s_region = "" + self.instance = self.instance() + + def instance(self): + return { + "ra_ref": self.ra_ref, + "dec_ref": self.dec_ref, + "ctype1": self.ctype1, + "ctype2": self.ctype2, + "v2_ref": self.v2_ref, + "v3_ref": self.v3_ref, + "v3yangle": self.v3yangle, + "roll_ref": self.roll_ref, + "vparity": self.vparity, + "wcsaxes": self.wcsaxes, + "s_region": self.s_region, + } class Coordinates: @@ -364,4 +381,6 @@ def test_update_s_region_imaging(model, bounding_box, data): shape = data.shape if data is not None else None update_s_region_imaging(model.meta.wcs, model.meta.wcsinfo, shape=shape, center=False) updated_s_region_coords = [float(x) for x in model.meta.wcsinfo.s_region.split(" ")[3:]] - assert all(np.isclose(x, y) for x, y in zip(updated_s_region_coords, expected_s_region_coords)) + assert all(np.isclose(x, y) for x, y in zip(updated_s_region_coords, + expected_s_region_coords, + strict=False)) diff --git a/tests/test_tweakreg.py b/tests/test_tweakreg.py index 05bde8b7..69135784 100644 --- a/tests/test_tweakreg.py +++ b/tests/test_tweakreg.py @@ -189,9 +189,6 @@ def __init__(self, wcs, epoch="2016-01-01T00:00:00.0", group_id=None): self.meta = Metadata(wcs, epoch, group_id=group_id) self.data = np.zeros((512, 512)) - def to_flat_dict(): - pass - @pytest.fixture(scope="module") def datamodel(wcsobj2, group_id=None): @@ -279,7 +276,7 @@ def test_relative_align(example_input, input_catalog, with_shift): cat1["y"] -= 9 correctors = [construct_wcs_corrector(dm.meta.wcs, - dm.meta.wcsinfo, + dm.meta.wcsinfo.instance, cat, dm.meta.group_id) for (dm, cat) in \ zip([m0, m1], [input_catalog, cat1], strict=True)] @@ -297,7 +294,7 @@ def test_relative_align(example_input, input_catalog, with_shift): def test_absolute_align(example_input, input_catalog): correctors = [construct_wcs_corrector(dm.meta.wcs, - dm.meta.wcsinfo, + dm.meta.wcsinfo.instance, input_catalog, dm.meta.group_id) for dm in example_input] From 0f945df52382a2e5d051553d861a1cbca425bcfd Mon Sep 17 00:00:00 2001 From: Ned Molter Date: Wed, 17 Jul 2024 15:49:23 -0400 Subject: [PATCH 22/36] fix docstring build --- src/stcal/alignment/util.py | 38 ++++++++++++++++++++----------------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/src/stcal/alignment/util.py b/src/stcal/alignment/util.py index d9e1ba3d..a50bb8bd 100644 --- a/src/stcal/alignment/util.py +++ b/src/stcal/alignment/util.py @@ -8,6 +8,9 @@ if TYPE_CHECKING: from collections.abc import Callable, Sequence + import astropy + import numpy # noqa: ICN001 this is the only way Sphinx can find ndarray + import gwcs import numpy as np from astropy import units as u @@ -48,7 +51,7 @@ def instance(self): def _calculate_fiducial_from_spatial_footprint( - spatial_footprint: np.ndarray, + spatial_footprint: numpy.ndarray, ) -> tuple: """ Calculates the fiducial coordinates from a given spatial footprint. @@ -81,7 +84,7 @@ def _calculate_fiducial_from_spatial_footprint( def _generate_tranform( wcs: gwcs.wcs.WCS, wcsinfo: dict | Wcsinfo, - ref_fiducial: np.ndarray, + ref_fiducial: numpy.ndarray, pscale_ratio: float | None = None, pscale: float | None = None, rotation: float | None = None, @@ -197,7 +200,7 @@ def _get_axis_min_and_bounding_box(wcs_list: list[gwcs.wcs.WCS], def _calculate_fiducial(wcs_list: list[gwcs.wcs.WCS], bounding_box: Sequence | None, - crval: list | None = None) -> np.ndarray: + crval: list | None = None) -> numpy.ndarray: """ Calculates the coordinates of the fiducial point and, if necessary, updates it with the values in CRVAL (the update is applied to spatial axes only). @@ -235,9 +238,9 @@ def _calculate_fiducial(wcs_list: list[gwcs.wcs.WCS], return fiducial -def _calculate_offsets(fiducial: np.ndarray, +def _calculate_offsets(fiducial: numpy.ndarray, wcs: gwcs.wcs.WCS | None, - axis_min_values: np.ndarray | None, + axis_min_values: numpy.ndarray | None, crpix: Sequence | None) -> astmodels.Model: """ Calculates the offsets to the transform. @@ -281,7 +284,7 @@ def _calculate_offsets(fiducial: np.ndarray, def _calculate_new_wcs(wcs: gwcs.wcs.WCS, shape: list | None, wcs_list: list[gwcs.wcs.WCS], - fiducial: np.ndarray, + fiducial: numpy.ndarray, crpix: tuple | None = None, transform: astmodels.Model | None = None, ) -> gwcs.wcs.WCS: @@ -382,7 +385,7 @@ def _validate_wcs_list(wcs_list: list[gwcs.wcs.WCS]) -> bool: def wcsinfo_from_model(wcsinfo: dict | Wcsinfo, reference_frame: str, - ) -> dict[str, np.ndarray | str | bool]: + ) -> dict[str, numpy.ndarray | str | bool]: """ Creates a dict {wcs_keyword: array_of_values} pairs from a datamodel. @@ -434,8 +437,8 @@ def wcsinfo_from_model(wcsinfo: dict | Wcsinfo, def compute_scale( - wcs: gwcs.WCS, - fiducial: tuple | np.ndarray, + wcs: gwcs.wcs.WCS, + fiducial: tuple | numpy.ndarray, disp_axis: int | None = None, pscale_ratio: float | None = None, ) -> float: @@ -498,7 +501,8 @@ def compute_scale( return np.sqrt(xscale * yscale) -def compute_fiducial(wcslist: list, bounding_box: Sequence | None = None) -> np.ndarray: +def compute_fiducial(wcslist: list, + bounding_box: Sequence | None = None) -> numpy.ndarray: """ Calculates the world coordinates of the fiducial point of a list of WCS objects. For a celestial footprint this is the center. For a spectral footprint, it is the @@ -587,18 +591,18 @@ def calc_rotation_matrix(roll_ref: float, v3i_yangle: float, vparity: int = 1) - def wcs_from_footprints( - wcs_list: list[gwcs.WCS], - ref_wcs: gwcs.WCS, + wcs_list: list[gwcs.wcs.WCS], + ref_wcs: gwcs.wcs.WCS, ref_wcsinfo: dict | Wcsinfo, - transform: astmodels.Model | None = None, + transform: astropy.modeling.models.Model | None = None, bounding_box: Sequence | None = None, pscale_ratio: float | None = None, pscale: float | None = None, rotation: float | None = None, shape: Sequence | None = None, - crpix: Sequence| None = None, + crpix: Sequence | None = None, crval: Sequence | None = None, -) -> gwcs.WCS: +) -> gwcs.wcs.WCS: """ Create a WCS from a list of input datamodels. @@ -779,7 +783,7 @@ def wcs_bbox_from_shape(shape: Sequence) -> tuple: def update_s_region_keyword(wcsinfo: dict | Wcsinfo, - footprint: np.ndarray) -> None: + footprint: numpy.ndarray) -> None: """Update the S_REGION keyword. Parameters @@ -855,7 +859,7 @@ def _get_backward_transform_func(wcs2): raise TypeError(msg) return backward_transform - def _reproject(x: float | np.ndarray, y: float | np.ndarray) -> tuple: + def _reproject(x: float | numpy.ndarray, y: float | numpy.ndarray) -> tuple: """ Reprojects the input coordinates from one WCS to another. From 32bda6a5a27255a6f51e07bb704193bddfc7d5b5 Mon Sep 17 00:00:00 2001 From: Ned Molter Date: Thu, 18 Jul 2024 14:52:56 -0400 Subject: [PATCH 23/36] responding to @braingram comments --- src/stcal/alignment/util.py | 57 +++++++++++++++++----------------- src/stcal/tweakreg/tweakreg.py | 2 +- tox.ini | 2 ++ 3 files changed, 32 insertions(+), 29 deletions(-) diff --git a/src/stcal/alignment/util.py b/src/stcal/alignment/util.py index a50bb8bd..daafe8ec 100644 --- a/src/stcal/alignment/util.py +++ b/src/stcal/alignment/util.py @@ -3,13 +3,13 @@ import functools import logging -from typing import TYPE_CHECKING, Protocol, runtime_checkable +from typing import TYPE_CHECKING, Protocol if TYPE_CHECKING: from collections.abc import Callable, Sequence import astropy - import numpy # noqa: ICN001 this is the only way Sphinx can find ndarray + import numpy # noqa: ICN001 needed to work around Sphinx type hint bug import gwcs import numpy as np @@ -35,7 +35,6 @@ ] -@runtime_checkable class Wcsinfo(Protocol): wcsaxes: int ra_ref: float @@ -46,25 +45,25 @@ class Wcsinfo(Protocol): s_region: str @property - def instance(self): + def instance(self: Wcsinfo) -> dict: ... def _calculate_fiducial_from_spatial_footprint( - spatial_footprint: numpy.ndarray, + spatial_footprint: np.ndarray, ) -> tuple: """ Calculates the fiducial coordinates from a given spatial footprint. Parameters ---------- - spatial_footprint : numpy.ndarray + spatial_footprint : np.ndarray A 2xN array containing the world coordinates of the WCS footprint's bounding box, where N is the number of bounding box positions. Returns ------- - lon_fiducial, lat_fiducial : numpy.ndarray, numpy.ndarray + lon_fiducial, lat_fiducial : np.ndarray, np.ndarray The world coordinates of the fiducial point in the output coordinate frame. """ lon, lat = spatial_footprint @@ -84,7 +83,7 @@ def _calculate_fiducial_from_spatial_footprint( def _generate_tranform( wcs: gwcs.wcs.WCS, wcsinfo: dict | Wcsinfo, - ref_fiducial: numpy.ndarray, + ref_fiducial: np.ndarray, pscale_ratio: float | None = None, pscale: float | None = None, rotation: float | None = None, @@ -176,7 +175,7 @@ def _get_axis_min_and_bounding_box(wcs_list: list[gwcs.wcs.WCS], ------- tuple A tuple containing two elements: - 1 - a :py:class:`numpy.ndarray` with the minimum value in each axis; + 1 - a :py:class:`np.ndarray` with the minimum value in each axis; 2 - a tuple containing the bounding box region in the format ((x0_lower, x0_upper), (x1_lower, x1_upper)). """ @@ -194,13 +193,12 @@ def _get_axis_min_and_bounding_box(wcs_list: list[gwcs.wcs.WCS], # populate output_bounding_box output_bounding_box.append((axis_min, axis_max)) - output_bounding_box = tuple(output_bounding_box) return (axis_min_values, output_bounding_box) def _calculate_fiducial(wcs_list: list[gwcs.wcs.WCS], bounding_box: Sequence | None, - crval: list | None = None) -> numpy.ndarray: + crval: Sequence | None = None) -> np.ndarray: """ Calculates the coordinates of the fiducial point and, if necessary, updates it with the values in CRVAL (the update is applied to spatial axes only). @@ -224,7 +222,7 @@ def _calculate_fiducial(wcs_list: list[gwcs.wcs.WCS], Returns ------- - fiducial : numpy.ndarray + fiducial : np.ndarray A two-elements array containing the world coordinate of the fiducial point. """ fiducial = compute_fiducial(wcs_list, bounding_box=bounding_box) @@ -238,22 +236,22 @@ def _calculate_fiducial(wcs_list: list[gwcs.wcs.WCS], return fiducial -def _calculate_offsets(fiducial: numpy.ndarray, +def _calculate_offsets(fiducial: np.ndarray, wcs: gwcs.wcs.WCS | None, - axis_min_values: numpy.ndarray | None, + axis_min_values: np.ndarray | None, crpix: Sequence | None) -> astmodels.Model: """ Calculates the offsets to the transform. Parameters ---------- - fiducial : numpy.ndarray + fiducial : np.ndarray A two-elements containing the world coordinates of the fiducial point. wcs : ~gwcs.wcs.WCS A WCS object. It will be used to determine the - axis_min_values : numpy.ndarray + axis_min_values : np.ndarray A two-elements array containing the minimum pixel value for each axis. crpix : list or tuple @@ -275,6 +273,9 @@ def _calculate_offsets(fiducial: numpy.ndarray, offset1, offset2 = wcs.backward_transform(*fiducial) offset1 -= axis_min_values[0] offset2 -= axis_min_values[1] + elif crpix is None: + msg = "If crpix is not provided, fiducial, wcs, and axis_min_values must be provided." + raise ValueError(msg) else: offset1, offset2 = crpix @@ -282,10 +283,10 @@ def _calculate_offsets(fiducial: numpy.ndarray, def _calculate_new_wcs(wcs: gwcs.wcs.WCS, - shape: list | None, + shape: Sequence | None, wcs_list: list[gwcs.wcs.WCS], - fiducial: numpy.ndarray, - crpix: tuple | None = None, + fiducial: np.ndarray, + crpix: Sequence | None = None, transform: astmodels.Model | None = None, ) -> gwcs.wcs.WCS: """ @@ -303,7 +304,7 @@ def _calculate_new_wcs(wcs: gwcs.wcs.WCS, wcs_list : list A list containing WCS objects. - fiducial : numpy.ndarray + fiducial : np.ndarray A two-elements array containing the location on the sky in some standard coordinate system. @@ -385,7 +386,7 @@ def _validate_wcs_list(wcs_list: list[gwcs.wcs.WCS]) -> bool: def wcsinfo_from_model(wcsinfo: dict | Wcsinfo, reference_frame: str, - ) -> dict[str, numpy.ndarray | str | bool]: + ) -> dict[str, np.ndarray | str | bool]: """ Creates a dict {wcs_keyword: array_of_values} pairs from a datamodel. @@ -438,7 +439,7 @@ def wcsinfo_from_model(wcsinfo: dict | Wcsinfo, def compute_scale( wcs: gwcs.wcs.WCS, - fiducial: tuple | numpy.ndarray, + fiducial: tuple | np.ndarray, disp_axis: int | None = None, pscale_ratio: float | None = None, ) -> float: @@ -523,7 +524,7 @@ def compute_fiducial(wcslist: list, Returns ------- - fiducial : numpy.ndarray + fiducial : np.ndarray A two-elements array containing the world coordinates of the fiducial point in the combined output coordinate frame. @@ -657,7 +658,7 @@ def wcs_from_footprints( provided. shape : tuple of int, None - Shape of the image (data array) using ``numpy.ndarray`` convention + Shape of the image (data array) using ``np.ndarray`` convention (``ny`` first and ``nx`` second). This value will be assigned to ``pixel_shape`` and ``array_shape`` properties of the returned WCS object. @@ -739,7 +740,7 @@ def update_s_region_imaging(wcs: gwcs.wcs.WCS, msg = "If wcs.bounding_box is not specified, shape must be provided." raise ValueError(msg) - if bbox is None: + if shape is not None and bbox is None: bbox = wcs_bbox_from_shape(shape) wcs.bounding_box = bbox @@ -772,7 +773,7 @@ def wcs_bbox_from_shape(shape: Sequence) -> tuple: Parameters ---------- shape : tuple - The shape attribute from a `numpy.ndarray` array + The shape attribute from a `np.ndarray` array Returns ------- @@ -783,7 +784,7 @@ def wcs_bbox_from_shape(shape: Sequence) -> tuple: def update_s_region_keyword(wcsinfo: dict | Wcsinfo, - footprint: numpy.ndarray) -> None: + footprint: np.ndarray) -> None: """Update the S_REGION keyword. Parameters @@ -859,7 +860,7 @@ def _get_backward_transform_func(wcs2): raise TypeError(msg) return backward_transform - def _reproject(x: float | numpy.ndarray, y: float | numpy.ndarray) -> tuple: + def _reproject(x: float | np.ndarray, y: float | np.ndarray) -> tuple: """ Reprojects the input coordinates from one WCS to another. diff --git a/src/stcal/tweakreg/tweakreg.py b/src/stcal/tweakreg/tweakreg.py index aa8c6166..d8487f67 100644 --- a/src/stcal/tweakreg/tweakreg.py +++ b/src/stcal/tweakreg/tweakreg.py @@ -214,7 +214,7 @@ def absolute_align(correctors: list, def _parse_refcat(abs_refcat: str | Path, correctors: list, wcs: gwcs.wcs.WCS, - wcsinfo: dict, + wcsinfo: dict | Wcsinfo, epoch: str | astropy.time.Time, save_abs_catalog: bool = False, output_dir: str | None = None) -> Table: diff --git a/tox.ini b/tox.ini index 91fc6868..8dc0aa59 100644 --- a/tox.ini +++ b/tox.ini @@ -54,6 +54,8 @@ deps = devdeps: scipy>=0.0.dev0 devdeps: pyerfa>=0.0.dev0 devdeps: astropy>=0.0.dev0 + devdeps: tweakreg>=0.0.dev0 + devdeps: requests>=0.0.dev0 use_develop = true pass_env = CI From 25ec18dba95c8d127b21008aae43c56af66a3830 Mon Sep 17 00:00:00 2001 From: Ned Molter Date: Fri, 19 Jul 2024 10:02:11 -0400 Subject: [PATCH 24/36] updates to Wcsinfo protocol --- src/stcal/alignment/util.py | 118 ++++++++++++--------------------- src/stcal/tweakreg/tweakreg.py | 7 +- 2 files changed, 46 insertions(+), 79 deletions(-) diff --git a/src/stcal/alignment/util.py b/src/stcal/alignment/util.py index daafe8ec..8cb7187d 100644 --- a/src/stcal/alignment/util.py +++ b/src/stcal/alignment/util.py @@ -3,7 +3,7 @@ import functools import logging -from typing import TYPE_CHECKING, Protocol +from typing import TYPE_CHECKING, Protocol, runtime_checkable if TYPE_CHECKING: from collections.abc import Callable, Sequence @@ -31,12 +31,13 @@ "update_s_region_keyword", "wcs_from_footprints", "reproject", - "Wcsinfo" + "Wcsinfo", + "wcsinfo_to_dict", ] +@runtime_checkable class Wcsinfo(Protocol): - wcsaxes: int ra_ref: float dec_ref: float v3yangle: float @@ -44,13 +45,33 @@ class Wcsinfo(Protocol): roll_ref: float s_region: str - @property - def instance(self: Wcsinfo) -> dict: - ... + +def wcsinfo_to_dict(wcsinfo: Wcsinfo) -> dict: + """ + Convert a WCSInfo object to a dictionary. + + Parameters + ---------- + wcsinfo : Wcsinfo + A WCSInfo object. + + Returns + ------- + dict + A dictionary containing the WCS FITS keywords and corresponding values. + """ + return { + "ra_ref": wcsinfo.ra_ref, + "dec_ref": wcsinfo.dec_ref, + "v3yangle": wcsinfo.v3yangle, + "vparity": wcsinfo.vparity, + "roll_ref": wcsinfo.roll_ref, + "s_region": wcsinfo.s_region, + } def _calculate_fiducial_from_spatial_footprint( - spatial_footprint: np.ndarray, + spatial_footprint: numpy.ndarray, ) -> tuple: """ Calculates the fiducial coordinates from a given spatial footprint. @@ -83,7 +104,7 @@ def _calculate_fiducial_from_spatial_footprint( def _generate_tranform( wcs: gwcs.wcs.WCS, wcsinfo: dict | Wcsinfo, - ref_fiducial: np.ndarray, + ref_fiducial: numpy.ndarray, pscale_ratio: float | None = None, pscale: float | None = None, rotation: float | None = None, @@ -130,8 +151,8 @@ def _generate_tranform( transform : ~astropy.modeling.Model An :py:mod:`~astropy` model containing the transform between frames. """ - if not isinstance(wcsinfo, dict): - wcsinfo = wcsinfo.instance + if isinstance(wcsinfo, Wcsinfo): + wcsinfo = wcsinfo_to_dict(wcsinfo) if transform is None: sky_axes = wcs._get_axes_indices().tolist() # noqa: SLF001 v3yangle = np.deg2rad(wcsinfo["v3yangle"]) @@ -198,7 +219,7 @@ def _get_axis_min_and_bounding_box(wcs_list: list[gwcs.wcs.WCS], def _calculate_fiducial(wcs_list: list[gwcs.wcs.WCS], bounding_box: Sequence | None, - crval: Sequence | None = None) -> np.ndarray: + crval: Sequence | None = None) -> numpy.ndarray: """ Calculates the coordinates of the fiducial point and, if necessary, updates it with the values in CRVAL (the update is applied to spatial axes only). @@ -236,9 +257,9 @@ def _calculate_fiducial(wcs_list: list[gwcs.wcs.WCS], return fiducial -def _calculate_offsets(fiducial: np.ndarray, +def _calculate_offsets(fiducial: numpy.ndarray, wcs: gwcs.wcs.WCS | None, - axis_min_values: np.ndarray | None, + axis_min_values: numpy.ndarray | None, crpix: Sequence | None) -> astmodels.Model: """ Calculates the offsets to the transform. @@ -285,7 +306,7 @@ def _calculate_offsets(fiducial: np.ndarray, def _calculate_new_wcs(wcs: gwcs.wcs.WCS, shape: Sequence | None, wcs_list: list[gwcs.wcs.WCS], - fiducial: np.ndarray, + fiducial: numpy.ndarray, crpix: Sequence | None = None, transform: astmodels.Model | None = None, ) -> gwcs.wcs.WCS: @@ -384,62 +405,9 @@ def _validate_wcs_list(wcs_list: list[gwcs.wcs.WCS]) -> bool: return True -def wcsinfo_from_model(wcsinfo: dict | Wcsinfo, - reference_frame: str, - ) -> dict[str, np.ndarray | str | bool]: - """ - Creates a dict {wcs_keyword: array_of_values} pairs from a datamodel. - - What is this actually doing? it looks badly named, as it requires the input - model to have its own wcsinfo already. It seems to just be setting defaults - in wcsinfo if they are None, then making the wcsinfo["PC"] matrix - - Parameters - ---------- - wcsinfo : dict - The input wcsinfo dict. - - reference_frame : str - The reference frame of the input model. - - Returns - ------- - wcsinfo : dict - A dict containing the WCS FITS keywords and corresponding values. - - """ - if not isinstance(wcsinfo, dict): - wcsinfo = wcsinfo.instance - defaults = { - "CRPIX": 0, - "CRVAL": 0, - "CDELT": 1.0, - "CTYPE": "", - "CUNIT": u.Unit(""), - } - wcsaxes = wcsinfo["wcsaxes"] - wcsinfo = {"WCSAXES": wcsaxes} - for key in ["CRPIX", "CRVAL", "CDELT", "CTYPE", "CUNIT"]: - val = [] - for ax in range(1, wcsaxes + 1): - k = (key + f"{ax}").lower() - v = getattr(wcsinfo, k, defaults[key]) - val.append(v) - wcsinfo[key] = np.array(val) - - pc = np.zeros((wcsaxes, wcsaxes), dtype=np.float32) - for i in range(1, wcsaxes + 1): - for j in range(1, wcsaxes + 1): - pc[i - 1, j - 1] = getattr(wcsinfo, f"pc{i}_{j}", 1) - wcsinfo["PC"] = pc - wcsinfo["RADESYS"] = reference_frame - wcsinfo["has_cd"] = False - return wcsinfo - - def compute_scale( wcs: gwcs.wcs.WCS, - fiducial: tuple | np.ndarray, + fiducial: tuple | numpy.ndarray, disp_axis: int | None = None, pscale_ratio: float | None = None, ) -> float: @@ -682,8 +650,8 @@ def wcs_from_footprints( The WCS object corresponding to the combined input footprints. """ - if not isinstance(ref_wcsinfo, dict): - ref_wcsinfo = ref_wcsinfo.instance + if isinstance(ref_wcsinfo, Wcsinfo): + ref_wcsinfo = wcsinfo_to_dict(ref_wcsinfo) _validate_wcs_list(wcs_list) fiducial = _calculate_fiducial(wcs_list=wcs_list, bounding_box=bounding_box, crval=crval) @@ -733,8 +701,8 @@ def update_s_region_imaging(wcs: gwcs.wcs.WCS, Whether or not to use the center of the pixel as reference for the coordinates, by default True """ - if not isinstance(wcsinfo, dict): - wcsinfo = wcsinfo.instance + if isinstance(wcsinfo, Wcsinfo): + wcsinfo = wcsinfo_to_dict(wcsinfo) bbox = wcs.bounding_box if shape is None and bbox is None: msg = "If wcs.bounding_box is not specified, shape must be provided." @@ -784,7 +752,7 @@ def wcs_bbox_from_shape(shape: Sequence) -> tuple: def update_s_region_keyword(wcsinfo: dict | Wcsinfo, - footprint: np.ndarray) -> None: + footprint: numpy.ndarray) -> None: """Update the S_REGION keyword. Parameters @@ -807,7 +775,7 @@ def update_s_region_keyword(wcsinfo: dict | Wcsinfo, # do not update s_region if there are NaNs. log.info("There are NaNs in s_region, S_REGION not updated.") return - if not isinstance(wcsinfo, dict): + if isinstance(wcsinfo, Wcsinfo): wcsinfo.s_region = s_region else: wcsinfo["s_region"] = s_region @@ -860,7 +828,7 @@ def _get_backward_transform_func(wcs2): raise TypeError(msg) return backward_transform - def _reproject(x: float | np.ndarray, y: float | np.ndarray) -> tuple: + def _reproject(x: float | numpy.ndarray, y: float | numpy.ndarray) -> tuple: """ Reprojects the input coordinates from one WCS to another. diff --git a/src/stcal/tweakreg/tweakreg.py b/src/stcal/tweakreg/tweakreg.py index d8487f67..e790cd9a 100644 --- a/src/stcal/tweakreg/tweakreg.py +++ b/src/stcal/tweakreg/tweakreg.py @@ -11,14 +11,13 @@ import astropy import gwcs - from stcal.alignment import Wcsinfo from astropy.coordinates import SkyCoord from astropy.table import Table from tweakwcs.correctors import JWSTWCSCorrector from tweakwcs.imalign import align_wcs from tweakwcs.matchutils import XYXYMatch -from stcal.alignment import wcs_from_footprints +from stcal.alignment import Wcsinfo, wcs_from_footprints, wcsinfo_to_dict from .astrometric_utils import create_astrometric_catalog @@ -320,8 +319,8 @@ def construct_wcs_corrector(wcs: gwcs.WCS, refang : dict Dictionary containing WCSreference angles. """ - if not isinstance(refang, dict): - refang = refang.instance + if isinstance(refang, Wcsinfo): + refang = wcsinfo_to_dict(refang) catalog = filter_catalog_by_bounding_box( catalog, wcs.bounding_box) From 89128739fe196fa3ad0e1ce493e4d340e68486d8 Mon Sep 17 00:00:00 2001 From: Ned Molter Date: Fri, 19 Jul 2024 10:30:42 -0400 Subject: [PATCH 25/36] pyproject.toml config for mypy --- pyproject.toml | 13 +++++++++++++ src/stcal/alignment/util.py | 5 ++--- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 528898fe..16861e07 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -196,6 +196,19 @@ convention = "numpy" [tool.ruff.lint.flake8-annotations] ignore-fully-untyped = true # Turn of annotation checking for fully untyped code +[tool.mypy] +python_version = "3.12" +warn_return_any = true +warn_unused_configs = true + +[[tool.mypy.overrides]] +module = [ + "astropy.*", + "gwcs.*", + "tweakwcs.*", +] +ignore_missing_imports = true + [tool.cython-lint] max-line-length = 110 diff --git a/src/stcal/alignment/util.py b/src/stcal/alignment/util.py index 8cb7187d..36882f0e 100644 --- a/src/stcal/alignment/util.py +++ b/src/stcal/alignment/util.py @@ -13,7 +13,6 @@ import gwcs import numpy as np -from astropy import units as u from astropy import wcs as fitswcs from astropy.coordinates import SkyCoord from astropy.modeling import models as astmodels @@ -465,9 +464,9 @@ def compute_scale( if spectral: # Assuming scale doesn't change with wavelength # Assuming disp_axis is consistent with DataModel.meta.wcsinfo.dispersion.direction - return yscale if disp_axis == 1 else xscale + return float(yscale) if disp_axis == 1 else float(xscale) - return np.sqrt(xscale * yscale) + return float(np.sqrt(xscale * yscale)) def compute_fiducial(wcslist: list, From a44d758a0d40dc2c7b988911c4dcbde597117ea4 Mon Sep 17 00:00:00 2001 From: Ned Molter Date: Fri, 19 Jul 2024 10:35:44 -0400 Subject: [PATCH 26/36] nitpick ignore to make docs build pass --- docs/conf.py | 2 +- src/stcal/alignment/util.py | 23 +++++++++++------------ 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 37082df6..c90a228a 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -50,7 +50,7 @@ def setup(app): "tweakwcs": ("https://tweakwcs.readthedocs.io/en/latest/", None), } -nitpick_ignore = [("py:class", "optional")] +nitpick_ignore = [("py:class", "optional"), ("py:class", "np.ndarray")] extensions = [ "pytest_doctestplus.sphinx.doctestplus", diff --git a/src/stcal/alignment/util.py b/src/stcal/alignment/util.py index 36882f0e..4269e249 100644 --- a/src/stcal/alignment/util.py +++ b/src/stcal/alignment/util.py @@ -9,7 +9,6 @@ from collections.abc import Callable, Sequence import astropy - import numpy # noqa: ICN001 needed to work around Sphinx type hint bug import gwcs import numpy as np @@ -70,7 +69,7 @@ def wcsinfo_to_dict(wcsinfo: Wcsinfo) -> dict: def _calculate_fiducial_from_spatial_footprint( - spatial_footprint: numpy.ndarray, + spatial_footprint: np.ndarray, ) -> tuple: """ Calculates the fiducial coordinates from a given spatial footprint. @@ -103,7 +102,7 @@ def _calculate_fiducial_from_spatial_footprint( def _generate_tranform( wcs: gwcs.wcs.WCS, wcsinfo: dict | Wcsinfo, - ref_fiducial: numpy.ndarray, + ref_fiducial: np.ndarray, pscale_ratio: float | None = None, pscale: float | None = None, rotation: float | None = None, @@ -139,7 +138,7 @@ def _generate_tranform( provided. If `None`, the rotation angle is extracted from the reference model's ``meta.wcsinfo.roll_ref``. - ref_fiducial : numpy.array + ref_fiducial : np.array A two-elements array containing the world coordinates of the fiducial point. transform : ~astropy.modeling.Model @@ -218,7 +217,7 @@ def _get_axis_min_and_bounding_box(wcs_list: list[gwcs.wcs.WCS], def _calculate_fiducial(wcs_list: list[gwcs.wcs.WCS], bounding_box: Sequence | None, - crval: Sequence | None = None) -> numpy.ndarray: + crval: Sequence | None = None) -> np.ndarray: """ Calculates the coordinates of the fiducial point and, if necessary, updates it with the values in CRVAL (the update is applied to spatial axes only). @@ -256,9 +255,9 @@ def _calculate_fiducial(wcs_list: list[gwcs.wcs.WCS], return fiducial -def _calculate_offsets(fiducial: numpy.ndarray, +def _calculate_offsets(fiducial: np.ndarray, wcs: gwcs.wcs.WCS | None, - axis_min_values: numpy.ndarray | None, + axis_min_values: np.ndarray | None, crpix: Sequence | None) -> astmodels.Model: """ Calculates the offsets to the transform. @@ -305,7 +304,7 @@ def _calculate_offsets(fiducial: numpy.ndarray, def _calculate_new_wcs(wcs: gwcs.wcs.WCS, shape: Sequence | None, wcs_list: list[gwcs.wcs.WCS], - fiducial: numpy.ndarray, + fiducial: np.ndarray, crpix: Sequence | None = None, transform: astmodels.Model | None = None, ) -> gwcs.wcs.WCS: @@ -406,7 +405,7 @@ def _validate_wcs_list(wcs_list: list[gwcs.wcs.WCS]) -> bool: def compute_scale( wcs: gwcs.wcs.WCS, - fiducial: tuple | numpy.ndarray, + fiducial: tuple | np.ndarray, disp_axis: int | None = None, pscale_ratio: float | None = None, ) -> float: @@ -470,7 +469,7 @@ def compute_scale( def compute_fiducial(wcslist: list, - bounding_box: Sequence | None = None) -> numpy.ndarray: + bounding_box: Sequence | None = None) -> np.ndarray: """ Calculates the world coordinates of the fiducial point of a list of WCS objects. For a celestial footprint this is the center. For a spectral footprint, it is the @@ -751,7 +750,7 @@ def wcs_bbox_from_shape(shape: Sequence) -> tuple: def update_s_region_keyword(wcsinfo: dict | Wcsinfo, - footprint: numpy.ndarray) -> None: + footprint: np.ndarray) -> None: """Update the S_REGION keyword. Parameters @@ -827,7 +826,7 @@ def _get_backward_transform_func(wcs2): raise TypeError(msg) return backward_transform - def _reproject(x: float | numpy.ndarray, y: float | numpy.ndarray) -> tuple: + def _reproject(x: float | np.ndarray, y: float | np.ndarray) -> tuple: """ Reprojects the input coordinates from one WCS to another. From 76bda86dc9dffc83d3810bd58e6ae3dabc97e409 Mon Sep 17 00:00:00 2001 From: Brett Date: Fri, 19 Jul 2024 11:17:10 -0400 Subject: [PATCH 27/36] run mypy in ci --- .github/workflows/ci.yml | 1 + pyproject.toml | 7 +++++++ tox.ini | 10 +++++++++- 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6ee74b29..ffb56d3a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,6 +23,7 @@ jobs: with: envs: | - linux: check-style + - linux: check-types - linux: check-build test: uses: OpenAstronomy/github-actions-workflows/.github/workflows/tox.yml@v1 diff --git a/pyproject.toml b/pyproject.toml index 16861e07..300131ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -206,6 +206,13 @@ module = [ "astropy.*", "gwcs.*", "tweakwcs.*", + "stdatamodels.*", + "asdf.*", + "scipy.*", + # don't complain about the installed c parts of this library + "stcal.ramp_fitting.ols_cas22._fit", + "stcal.ramp_fitting.ols_cas22._jump", + "stcal.ramp_fitting.slope_fitter", ] ignore_missing_imports = true diff --git a/tox.ini b/tox.ini index 8dc0aa59..0c7b3cf8 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] envlist = - check-{style,build} + check-{style,build,types} test{,-warnings,-cov}-xdist test-numpy{120,121,122} test-{jwst,romancal}-xdist @@ -15,6 +15,14 @@ envlist = # tox -l -v # +[testenv:check-types] +description = check type hints, e.g. with mypy +deps = + mypy +commands = + mypy --install-types --non-interactive + mypy src/stcal --config-file pyproject.toml + [testenv:check-style] description = check code style, e.g. with ruff skip_install = true From 3768ea203f16a18be005594d5cf5c04d626f5bc9 Mon Sep 17 00:00:00 2001 From: Ned Molter Date: Fri, 19 Jul 2024 11:35:00 -0400 Subject: [PATCH 28/36] Update tox.ini Co-authored-by: Brett Graham --- tox.ini | 1 + 1 file changed, 1 insertion(+) diff --git a/tox.ini b/tox.ini index 0c7b3cf8..6aabdf24 100644 --- a/tox.ini +++ b/tox.ini @@ -19,6 +19,7 @@ envlist = description = check type hints, e.g. with mypy deps = mypy + types-requests commands = mypy --install-types --non-interactive mypy src/stcal --config-file pyproject.toml From 3a810e57d3da7436491c872f85055efc43915328 Mon Sep 17 00:00:00 2001 From: Ned Molter Date: Fri, 19 Jul 2024 11:36:38 -0400 Subject: [PATCH 29/36] Update tox.ini Co-authored-by: Brett Graham --- tox.ini | 1 - 1 file changed, 1 deletion(-) diff --git a/tox.ini b/tox.ini index 6aabdf24..3e7bbbc5 100644 --- a/tox.ini +++ b/tox.ini @@ -21,7 +21,6 @@ deps = mypy types-requests commands = - mypy --install-types --non-interactive mypy src/stcal --config-file pyproject.toml [testenv:check-style] From e82ac171cef154cbac0ed6f580918291a90b1275 Mon Sep 17 00:00:00 2001 From: Ned Molter Date: Mon, 22 Jul 2024 10:09:30 -0400 Subject: [PATCH 30/36] changelog bump to 1.8.0 --- CHANGES.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index b6fbe842..5203efd1 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,4 +1,4 @@ -1.7.4 (unreleased) +1.8.0 (unreleased) ================== General From b2cab4aaee76b6843c521ec10254ca3ee657fa26 Mon Sep 17 00:00:00 2001 From: Ned Molter Date: Tue, 23 Jul 2024 13:20:25 -0400 Subject: [PATCH 31/36] revert isinstance check to address jwst repo failures --- src/stcal/alignment/util.py | 15 +++++++++------ src/stcal/tweakreg/tweakreg.py | 2 +- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/stcal/alignment/util.py b/src/stcal/alignment/util.py index 4269e249..10905e4a 100644 --- a/src/stcal/alignment/util.py +++ b/src/stcal/alignment/util.py @@ -3,7 +3,7 @@ import functools import logging -from typing import TYPE_CHECKING, Protocol, runtime_checkable +from typing import TYPE_CHECKING, Protocol if TYPE_CHECKING: from collections.abc import Callable, Sequence @@ -34,13 +34,14 @@ ] -@runtime_checkable class Wcsinfo(Protocol): ra_ref: float dec_ref: float v3yangle: float vparity: int roll_ref: float + v2_ref: float + v3_ref: float s_region: str @@ -64,6 +65,8 @@ def wcsinfo_to_dict(wcsinfo: Wcsinfo) -> dict: "v3yangle": wcsinfo.v3yangle, "vparity": wcsinfo.vparity, "roll_ref": wcsinfo.roll_ref, + "v2_ref": wcsinfo.v2_ref, + "v3_ref": wcsinfo.v3_ref, "s_region": wcsinfo.s_region, } @@ -149,7 +152,7 @@ def _generate_tranform( transform : ~astropy.modeling.Model An :py:mod:`~astropy` model containing the transform between frames. """ - if isinstance(wcsinfo, Wcsinfo): + if not isinstance(wcsinfo, dict): wcsinfo = wcsinfo_to_dict(wcsinfo) if transform is None: sky_axes = wcs._get_axes_indices().tolist() # noqa: SLF001 @@ -648,7 +651,7 @@ def wcs_from_footprints( The WCS object corresponding to the combined input footprints. """ - if isinstance(ref_wcsinfo, Wcsinfo): + if not isinstance(ref_wcsinfo, dict): ref_wcsinfo = wcsinfo_to_dict(ref_wcsinfo) _validate_wcs_list(wcs_list) @@ -699,7 +702,7 @@ def update_s_region_imaging(wcs: gwcs.wcs.WCS, Whether or not to use the center of the pixel as reference for the coordinates, by default True """ - if isinstance(wcsinfo, Wcsinfo): + if not isinstance(wcsinfo, dict): wcsinfo = wcsinfo_to_dict(wcsinfo) bbox = wcs.bounding_box if shape is None and bbox is None: @@ -773,7 +776,7 @@ def update_s_region_keyword(wcsinfo: dict | Wcsinfo, # do not update s_region if there are NaNs. log.info("There are NaNs in s_region, S_REGION not updated.") return - if isinstance(wcsinfo, Wcsinfo): + if not isinstance(wcsinfo, dict): wcsinfo.s_region = s_region else: wcsinfo["s_region"] = s_region diff --git a/src/stcal/tweakreg/tweakreg.py b/src/stcal/tweakreg/tweakreg.py index e790cd9a..5efdcbe3 100644 --- a/src/stcal/tweakreg/tweakreg.py +++ b/src/stcal/tweakreg/tweakreg.py @@ -319,7 +319,7 @@ def construct_wcs_corrector(wcs: gwcs.WCS, refang : dict Dictionary containing WCSreference angles. """ - if isinstance(refang, Wcsinfo): + if not isinstance(refang, dict): refang = wcsinfo_to_dict(refang) catalog = filter_catalog_by_bounding_box( catalog, wcs.bounding_box) From fd5c2f4b147867e8c33034fdf1f0722b7062cf8a Mon Sep 17 00:00:00 2001 From: Ned Molter Date: Tue, 23 Jul 2024 17:56:21 -0400 Subject: [PATCH 32/36] reworking compute_s_region functions --- src/stcal/alignment/util.py | 91 +++++++++++++--------------------- src/stcal/tweakreg/tweakreg.py | 10 ++-- tests/test_alignment.py | 21 ++++---- 3 files changed, 50 insertions(+), 72 deletions(-) diff --git a/src/stcal/alignment/util.py b/src/stcal/alignment/util.py index 10905e4a..b2bc7b6b 100644 --- a/src/stcal/alignment/util.py +++ b/src/stcal/alignment/util.py @@ -3,7 +3,7 @@ import functools import logging -from typing import TYPE_CHECKING, Protocol +from typing import TYPE_CHECKING if TYPE_CHECKING: from collections.abc import Callable, Sequence @@ -25,50 +25,39 @@ "compute_scale", "compute_fiducial", "calc_rotation_matrix", - "update_s_region_imaging", - "update_s_region_keyword", + "compute_s_region_imaging", + "compute_s_region_keyword", "wcs_from_footprints", "reproject", - "Wcsinfo", - "wcsinfo_to_dict", ] -class Wcsinfo(Protocol): - ra_ref: float - dec_ref: float - v3yangle: float - vparity: int - roll_ref: float - v2_ref: float - v3_ref: float - s_region: str +WCSINFO_REQUIRED_KEYS = {"ra_ref", "dec_ref", "v3yangle", "vparity", + "roll_ref", "v2_ref", "v3_ref"} -def wcsinfo_to_dict(wcsinfo: Wcsinfo) -> dict: +def _validate_wcsinfo(wcsinfo: dict) -> None: """ - Convert a WCSInfo object to a dictionary. + Validate the wcsinfo dictionary. Parameters ---------- - wcsinfo : Wcsinfo - A WCSInfo object. + wcsinfo : dict + A dictionary containing the WCS FITS keywords and corresponding values. Returns ------- - dict - A dictionary containing the WCS FITS keywords and corresponding values. + bool or Exception + If wcsinfo is valid, returns True. Otherwise, it will raise an error. + + Raises + ------ + ValueError + Raised when wcsinfo does not contain the required keys. """ - return { - "ra_ref": wcsinfo.ra_ref, - "dec_ref": wcsinfo.dec_ref, - "v3yangle": wcsinfo.v3yangle, - "vparity": wcsinfo.vparity, - "roll_ref": wcsinfo.roll_ref, - "v2_ref": wcsinfo.v2_ref, - "v3_ref": wcsinfo.v3_ref, - "s_region": wcsinfo.s_region, - } + if not WCSINFO_REQUIRED_KEYS.issubset(wcsinfo.keys()): + msg = f"Expected 'wcsinfo' to contain the following keys: {WCSINFO_REQUIRED_KEYS}." + raise ValueError(msg) def _calculate_fiducial_from_spatial_footprint( @@ -104,7 +93,7 @@ def _calculate_fiducial_from_spatial_footprint( def _generate_tranform( wcs: gwcs.wcs.WCS, - wcsinfo: dict | Wcsinfo, + wcsinfo: dict, ref_fiducial: np.ndarray, pscale_ratio: float | None = None, pscale: float | None = None, @@ -152,8 +141,6 @@ def _generate_tranform( transform : ~astropy.modeling.Model An :py:mod:`~astropy` model containing the transform between frames. """ - if not isinstance(wcsinfo, dict): - wcsinfo = wcsinfo_to_dict(wcsinfo) if transform is None: sky_axes = wcs._get_axes_indices().tolist() # noqa: SLF001 v3yangle = np.deg2rad(wcsinfo["v3yangle"]) @@ -563,7 +550,7 @@ def calc_rotation_matrix(roll_ref: float, v3i_yangle: float, vparity: int = 1) - def wcs_from_footprints( wcs_list: list[gwcs.wcs.WCS], ref_wcs: gwcs.wcs.WCS, - ref_wcsinfo: dict | Wcsinfo, + ref_wcsinfo: dict, transform: astropy.modeling.models.Model | None = None, bounding_box: Sequence | None = None, pscale_ratio: float | None = None, @@ -651,8 +638,7 @@ def wcs_from_footprints( The WCS object corresponding to the combined input footprints. """ - if not isinstance(ref_wcsinfo, dict): - ref_wcsinfo = wcsinfo_to_dict(ref_wcsinfo) + _validate_wcsinfo(ref_wcsinfo) _validate_wcs_list(wcs_list) fiducial = _calculate_fiducial(wcs_list=wcs_list, bounding_box=bounding_box, crval=crval) @@ -679,10 +665,9 @@ def wcs_from_footprints( ) -def update_s_region_imaging(wcs: gwcs.wcs.WCS, - wcsinfo: dict | Wcsinfo, - shape: Sequence | None = None, - center: bool | None = None) -> dict: +def compute_s_region_imaging(wcs: gwcs.wcs.WCS, + shape: Sequence | None = None, + center: bool | None = None) -> str: """ Update the ``S_REGION`` keyword using the WCS footprint. @@ -691,9 +676,6 @@ def update_s_region_imaging(wcs: gwcs.wcs.WCS, wcs : ~gwcs.wcs.WCS The WCS object. - wcsinfo : dict - A dictionary containing the WCS FITS keywords and corresponding values. - shape : tuple, optional Shape of input model data array. Used to compute the bounding box if not provided in the WCS object, and required in that case. The default is None. @@ -701,9 +683,12 @@ def update_s_region_imaging(wcs: gwcs.wcs.WCS, center : bool, optional Whether or not to use the center of the pixel as reference for the coordinates, by default True + + Returns + ------- + s_region : str + String containing the S_REGION object. """ - if not isinstance(wcsinfo, dict): - wcsinfo = wcsinfo_to_dict(wcsinfo) bbox = wcs.bounding_box if shape is None and bbox is None: msg = "If wcs.bounding_box is not specified, shape must be provided." @@ -730,8 +715,7 @@ def update_s_region_imaging(wcs: gwcs.wcs.WCS, footprint[0][negative_ind] = 360 + footprint[0][negative_ind] footprint = footprint.T - update_s_region_keyword(wcsinfo, footprint) - return wcsinfo + return compute_s_region_keyword(footprint) def wcs_bbox_from_shape(shape: Sequence) -> tuple: @@ -752,15 +736,11 @@ def wcs_bbox_from_shape(shape: Sequence) -> tuple: return (-0.5, shape[-1] - 0.5), (-0.5, shape[-2] - 0.5) -def update_s_region_keyword(wcsinfo: dict | Wcsinfo, - footprint: np.ndarray) -> None: +def compute_s_region_keyword(footprint: np.ndarray) -> str: """Update the S_REGION keyword. Parameters ---------- - wcsinfo : dict - A dictionary containing the WCS FITS keywords and corresponding values. - footprint : A 4x2 numpy array containing the coordinates of the vertices of the footprint. @@ -775,12 +755,9 @@ def update_s_region_keyword(wcsinfo: dict | Wcsinfo, if "nan" in s_region: # do not update s_region if there are NaNs. log.info("There are NaNs in s_region, S_REGION not updated.") - return - if not isinstance(wcsinfo, dict): - wcsinfo.s_region = s_region - else: - wcsinfo["s_region"] = s_region + return "" log.info("Update S_REGION to %s", s_region) + return s_region def reproject(wcs1: gwcs.wcs.WCS, wcs2: gwcs.wcs.WCS) -> Callable: diff --git a/src/stcal/tweakreg/tweakreg.py b/src/stcal/tweakreg/tweakreg.py index 5efdcbe3..2e774f3f 100644 --- a/src/stcal/tweakreg/tweakreg.py +++ b/src/stcal/tweakreg/tweakreg.py @@ -17,7 +17,7 @@ from tweakwcs.imalign import align_wcs from tweakwcs.matchutils import XYXYMatch -from stcal.alignment import Wcsinfo, wcs_from_footprints, wcsinfo_to_dict +from stcal.alignment import wcs_from_footprints from .astrometric_utils import create_astrometric_catalog @@ -113,7 +113,7 @@ def relative_align(correctors: list, def absolute_align(correctors: list, abs_refcat: str, ref_wcs: gwcs.wcs.WCS, - ref_wcsinfo: dict | Wcsinfo, + ref_wcsinfo: dict, epoch: str | astropy.time.Time, save_abs_catalog: bool = False, abs_catalog_output_dir: str | None = None, @@ -213,7 +213,7 @@ def absolute_align(correctors: list, def _parse_refcat(abs_refcat: str | Path, correctors: list, wcs: gwcs.wcs.WCS, - wcsinfo: dict | Wcsinfo, + wcsinfo: dict, epoch: str | astropy.time.Time, save_abs_catalog: bool = False, output_dir: str | None = None) -> Table: @@ -304,7 +304,7 @@ def filter_catalog_by_bounding_box(catalog: Table, bounding_box: list[tuple]) -> def construct_wcs_corrector(wcs: gwcs.WCS, - refang: dict | Wcsinfo, + refang: dict, catalog: Table, group_id: str,) -> JWSTWCSCorrector: """ @@ -319,8 +319,6 @@ def construct_wcs_corrector(wcs: gwcs.WCS, refang : dict Dictionary containing WCSreference angles. """ - if not isinstance(refang, dict): - refang = wcsinfo_to_dict(refang) catalog = filter_catalog_by_bounding_box( catalog, wcs.bounding_box) diff --git a/tests/test_alignment.py b/tests/test_alignment.py index d7530bec..809cb7b5 100644 --- a/tests/test_alignment.py +++ b/tests/test_alignment.py @@ -10,13 +10,12 @@ from stcal.alignment import resample_utils from stcal.alignment.util import ( - Wcsinfo, _validate_wcs_list, compute_fiducial, compute_scale, reproject, - update_s_region_imaging, - update_s_region_keyword, + compute_s_region_imaging, + compute_s_region_keyword, wcs_bbox_from_shape, wcs_from_footprints, ) @@ -71,7 +70,10 @@ def _create_wcs_and_datamodel(fiducial_world, shape, pscale): ) -class WcsInfo(Wcsinfo): +class WcsInfo(): + """ + JWST-like wcsinfo object + """ def __init__(self, ra_ref, dec_ref, roll_ref, v2_ref, v3_ref, v3yangle): self.ra_ref = ra_ref self.dec_ref = dec_ref @@ -115,6 +117,7 @@ def __init__(self, ra_ref, dec_ref, roll_ref, v2_ref, v3_ref, v3yangle, wcs=None class DataModel: + """JWST-like datamodel object""" def __init__(self, ra_ref, dec_ref, roll_ref, v2_ref, v3_ref, v3yangle, wcs=None): self.meta = MetaData(ra_ref, dec_ref, roll_ref, v2_ref, v3_ref, v3yangle, wcs=wcs) @@ -176,7 +179,7 @@ def test_wcs_from_footprints(): dm_2 = _create_wcs_and_datamodel(fiducial_world, shape, pscale) wcs_2 = dm_2.meta.wcs - wcs = wcs_from_footprints([wcs_1, wcs_2], wcs_1, dm_1.meta.wcsinfo) + wcs = wcs_from_footprints([wcs_1, wcs_2], wcs_1, dm_1.meta.wcsinfo.instance) # check that all elements of footprint match the *vertices* of the new combined WCS assert all(np.isclose(wcs.footprint()[0], wcs(0, 0))) @@ -325,11 +328,11 @@ def test_calc_pixmap_shape(shape, pixmap_expected_shape): ), ], ) -def test_update_s_region_keyword(model, footprint, expected_s_region, expected_log_info, caplog): +def test_compute_s_region_keyword(model, footprint, expected_s_region, expected_log_info, caplog): """ Test that S_REGION keyword is being properly populated with the coordinate values. """ - update_s_region_keyword(model.meta.wcsinfo, footprint) + model.meta.wcsinfo.s_region = compute_s_region_keyword(footprint) assert model.meta.wcsinfo.s_region == expected_s_region assert expected_log_info in caplog.text @@ -365,7 +368,7 @@ def test_wcs_bbox_from_shape(shape, expected_bbox): ), ], ) -def test_update_s_region_imaging(model, bounding_box, data): +def test_compute_s_region_imaging(model, bounding_box, data): """ Test that S_REGION keyword is being properly updated with the coordinates corresponding to the footprint (same as WCS(bounding box)). @@ -379,7 +382,7 @@ def test_update_s_region_imaging(model, bounding_box, data): *model.meta.wcs(2.5, -0.5), ] shape = data.shape if data is not None else None - update_s_region_imaging(model.meta.wcs, model.meta.wcsinfo, shape=shape, center=False) + model.meta.wcsinfo.s_region = compute_s_region_imaging(model.meta.wcs, shape=shape, center=False) updated_s_region_coords = [float(x) for x in model.meta.wcsinfo.s_region.split(" ")[3:]] assert all(np.isclose(x, y) for x, y in zip(updated_s_region_coords, expected_s_region_coords, From 9b55e08c46b4ecd2cde3a7363270387bab1357bb Mon Sep 17 00:00:00 2001 From: Ned Molter Date: Tue, 23 Jul 2024 19:06:15 -0400 Subject: [PATCH 33/36] remove check of required keywords, should be handled by default error handler --- src/stcal/alignment/util.py | 31 +------------------------------ 1 file changed, 1 insertion(+), 30 deletions(-) diff --git a/src/stcal/alignment/util.py b/src/stcal/alignment/util.py index b2bc7b6b..da808d66 100644 --- a/src/stcal/alignment/util.py +++ b/src/stcal/alignment/util.py @@ -32,34 +32,6 @@ ] -WCSINFO_REQUIRED_KEYS = {"ra_ref", "dec_ref", "v3yangle", "vparity", - "roll_ref", "v2_ref", "v3_ref"} - - -def _validate_wcsinfo(wcsinfo: dict) -> None: - """ - Validate the wcsinfo dictionary. - - Parameters - ---------- - wcsinfo : dict - A dictionary containing the WCS FITS keywords and corresponding values. - - Returns - ------- - bool or Exception - If wcsinfo is valid, returns True. Otherwise, it will raise an error. - - Raises - ------ - ValueError - Raised when wcsinfo does not contain the required keys. - """ - if not WCSINFO_REQUIRED_KEYS.issubset(wcsinfo.keys()): - msg = f"Expected 'wcsinfo' to contain the following keys: {WCSINFO_REQUIRED_KEYS}." - raise ValueError(msg) - - def _calculate_fiducial_from_spatial_footprint( spatial_footprint: np.ndarray, ) -> tuple: @@ -638,7 +610,6 @@ def wcs_from_footprints( The WCS object corresponding to the combined input footprints. """ - _validate_wcsinfo(ref_wcsinfo) _validate_wcs_list(wcs_list) fiducial = _calculate_fiducial(wcs_list=wcs_list, bounding_box=bounding_box, crval=crval) @@ -750,7 +721,7 @@ def compute_s_region_keyword(footprint: np.ndarray) -> str: String containing the S_REGION object. """ s_region = "POLYGON ICRS {:.9f} {:.9f} {:.9f} {:.9f} {:.9f} {:.9f} {:.9f} {:.9f}".format( - *footprint.flatten() + *footprint.flatten() ) if "nan" in s_region: # do not update s_region if there are NaNs. From 9cb89d9f48bd576df930424ab541cab6e549e6a1 Mon Sep 17 00:00:00 2001 From: Ned Molter Date: Wed, 24 Jul 2024 12:28:41 -0400 Subject: [PATCH 34/36] reverting inadvertent change to s_region_imaging default value --- src/stcal/alignment/util.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/stcal/alignment/util.py b/src/stcal/alignment/util.py index da808d66..55c6af46 100644 --- a/src/stcal/alignment/util.py +++ b/src/stcal/alignment/util.py @@ -638,7 +638,7 @@ def wcs_from_footprints( def compute_s_region_imaging(wcs: gwcs.wcs.WCS, shape: Sequence | None = None, - center: bool | None = None) -> str: + center: bool = True) -> str: """ Update the ``S_REGION`` keyword using the WCS footprint. @@ -689,6 +689,25 @@ def compute_s_region_imaging(wcs: gwcs.wcs.WCS, return compute_s_region_keyword(footprint) +# def compute_s_region_imaging(wcs: gwcs.wcs.WCS, shape: Sequence) -> str: + +# bbox = wcs.bounding_box +# if bbox is None: +# bbox = wcs_bbox_from_shape(shape) +# wcs.bounding_box = bbox + +# footprint = wcs.footprint(bbox, center=True, axis_type="spatial").T +# footprint = footprint[:2, :] + +# # Make sure RA values are all positive +# negative_ind = footprint[0] < 0 +# if negative_ind.any(): +# footprint[0][negative_ind] = 360 + footprint[0][negative_ind] + +# footprint = footprint.T +# return compute_s_region_keyword(footprint) + + def wcs_bbox_from_shape(shape: Sequence) -> tuple: """Create a bounding box from the shape of the data. From f6320dc1261f1882e741dd5e6af53787686cac56 Mon Sep 17 00:00:00 2001 From: Ned Molter Date: Thu, 25 Jul 2024 10:01:51 -0400 Subject: [PATCH 35/36] add wcsinfo docs, make compute_s_region return None if NaNs --- docs/stcal/alignment/description.rst | 18 ++++++++++++++++++ src/stcal/alignment/util.py | 25 +++---------------------- src/stcal/tweakreg/astrometric_utils.py | 7 ------- tests/test_alignment.py | 8 ++++---- 4 files changed, 25 insertions(+), 33 deletions(-) diff --git a/docs/stcal/alignment/description.rst b/docs/stcal/alignment/description.rst index f08b86e5..312678f7 100644 --- a/docs/stcal/alignment/description.rst +++ b/docs/stcal/alignment/description.rst @@ -2,3 +2,21 @@ Description ============ This sub-package contains all the modules common to all missions. + +WCS Info Dictionary +------------------- +Many of the functions in this submodule require a ``wcsinfo`` dictionary. +This dictionary contains information about the spacecraft pointing, and +requires at least the following keys: + +- ``'ra_ref'``: The right ascension at the reference point in degrees. +- ``'dec_ref'``: The declination at the reference point in degrees. +- ``'v2_ref'``: The V2 reference point in arcseconds, + with ``'v3_ref'`` maps to ``'ra_ref'`` and ``'dec_ref'``. +- ``'v3_ref'``: The V3 reference point in arcseconds, + with ``'v2_ref'`` maps to ``'ra_ref'`` and ``'dec_ref'``. +- ``'roll_ref'``: Local roll angle associated with each aperture in degrees. +- ``'v3yangle'``: The angle between V3 and North in degrees. +- ``'vparity'``: The "V-parity" of the observation, which is 1 if the + V3 axis is parallel to the detector Y-axis, and -1 if the V3 axis is + parallel to the detector X-axis. diff --git a/src/stcal/alignment/util.py b/src/stcal/alignment/util.py index 55c6af46..49ef0c20 100644 --- a/src/stcal/alignment/util.py +++ b/src/stcal/alignment/util.py @@ -638,7 +638,7 @@ def wcs_from_footprints( def compute_s_region_imaging(wcs: gwcs.wcs.WCS, shape: Sequence | None = None, - center: bool = True) -> str: + center: bool = True) -> str | None: """ Update the ``S_REGION`` keyword using the WCS footprint. @@ -689,25 +689,6 @@ def compute_s_region_imaging(wcs: gwcs.wcs.WCS, return compute_s_region_keyword(footprint) -# def compute_s_region_imaging(wcs: gwcs.wcs.WCS, shape: Sequence) -> str: - -# bbox = wcs.bounding_box -# if bbox is None: -# bbox = wcs_bbox_from_shape(shape) -# wcs.bounding_box = bbox - -# footprint = wcs.footprint(bbox, center=True, axis_type="spatial").T -# footprint = footprint[:2, :] - -# # Make sure RA values are all positive -# negative_ind = footprint[0] < 0 -# if negative_ind.any(): -# footprint[0][negative_ind] = 360 + footprint[0][negative_ind] - -# footprint = footprint.T -# return compute_s_region_keyword(footprint) - - def wcs_bbox_from_shape(shape: Sequence) -> tuple: """Create a bounding box from the shape of the data. @@ -726,7 +707,7 @@ def wcs_bbox_from_shape(shape: Sequence) -> tuple: return (-0.5, shape[-1] - 0.5), (-0.5, shape[-2] - 0.5) -def compute_s_region_keyword(footprint: np.ndarray) -> str: +def compute_s_region_keyword(footprint: np.ndarray) -> str | None: """Update the S_REGION keyword. Parameters @@ -745,7 +726,7 @@ def compute_s_region_keyword(footprint: np.ndarray) -> str: if "nan" in s_region: # do not update s_region if there are NaNs. log.info("There are NaNs in s_region, S_REGION not updated.") - return "" + return None log.info("Update S_REGION to %s", s_region) return s_region diff --git a/src/stcal/tweakreg/astrometric_utils.py b/src/stcal/tweakreg/astrometric_utils.py index a5c38f8e..2550d0a2 100644 --- a/src/stcal/tweakreg/astrometric_utils.py +++ b/src/stcal/tweakreg/astrometric_utils.py @@ -137,13 +137,6 @@ def create_astrometric_catalog( return ref_table -""" - -Utility functions for creating an astrometric reference catalog. - -""" - - def compute_radius(wcs): """Compute the radius from the center to the furthest edge of the WCS.""" fiducial = compute_fiducial([wcs], wcs.bounding_box) diff --git a/tests/test_alignment.py b/tests/test_alignment.py index 809cb7b5..ecf22303 100644 --- a/tests/test_alignment.py +++ b/tests/test_alignment.py @@ -12,10 +12,10 @@ from stcal.alignment.util import ( _validate_wcs_list, compute_fiducial, - compute_scale, - reproject, compute_s_region_imaging, compute_s_region_keyword, + compute_scale, + reproject, wcs_bbox_from_shape, wcs_from_footprints, ) @@ -70,7 +70,7 @@ def _create_wcs_and_datamodel(fiducial_world, shape, pscale): ) -class WcsInfo(): +class WcsInfo: """ JWST-like wcsinfo object """ @@ -323,7 +323,7 @@ def test_calc_pixmap_shape(shape, pixmap_expected_shape): ( _create_wcs_and_datamodel((10, 0), (3, 3), (0.000028, 0.000028)), np.array([[1.0, 2.0], [3.0, np.nan], [5.0, 6.0], [7.0, 8.0]]), - "", + None, "There are NaNs in s_region, S_REGION not updated.", ), ], From 37ea9755e827078e32a699fec801bc1bff3ac6d7 Mon Sep 17 00:00:00 2001 From: Ned Molter Date: Thu, 25 Jul 2024 13:21:42 -0400 Subject: [PATCH 36/36] Update pyproject.toml Co-authored-by: Nadia Dencheva --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 300131ea..7a16bc84 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,7 @@ dependencies = [ "opencv-python-headless >=4.6.0.66", "asdf >=2.15.0", "gwcs >= 0.18.1", - "tweakwcs >=0.8.6", + "tweakwcs >=0.8.8", "requests >=2.22", ] dynamic = [