Optimizing Pointing#

In this notebook, we will explore the jwpoint functionality that can help us find the optimal pointing when planning an upcoming JWST observations.

The goal here, for a given instrument, detector and subaperture, is to select the optimal pointing position that minimizes the amount of bad pixels and respects a series of other criteria.

This is mostly done via do_region_search(). We will explain its arguments in the following sections.

Downloading a reference observation#

Before we start optimizing the pointing, we need to download a reference observation. We need an observation with the same instrument, detector, subaperture and filter as our planned observation. Ideally, there should be at least once easy to find and isolated point source in the observation so we can use it as a reference PSF.

Here we use an data from the 1205 program which observed high-z quasars with NIRCam We use an observation from the long-wavelength (LW) channel in the F430M filter and the SUB400P subarray.

from pathlib import Path
from astroquery.mast import Observations

data_dir = Path("data")
program_dir = "01205"

filename = Path("jw01205002001_02104_00002_nrcblong_cal.fits")
uri = f"mast:JWST/product/{filename}"

filepath = data_dir / program_dir / filename
filepath.parent.mkdir(exist_ok=True, parents=True)
_ = Observations.download_file(uri, local_path=filepath)
INFO: Found cached file data/01205/jw01205002001_02104_00002_nrcblong_cal.fits with expected size 4613760. [astroquery.query]
import numpy as np
from astropy.io import fits

with fits.open(filepath) as hdul:
    hdr = hdul[0].header
    img = hdul[1].data
dq_mask = np.isnan(img)
from matplotlib import rcParams
import matplotlib.pyplot as plt

rcParams["image.origin"] = "lower"

plt.imshow(img, norm="symlog")
plt.xlabel("X [pixel]")
plt.ylabel("Y [pixel]")
plt.show()
../_images/b1659ec45a601401f1c95ac9ef843f46b34baa724b3ef7b2be6808b0cfab8042.png

It is pretty clear which is our point source target here. Let us extract it so it can serve as a reference PSF.

Extracting the reference PSF#

First, we can use get_pointing_position() to get the position of our target.

from jwpoint import pointing
x_target, y_target = (int(round(off)) for off in pointing.get_pointing_position(hdr["XOFFSET"], hdr["YOFFSET"], filepath))
from matplotlib import rcParams
import matplotlib.pyplot as plt

rcParams["image.origin"] = "lower"

plt.imshow(img, norm="symlog")
plt.plot(x_target, y_target, "r*")
plt.xlabel("X [pixel]")
plt.ylabel("Y [pixel]")
plt.show()
../_images/e3693762aebe2787745e321438141ba2105a68ed90c556b24b634cbeec93ad23.png
from jwpoint.plot import plot_dithers, zoom_plot

zoom_plot(img, x_target, y_target, size=64, show_mask=False)
plt.xlabel("X [pixel]")
plt.ylabel("Y [pixel]")
plt.show()
../_images/ef32e41685b3f1795f4d75253b2b445297cb270bcdb591b79f9278d9a5794aa9.png

This looks good! We can crop the image. The PSF must have the same shape as the region size we will be optimizing later. Here we set this to 70 pixels, which is a reasonable value if what we care about is the close surroundings of the source.

crop_size = 70
hs = crop_size // 2
psf_with_badpix = img[y_target-hs:y_target+hs, x_target-hs:x_target+hs]
plt.imshow(psf_with_badpix, norm="symlog")
plt.xlabel("X [pixel]")
plt.ylabel("Y [pixel]")
plt.show()
../_images/06acc6d09b12b5e8b6801298e16631f909d29e1648b448333e62589ce22c760f.png

This cropped PSF has bad pixels set to NaN. To avoid propagating NaNs throughout our calculations, we can replace them with a median filter.

import jwpoint.utils as ut

psf = ut.filter_nans(psf_with_badpix)
plt.imshow(psf, norm="symlog")
plt.xlabel("X [pixel]")
plt.ylabel("Y [pixel]")
plt.show()
../_images/03c6fe228f725aee38d13fb304d446567e626230048092bba77d2fddd1843cfe.png

Searching for multiple regions#

If we wanted to get a few options, we could simply increase n_top.

x_few, y_few = do_region_search(
    dq_mask, img, crop_size, psf=psf, n_top=5
)
x_few, y_few
../_images/6d6974961f8a2087fad94357d4629406e91e94ab0aa01e3a986555f81167d6b1.png ../_images/d44bdf64226a2686ac911b05e5cb47487318ba91427bf15ca6c7a26b88744120.png
(array([ 81, 244, 310, 164, 236]), array([207, 260, 104, 249, 127]))

The x and Y positions are each returned in a NumPy array.

Weighting bad pixels with a PSF#

The searches above simply summed the number of bad pixels everywhere in the image uniformly. While this can work, it may be desirable to penalize bad pixels near the core of the PSF more than on the edges. This can be done with the kernel="weighted" argument:

x_weighted, y_weighted = do_region_search(
    dq_mask, img, crop_size, psf=psf, n_top=1, kernel="weighted"
)
../_images/70be5898c9807d319790a384b4e724f60f272efe776dc21fc429c35218b19e7b.png ../_images/c6d34556935f728b9a3ab871f13966ccba0f5570689b9ea329530d8e3d63a963.png

As we can see, in this case, it barely changes the result, but it does change how pixels are weighted (we see PPSFs instead of squares).

Rejecting bad pixels at the core of the PSF#

Sometimes, the weighting from the previous section may not be enough. There are cases where any region with bad pixels in the core of the PSF should be rejected. This can be done with the forbidden_size argument, which defines the size of the PSF core where no bad pixels are accepted.

x_forbid, y_forbid = do_region_search(
    dq_mask, img, crop_size, psf=psf, n_top=1, forbidden_size=25,
)
../_images/45c838b459ecc869571dd1919af22dd8e63e7152f782473e81b678b859fcf2e7.png ../_images/de1336a193483f3245b905a7bffe327b1f4b6bbfefc0cd8d532aeafe12a60604.png

As we can see, this changes the results quite a bit: many pixels are now rejected (NaN in the middle panel) and the pointing goes from middle left to bottom right.

However, be careful not to push this option too far: you will reach a regime where no region satisfies the requirement.

x_forbid, y_forbid = do_region_search(
    dq_mask,
    img,
    crop_size,
    psf=psf,
    n_top=1,
    forbidden_size=40,
)
WARNING: No optimal region was was found. Try relaxing the constraints.

Requiring a minimal edge distance#

If we need our target to be centered in the subarray, or if we don’t want to take too many risks by putting it near the edge, we can request a minimum edge distance for the optimal region(s). By default, the search window (region size of crop_size = 70 here) is used as the minimum edge distance.

x_edge, y_edge = do_region_search(
    dq_mask,
    img,
    crop_size,
    psf=psf,
    n_top=1,
    min_edge_distance=150,
)
../_images/37fa7f15f31886f03aa70fd1104f3cbd86c4ba5d39d2ab2fa3ddf2ebb2a12341.png ../_images/c21403631ee673dcb3815c375e8504544cc5ffc357b5fd0abed371ab208e1f50.png

Accounting for the subarray in the short-wavelength channel#

In all the searches above, we considered only the long-wavelength (LW) channel with the F430M filter. However, NIRCam also observed in the short-wavelength (SW) channel simultaneously.

Naive LW transformed to SW#

The simplest thing we can do is just take our optimal pointing for the LW channel and see where it falls in SW. Let us first extract the LW filter and subarray for later use.

filt = hdr["FILTER"]
subarray = hdr["SUBARRAY"]
print(f"Filter: {filt}")
print(f"Subarray: {subarray}")
Filter: F430M
Subarray: SUB400P

We then infer the SW detector from the initial “naive” optimal position and download the file, all with the download_sw_file() function which is discussed in more detail in the Understanding JWST Pointing notebook.

filepath_sw = ut.download_sw_file(filepath, x_naive, y_naive)
INFO: Found cached file data/01205/jw01205002001_02104_00002_nrcb1_cal.fits with expected size 4610880. [astroquery.query]
with fits.open(filepath_sw) as hdul_sw:
    hdr_sw = hdul_sw[0].header
    img_sw = hdul_sw[1].data

filt_sw = hdr_sw["FILTER"]
x_naive_sw, y_naive_sw = pointing.long_to_short(x_naive, y_naive, filepath, filepath_sw)
fig, axs = plt.subplots(1, 2, figsize=(10, 5))
ax_lw, ax_sw = axs
ax_lw.imshow(img, norm="symlog")
ax_lw.plot(x_naive, y_naive, "r*", label="naive position")
ax_lw.set_xlabel("X [pixel]")
ax_lw.set_ylabel("Y [pixel]")
ax_lw.set_title(f"LW image in filter {filt}")

ax_sw.imshow(img_sw, norm="symlog")
ax_sw.plot(x_naive_sw, y_naive_sw, "r*")
ax_sw.set_xlabel("X [pixel]")
ax_sw.set_ylabel("Y [pixel]")
ax_sw.set_title(f"SW Image in filter {filt_sw}")
fig.legend()
plt.show()
../_images/7b81d4fdd73864eb3ecc97e637c34ba88149291a24b7d58e19f7093ea6c90e7a.png

Oups! As we can see above, the pointing falls outside the subarray in the SW filter. This can happen because some subarrays (such as SUB400P used here) have different FOVs in the LW and SW channels. If we don’t care about the SW data, this is not a big deal, but if we do we must make sure our optimal position is usable in both channels.

Using the subarray option#

Currently, jwpoint does not support joint optimization in the LW and SW filters since most of the science cases we used it for had a strong emphasis on LW. However, it should not be too hard to add, so feel free to open an issue or a pull request on GitHub!

For now, what we do have is a subarray argument that will restrict the search to regions that are visible in both channels for a given subarray, but the optimization takes place entirely in the provided LW image.

x_naive_sub, y_naive_sub = do_region_search(
    dq_mask, img, crop_size, psf=psf, n_top=1, subarray=subarray,
)
../_images/8addd690d766e7f5953e64a98bb6b479f99f3e9723e4299ebc43bf0314cdf67c.png ../_images/968db5a5abe42aade413aab9fd199084869448439400cb006ce08f2a0de77d5c.png

Again, we can propagate this to the SW filter and see where it falls on both detectors.

x_naive_sub_sw, y_naive_sub_sw = pointing.long_to_short(x_naive_sub, y_naive_sub, filepath, filepath_sw)

fig, axs = plt.subplots(1, 2, figsize=(10, 5))
ax_lw, ax_sw = axs
ax_lw.imshow(img, norm="symlog")
ax_lw.plot(x_naive_sub, y_naive_sub, "r*", label="Subarray-constrained position")
ax_lw.set_xlabel("X [pixel]")
ax_lw.set_ylabel("Y [pixel]")
ax_lw.set_title(f"LW image in filter {filt}")

ax_sw.imshow(img_sw, norm="symlog")
ax_sw.plot(x_naive_sub_sw, y_naive_sub_sw, "r*")
ax_sw.set_xlabel("X [pixel]")
ax_sw.set_ylabel("Y [pixel]")
ax_sw.set_title(f"SW Image in filter {filt_sw}")

fig.legend()
plt.show()
../_images/49072111311f4a21c534626abd7edeb08271e94ff8a54905488b2304cc470241.png

Looks like the contraint worked and the optimal position found is now within both detectors.

Optimizing pointing for multiple dithers#

In Understanding Pointing, we discuss how to calculate the pointing for multiple dithers. Similarly, we can optimize the pointing by jointly optimizing the number of bad pixels across dithers instead of on a region.

This is done by specifying the joint_offsets argument. It needs to be a list of tuples, each with the x and y coordinates of the dithers, or a mapping to x and y offsets in pixels.

Let us say we wanted to apply the INTRAMODULEBOX pattern. We first need to fetch the dither offsets. By passing a detector argument, the offsets are converted from arcsec to pixels.

from jwpoint.dithers import get_dither_info

dither_pattern = "INTRAMODULEBOX"
n_dithers = 5
detector = hdr["DETECTOR"]

Then we can pass the offsets so that they are taken into account for the region search. Note that the plots currently only show the reference pointing position and do not show the dithers, the dithers are however considered when counting bad pixels.

x_dithers, y_dithers = do_region_search(
    dq_mask,
    img,
    crop_size,
    psf=psf,
    n_top=1,
    dither_pattern=dither_pattern,
    n_dithers=n_dithers,
    detector=detector
)
../_images/544c64f34ddd5456e5f8a25a50bb1dbc48a8ac98472d2b5719a5d70eebcdfff6.png ../_images/72b3298a2447d93e2cdaae6b55cc0bb36973850fb93d3328ee0cdd09e2ff07d2.png

Now, instead of being 1D with shape (n_top,), the arrays will have shape (n_top, n_dithers).

Finding multiple pointings for multiple dithers#

We can also do the multi-dither optimization while also optimizing for the n_top best regions while accounting for dithers

x_dithers_multi, y_dithers_multi = do_region_search(
    dq_mask,
    img,
    crop_size,
    psf=psf,
    n_top=2,
    dither_pattern=dither_pattern,
    n_dithers=n_dithers,
    detector=detector
)
../_images/a35eaa8b6a4e790e70cf2f378aa978f75c98caedf132ab8782056a51566f0f3e.png ../_images/72b3298a2447d93e2cdaae6b55cc0bb36973850fb93d3328ee0cdd09e2ff07d2.png ../_images/4ae5f3ebef812fb89639df07265eab6d1e13c1c474e54e9c5dfa054c629edb24.png