From b653281ddde488d3d2bbb12fbfbb88b5cefb522c Mon Sep 17 00:00:00 2001 From: Andreas Faisst Date: Tue, 10 Mar 2026 16:53:26 -0700 Subject: [PATCH 01/14] new branch for psf fix --- tutorials/spherex/spherex_psf.md | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/tutorials/spherex/spherex_psf.md b/tutorials/spherex/spherex_psf.md index 0a78ccc9..55476401 100644 --- a/tutorials/spherex/spherex_psf.md +++ b/tutorials/spherex/spherex_psf.md @@ -1,20 +1,20 @@ --- +authors: +- name: Vandana Desai +- name: Jessica Krick +- name: Andreas Faisst +- name: "Brigitta Sip\u0151cz" +- name: Troy Raen jupytext: text_representation: extension: .md format_name: myst format_version: 0.13 - jupytext_version: 1.17.3 + jupytext_version: 1.18.1 kernelspec: display_name: Python 3 (ipykernel) language: python name: python3 -authors: - - name: Vandana Desai - - name: Jessica Krick - - name: Andreas Faisst - - name: Brigitta Sipőcz - - name: Troy Raen --- # Understanding and Extracting the PSF Extension in a SPHEREx Cutout @@ -317,8 +317,12 @@ To use this PSF for forward modeling or fitting, you must: ## About this notebook -**Updated:** 5 March 2026 +**Updated:** 10 March 2026 **Contact:** Contact [IRSA Helpdesk](https://irsa.ipac.caltech.edu/docs/help_desk.html) with questions or problems. **Runtime:** Approximately 30 seconds. + +```{code-cell} ipython3 + +``` From fb6f95e3e0a1f62c0f46d4799058ad17c939b998 Mon Sep 17 00:00:00 2001 From: Andreas Faisst Date: Tue, 10 Mar 2026 18:00:00 -0700 Subject: [PATCH 02/14] first version of PSF header fix --- tutorials/spherex/spherex_psf.md | 292 ++++++++++++++++++++++++++++++- 1 file changed, 288 insertions(+), 4 deletions(-) diff --git a/tutorials/spherex/spherex_psf.md b/tutorials/spherex/spherex_psf.md index 55476401..82b3de4a 100644 --- a/tutorials/spherex/spherex_psf.md +++ b/tutorials/spherex/spherex_psf.md @@ -21,10 +21,20 @@ kernelspec: +++ +```{warning} +In the SPHEREx spectral image versions prior to XXXX, there was a missmatch between the spatial layout of the PSF zones and the the indexing of the PSF zones in the header. This has now been fixed in versions post XXXX. +However, users using the old versions will need to implement and extra step (described below in Section 5) to update the image header. + +For more information, see the following webpage: [PSF Erratum](WEBPAGE) +``` + ++++ + ## 1. Learning Goals * Determine how pixels in a SPHEREx cutout map to the pixels in the parent SPHEREx spectral image. * Understand the structure of the PSF extension in a SPHEREx cutout (which is the same as the PSF extension in the parent spectral image) +* Learn how to tell which version of the SPHEREx spectral image you are looking at, and how to interpret this information to obtain the correct PSF extension for the SPHEREx spectral images. * Learn which plane in a SPHEREx cutout PSF extension cube most accurately describes the coordinates you are interested in. +++ @@ -59,6 +69,7 @@ import http.client import re import time import urllib.error +import copy import astropy.units as u import matplotlib.pyplot as plt @@ -120,10 +131,10 @@ print("Time to do TAP query: {:2.2f} seconds.".format(time.time() - t1)) print("Number of images found: {}".format(len(results))) ``` -:::{note} +```{note} SPHEREx data are also available via SIA which can provide a simpler interface for many queries, as demonstrated in {ref}`spherex-intro`. An advantage of the method shown above is that it provides access to data immediately after ingestion (which occurs weekly) and is not subject to the same ~1 day delay as SIA. -::: +``` For this example, we focus on the first one of the retrieved SPHEREx spectral images. @@ -148,6 +159,7 @@ for attempt in range(max_retries): try: # Read the data. with fits.open(spectral_image_url) as hdul: + image_hdul = copy.deepcopy(hdul) cutout_header = hdul['IMAGE'].header psf_header = hdul['PSF'].header cutout = hdul['IMAGE'].data @@ -183,10 +195,282 @@ This means that the actual size of the PSFs is about 10x10 SPHEREx pixels, which +++ -Let's look at a small part of the PSF header to understand its format: +Let's look at a small part of the PSF header to understand its format. + +```{code-cell} ipython3 +psf_header[0:30] +``` + +We confirm that the oversampling factor (`OVERSAMP`) is 10. +The PSFs are distributed in an even grid with 11x11 zones. +Each of the 121 PSFs is responsible for one of these zones. +The PSF header therefore includes the center position of these zones as well as the width of the zones. +These center coordinate are specified with `XCTR_i` and `YCTR_i`, respectively, where i = 1...121. +The widths are specified with `XWID_i` and `YWID_i`, respectively, where again i = 1...121. +The zones have approximately equal widths and are arranged in an even grid. +The size of the zones is sufficient to capture well the changes of the PSF size and structure with wavelength and spatial coordinates. + +The goal of this tutorial now is to find the PSF corresponding to our input coordinates of interest. + ++++ + +```{warning} +In the SPHEREx spectral image versions prior to XXXX, there was a missmatch between the spatial layout of the PSF zones and the the indexing of the PSF zones in the header. This has now been fixed in versions post XXXX. + +For more information, see the following webpage: [PSF Erratum](WEBPAGE) + +**Users using the old versions will need to implement and extra step (described below) to update the image header.** +``` + +Let's first check if a header update is necessary. We can do that by checking the `VERSION` keyword in the header. + +```{code-cell} ipython3 +image_hdul['PRIMARY'].header["VERSION"] +``` + +If the version of the SPHEREx spectral image is less than `6.4`, we will have to update the header. This is explained in Section 5.1. If the version is later than `6.4`, the header is already updated and the PSF issue is fixed. In this case, proceed to Section 6 directly. + ++++ + +### 5.1 Updating Old SPHEREx Spectral Image Data (`VERSIONS` < XXX) + ++++ + +The function that can be used to update the header is shown below. The function +* first checks if a header update is necessary +* changes the PSF zone indexing and +* changes the version of the header such that it is consistent with the new released images + +Note that this function an work as standalone function to process many images. + +```{code-cell} ipython3 +def update_psf_header(old_hdul): + """ + Fix a old PSF FITS file header by rewriting only the per-plane header metadata + so that plane k corresponds to x-fast ordering: + k0 = iy * bins_x + ix + + The cube data are left untouched. + + Parameters + ---------- + old_hdul : astropy hdul + Old SPHEREx Spectral Image HDUL + + Return + ---------- + new_hdul : astropy hdul + New SPHEREx Spectral Image HDUL with updated PSF zone data in header and updated version number + + """ + + ## Check if old version + this_version = float( old_hdul['PRIMARY'].header["VERSION"] ) + if this_version <= 6.4: + print(f"Old version detected ({this_version}) -> Update header.") + elif this_version > 6.4: + print(f"New version detected ({this_version}) -> Do not update header.") + return(old_hdul) + + ## Define some auxillary functions ------- + def parse_ixiy_from_comment(comment): + _zone_pat = re.compile(r"\((\d+)\s*,\s*(\d+)\)") + m = _zone_pat.search(str(comment)) + if not m: + raise ValueError(f"Could not parse zone indices from comment: {comment!r}") + return int(m.group(1)), int(m.group(2)) + + def infer_grid_shape_from_header_comments(hdr, nzone): + max_ix = -1 + max_iy = -1 + + for k1 in range(1, nzone + 1): + key = f"XCTR_{k1}" + if key not in hdr: + raise KeyError(f"Missing required key: {key}") + ix, iy = parse_ixiy_from_comment(hdr.comments[key]) + max_ix = max(max_ix, ix) + max_iy = max(max_iy, iy) + + bins_x = max_ix + 1 + bins_y = max_iy + 1 + + if bins_x * bins_y != nzone: + raise ValueError( + f"Inconsistent grid inferred from comments: " + f"bins_x={bins_x}, bins_y={bins_y}, nzone={nzone}" + ) + + return bins_x, bins_y + + def collect_axis_values_by_zone(hdr, nzone): + """ + Read the old header and collect unique x/y centers and widths by zone index + labels found in the comments. + + This uses the old header only to recover the per-axis values for each ix, iy. + It does NOT use the old plane ordering as truth. + """ + x_center_by_ix = {} + y_center_by_iy = {} + x_width_by_ix = {} + y_width_by_iy = {} + + for k1 in range(1, nzone + 1): + ix, iy = parse_ixiy_from_comment(hdr.comments[f"XCTR_{k1}"]) + + xck = f"XCTR_{k1}" + yck = f"YCTR_{k1}" + xwk = f"XWID_{k1}" + ywk = f"YWID_{k1}" + + if xck in hdr: + val = hdr[xck] + if ix in x_center_by_ix and not np.isclose(x_center_by_ix[ix], val): + raise ValueError( + f"Inconsistent XCTR for ix={ix}: " + f"{x_center_by_ix[ix]} vs {val}" + ) + x_center_by_ix[ix] = val + + if yck in hdr: + val = hdr[yck] + if iy in y_center_by_iy and not np.isclose(y_center_by_iy[iy], val): + raise ValueError( + f"Inconsistent YCTR for iy={iy}: " + f"{y_center_by_iy[iy]} vs {val}" + ) + y_center_by_iy[iy] = val + + if xwk in hdr: + val = hdr[xwk] + if ix in x_width_by_ix and not np.isclose(x_width_by_ix[ix], val): + raise ValueError( + f"Inconsistent XWID for ix={ix}: " + f"{x_width_by_ix[ix]} vs {val}" + ) + x_width_by_ix[ix] = val + + if ywk in hdr: + val = hdr[ywk] + if iy in y_width_by_iy and not np.isclose(y_width_by_iy[iy], val): + raise ValueError( + f"Inconsistent YWID for iy={iy}: " + f"{y_width_by_iy[iy]} vs {val}" + ) + y_width_by_iy[iy] = val + + return x_center_by_ix, y_center_by_iy, x_width_by_ix, y_width_by_iy + ## End defining some auxillary functions -------- + + ## Get Header + extname = "PSF" + hdu = old_hdul[extname] + cube = np.asarray(hdu.data) + hdr_in = hdu.header.copy() + + if cube.ndim != 3: + raise ValueError(f"Expected 3D PSF cube, got shape {cube.shape}") + + nzone = cube.shape[0] + bins_x, bins_y = infer_grid_shape_from_header_comments(hdr_in, nzone) + + print(f"Detected bins_x={bins_x}, bins_y={bins_y}, nzone={nzone}") + + x_center_by_ix, y_center_by_iy, x_width_by_ix, y_width_by_iy = collect_axis_values_by_zone( + hdr_in, nzone + ) + + # Validate that all needed axis values were recovered + missing = [] + for ix in range(bins_x): + if ix not in x_center_by_ix: + missing.append(f"x_center[{ix}]") + if ix not in x_width_by_ix: + missing.append(f"x_width[{ix}]") + for iy in range(bins_y): + if iy not in y_center_by_iy: + missing.append(f"y_center[{iy}]") + if iy not in y_width_by_iy: + missing.append(f"y_width[{iy}]") + + if missing: + raise ValueError(f"Missing axis metadata recovered from old header: {missing}") + + hdr_out = hdr_in.copy() + + # Rewrite only the per-plane metadata so plane k matches x-fast ordering. + # plane k0 should correspond to: + # ix = k0 % bins_x + # iy = k0 // bins_x + for k0 in range(nzone): + ix = k0 % bins_x + iy = k0 // bins_x + k1 = k0 + 1 + + hdr_out[f"XCTR_{k1}"] = ( + x_center_by_ix[ix], + f"Center of x zone ({ix}, {iy})" + ) + hdr_out[f"YCTR_{k1}"] = ( + y_center_by_iy[iy], + f"Center of y zone ({ix}, {iy})" + ) + hdr_out[f"XWID_{k1}"] = ( + x_width_by_ix[ix], + f"Width of x zone ({ix}, {iy})" + ) + hdr_out[f"YWID_{k1}"] = ( + y_width_by_iy[iy], + f"Width of y zone ({ix}, {iy})" + ) + + # Optional but useful provenance note + hdr_out["HISTORY"] = "Rewrote PSF per-plane zone metadata to x-fast ordering." + hdr_out["HISTORY"] = "Cube plane data left unchanged." + + + + new_hdu = fits.ImageHDU(data=cube, header=hdr_out, name=hdu.name) + + ext_index = old_hdul.index_of(extname) + new_hdul = fits.HDUList() + for i, old in enumerate(old_hdul): + if i == ext_index: + new_hdul.append(new_hdu) + else: + new_hdul.append(old.copy()) + + ## TO DO: UPDATE VERSION + #new_hdul['PRIMARY'].header["VERSION"] = '6.5' # SET NEW VERSION HERE + + return(new_hdul) +``` + +We now run this function to create a new HDU list that we will use later. + +```{code-cell} ipython3 +new_image_hdul = update_psf_header(old_hdul=image_hdul) +``` + +Let's compare the new and old PSF headers to see the difference. + +```{code-cell} ipython3 +image_hdul['PSF'].header[22:40] +``` + +```{code-cell} ipython3 +new_image_hdul['PSF'].header[22:40] +``` + +Now we have to update the variables we have set above. + +```{code-cell} ipython3 +### TODO UPDATE VARIABLES. +``` ```{code-cell} ipython3 -psf_header[22:40] +hdul['PSF'].header[22:40] ``` We confirm that the oversampling factor (`OVERSAMP`) is 10. From 380ab4e2c138ef41d9b6a70064ccf587baf1e4c0 Mon Sep 17 00:00:00 2001 From: Andreas Faisst Date: Tue, 10 Mar 2026 20:18:00 -0700 Subject: [PATCH 03/14] update goals; remarks PSF header fix; correct version look up --- tutorials/spherex/spherex_psf.md | 78 ++++++++++++++++++++------------ 1 file changed, 49 insertions(+), 29 deletions(-) diff --git a/tutorials/spherex/spherex_psf.md b/tutorials/spherex/spherex_psf.md index 82b3de4a..3f52a45c 100644 --- a/tutorials/spherex/spherex_psf.md +++ b/tutorials/spherex/spherex_psf.md @@ -22,10 +22,10 @@ kernelspec: +++ ```{warning} -In the SPHEREx spectral image versions prior to XXXX, there was a missmatch between the spatial layout of the PSF zones and the the indexing of the PSF zones in the header. This has now been fixed in versions post XXXX. -However, users using the old versions will need to implement and extra step (described below in Section 5) to update the image header. +In the SPHEREx spectral image versions prior or equal to 6.5.5, there was a missmatch between the spatial layout of the PSF zones and the the indexing of the PSF zones in the image header. This has now been fixed in versions post 6.5.5. +However, users using the old versions will need to implement and extra step (described below in Section 5.1) to update the image header. -For more information, see the following webpage: [PSF Erratum](WEBPAGE) +For more information about these changes, see the following webpage: [PSF Erratum](WEBPAGE) ``` +++ @@ -33,7 +33,7 @@ For more information, see the following webpage: [PSF Erratum](WEBPAGE) ## 1. Learning Goals * Determine how pixels in a SPHEREx cutout map to the pixels in the parent SPHEREx spectral image. -* Understand the structure of the PSF extension in a SPHEREx cutout (which is the same as the PSF extension in the parent spectral image) +* Understand the structure of the PSF extension in a SPHEREx cutout (which is the same as the PSF extension in the parent spectral image). * Learn how to tell which version of the SPHEREx spectral image you are looking at, and how to interpret this information to obtain the correct PSF extension for the SPHEREx spectral images. * Learn which plane in a SPHEREx cutout PSF extension cube most accurately describes the coordinates you are interested in. @@ -70,6 +70,7 @@ import re import time import urllib.error import copy +from packaging.version import Version import astropy.units as u import matplotlib.pyplot as plt @@ -171,7 +172,7 @@ for attempt in range(max_retries): time.sleep(10 * (attempt + 1)) ``` -Examine the header. +Let's examine the HDU list info. ```{code-cell} ipython3 hdul.info() @@ -215,24 +216,46 @@ The goal of this tutorial now is to find the PSF corresponding to our input coor +++ ```{warning} -In the SPHEREx spectral image versions prior to XXXX, there was a missmatch between the spatial layout of the PSF zones and the the indexing of the PSF zones in the header. This has now been fixed in versions post XXXX. +In the SPHEREx spectral image versions prior or equal to 6.5.5, there was a missmatch between the spatial layout of the PSF zones and the the indexing of the PSF zones in the image header. This has now been fixed in versions post 6.5.5. -For more information, see the following webpage: [PSF Erratum](WEBPAGE) +For more information about these changes, see the following webpage: [PSF Erratum](WEBPAGE) **Users using the old versions will need to implement and extra step (described below) to update the image header.** ``` -Let's first check if a header update is necessary. We can do that by checking the `VERSION` keyword in the header. +Let's first check here if a header update is necessary. We can do that by printing the `VERSION` keyword in the header. + +For comparisons versions, we can use the Python-internal `Version()` function from the `packaging.version` package. However, since reprocessed images can have version names such as `6.5.4-001` (which are superior to `6.5.4`, for example), we have to write a little wrapper function such that `Version()` can interpret these correctly. + +```{code-cell} ipython3 +def parse_version(v): + ''' + Parses versions correctly such that "6.5.4-001" is later than "6.5.5". + ''' + if "-" in v: + base, mod = v.split("-", 1) + return (1, Version(base), int(mod)) # modified versions always rank higher + else: + return (0, Version(v), 0) +``` + +Now, we can use this function to properly compare versions. ```{code-cell} ipython3 -image_hdul['PRIMARY'].header["VERSION"] +this_version = parse_version( image_hdul['PRIMARY'].header["VERSION"] ) +print(f"Current version is {this_version}") + +if this_version <= parse_version("6.5.5"): + print("PSF header needs to be updated! -> Go to Section 5.1 :(") +else: + print("PSF header is already up-to-date! -> Proceed to Section 6 :)") ``` -If the version of the SPHEREx spectral image is less than `6.4`, we will have to update the header. This is explained in Section 5.1. If the version is later than `6.4`, the header is already updated and the PSF issue is fixed. In this case, proceed to Section 6 directly. +If the version of the SPHEREx spectral image is less or equal than `6.5.5`, we will have to update the header. This is explained in Section 5.1. If the version is later than `6.5.5`, the header is already updated and the PSF issue is fixed. In this case, proceed to Section 6 directly. +++ -### 5.1 Updating Old SPHEREx Spectral Image Data (`VERSIONS` < XXX) +### 5.1 Updating Old SPHEREx Spectral Image Data (if version is $\leq$ 6.5.5) +++ @@ -264,11 +287,18 @@ def update_psf_header(old_hdul): """ + def parse_version(v): + if "-" in v: + base, mod = v.split("-", 1) + return (1, Version(base), int(mod)) # modified versions always rank higher + else: + return (0, Version(v), 0) + ## Check if old version - this_version = float( old_hdul['PRIMARY'].header["VERSION"] ) - if this_version <= 6.4: + this_version = parse_version( old_hdul['PRIMARY'].header["VERSION"] ) + if this_version <= parse_version("6.5.5"): print(f"Old version detected ({this_version}) -> Update header.") - elif this_version > 6.4: + elif this_version > Version("6.5.5"): print(f"New version detected ({this_version}) -> Do not update header.") return(old_hdul) @@ -466,23 +496,13 @@ new_image_hdul['PSF'].header[22:40] Now we have to update the variables we have set above. ```{code-cell} ipython3 -### TODO UPDATE VARIABLES. -``` - -```{code-cell} ipython3 -hdul['PSF'].header[22:40] +cutout_header = new_image_hdul['IMAGE'].header +psf_header = new_image_hdul['PSF'].header +cutout = new_image_hdul['IMAGE'].data +psfcube = new_image_hdul['PSF'].data ``` -We confirm that the oversampling factor (`OVERSAMP`) is 10. -The PSFs are distributed in an even grid with 11x11 zones. -Each of the 121 PSFs is responsible for one of these zones. -The PSF header therefore includes the center position of these zones as well as the width of the zones. -These center coordinate are specified with `XCTR_i` and `YCTR_i`, respectively, where i = 1...121. -The widths are specified with `XWID_i` and `YWID_i`, respectively, where again i = 1...121. -The zones have approximately equal widths and are arranged in an even grid. -The size of the zones is sufficient to capture well the changes of the PSF size and structure with wavelength and spatial coordinates. - -The goal of this tutorial now is to find the PSF corresponding to our input coordinates of interest. +With this fix, we are now ready to proceed! +++ From 1056179ea275acb40425c2fd38d9a041d16950e3 Mon Sep 17 00:00:00 2001 From: Andreas Faisst Date: Tue, 10 Mar 2026 21:18:04 -0700 Subject: [PATCH 04/14] implemented version change after PSF header update. Still placeholder --- tutorials/spherex/spherex_psf.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tutorials/spherex/spherex_psf.md b/tutorials/spherex/spherex_psf.md index 3f52a45c..eba62ea8 100644 --- a/tutorials/spherex/spherex_psf.md +++ b/tutorials/spherex/spherex_psf.md @@ -472,7 +472,7 @@ def update_psf_header(old_hdul): new_hdul.append(old.copy()) ## TO DO: UPDATE VERSION - #new_hdul['PRIMARY'].header["VERSION"] = '6.5' # SET NEW VERSION HERE + new_hdul['PRIMARY'].header["VERSION"] = new_hdul['PRIMARY'].header["VERSION"] + "-001" # SET NEW VERSION HERE return(new_hdul) ``` @@ -483,6 +483,13 @@ We now run this function to create a new HDU list that we will use later. new_image_hdul = update_psf_header(old_hdul=image_hdul) ``` +Let's check if the version keywords was updated: + +```{code-cell} ipython3 +print(f"Old version: {image_hdul['PRIMARY'].header['VERSION']}") +print(f"Updated version: {new_image_hdul['PRIMARY'].header['VERSION']}") +``` + Let's compare the new and old PSF headers to see the difference. ```{code-cell} ipython3 From cf74f0caafff4e03864e31316f41702c37bbd605 Mon Sep 17 00:00:00 2001 From: Andreas Faisst Date: Thu, 12 Mar 2026 05:34:19 -0700 Subject: [PATCH 05/14] updated to new version fix x.y+psffix1 --- tutorials/spherex/spherex_psf.md | 53 +++++++++++++++++++++++--------- 1 file changed, 38 insertions(+), 15 deletions(-) diff --git a/tutorials/spherex/spherex_psf.md b/tutorials/spherex/spherex_psf.md index eba62ea8..03163caf 100644 --- a/tutorials/spherex/spherex_psf.md +++ b/tutorials/spherex/spherex_psf.md @@ -225,18 +225,27 @@ For more information about these changes, see the following webpage: [PSF Erratu Let's first check here if a header update is necessary. We can do that by printing the `VERSION` keyword in the header. -For comparisons versions, we can use the Python-internal `Version()` function from the `packaging.version` package. However, since reprocessed images can have version names such as `6.5.4-001` (which are superior to `6.5.4`, for example), we have to write a little wrapper function such that `Version()` can interpret these correctly. +For comparisons versions, we can use the Python-internal `Version()` function from the `packaging.version` package. However, since reprocessed images can have version names such as `6.5.4+psffix1` (which are superior to `6.5.4`, for example), we have to write a little wrapper function such that `Version()` can interpret these correctly. ```{code-cell} ipython3 def parse_version(v): - ''' - Parses versions correctly such that "6.5.4-001" is later than "6.5.5". - ''' - if "-" in v: - base, mod = v.split("-", 1) - return (1, Version(base), int(mod)) # modified versions always rank higher - else: - return (0, Version(v), 0) + # detect modifiers + modifier = None + base = v + + if "+" in v: + base, modifier = v.split("+", 1) + + base_version = Version(base) + + if modifier is None: + return (0, base_version, 0) + + # extract numeric part if present + m = re.search(r'\d+', modifier) + modnum = int(m.group()) if m else 0 + + return (1, base_version, modnum) ``` Now, we can use this function to properly compare versions. @@ -288,11 +297,25 @@ def update_psf_header(old_hdul): """ def parse_version(v): - if "-" in v: - base, mod = v.split("-", 1) - return (1, Version(base), int(mod)) # modified versions always rank higher - else: - return (0, Version(v), 0) + # detect modifiers + modifier = None + base = v + + if "+" in v: + base, modifier = v.split("+", 1) + elif "-" in v: + base, modifier = v.split("-", 1) + + base_version = Version(base) + + if modifier is None: + return (0, base_version, 0) + + # extract numeric part if present + m = re.search(r'\d+', modifier) + modnum = int(m.group()) if m else 0 + + return (1, base_version, modnum) ## Check if old version this_version = parse_version( old_hdul['PRIMARY'].header["VERSION"] ) @@ -472,7 +495,7 @@ def update_psf_header(old_hdul): new_hdul.append(old.copy()) ## TO DO: UPDATE VERSION - new_hdul['PRIMARY'].header["VERSION"] = new_hdul['PRIMARY'].header["VERSION"] + "-001" # SET NEW VERSION HERE + new_hdul['PRIMARY'].header["VERSION"] = new_hdul['PRIMARY'].header["VERSION"] + "+psffix1" # SET NEW VERSION HERE return(new_hdul) ``` From a00f2cea852e321e2341caa223166b77676266e5 Mon Sep 17 00:00:00 2001 From: Andreas Faisst Date: Thu, 12 Mar 2026 05:37:41 -0700 Subject: [PATCH 06/14] updated to new version fix x.y+psffix1 --- tutorials/spherex/spherex_psf.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/tutorials/spherex/spherex_psf.md b/tutorials/spherex/spherex_psf.md index 03163caf..9b005441 100644 --- a/tutorials/spherex/spherex_psf.md +++ b/tutorials/spherex/spherex_psf.md @@ -303,8 +303,6 @@ def update_psf_header(old_hdul): if "+" in v: base, modifier = v.split("+", 1) - elif "-" in v: - base, modifier = v.split("-", 1) base_version = Version(base) From 61c8ee81e7fea94d55d82adfda3817cc9db09641 Mon Sep 17 00:00:00 2001 From: Andreas Faisst Date: Thu, 12 Mar 2026 18:47:24 -0700 Subject: [PATCH 07/14] updated text regarding number of PSF zones --- tutorials/spherex/spherex_psf.md | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/tutorials/spherex/spherex_psf.md b/tutorials/spherex/spherex_psf.md index 9b005441..3e6e9092 100644 --- a/tutorials/spherex/spherex_psf.md +++ b/tutorials/spherex/spherex_psf.md @@ -186,8 +186,14 @@ We have already loaded their data as well as their header. psfcube.shape ``` -The shape of the `psfcube` is (121,101,101). -This corresponds to a grid of 11x11 PSFs across the image, each of them of the size 101x101 pixels. +The shape of the `psfcube` is output above. +In the QR-2 data, the shape is (121,101,101), which corresponds to a grid of 11x11 PSF zones across the image. + +```{note} +The number of PSF zones may change in later versions of data products. +``` + +Each PSF has a size of 101x101 pixels. ```{note} Remember that the PSFs are oversampled by a factor of 10. @@ -203,11 +209,11 @@ psf_header[0:30] ``` We confirm that the oversampling factor (`OVERSAMP`) is 10. -The PSFs are distributed in an even grid with 11x11 zones. -Each of the 121 PSFs is responsible for one of these zones. +The PSFs are distributed in an even grid with NxM zones (in QR-2 data products it is N=M=11). +Each of the NxM PSFs is responsible for one of these zones. The PSF header therefore includes the center position of these zones as well as the width of the zones. -These center coordinate are specified with `XCTR_i` and `YCTR_i`, respectively, where i = 1...121. -The widths are specified with `XWID_i` and `YWID_i`, respectively, where again i = 1...121. +These center coordinate are specified with `XCTR_i` and `YCTR_i`, respectively, where i = 1...(NxM). +The widths are specified with `XWID_i` and `YWID_i`, respectively, where again i = 1...(NxM). The zones have approximately equal widths and are arranged in an even grid. The size of the zones is sufficient to capture well the changes of the PSF size and structure with wavelength and spatial coordinates. @@ -216,7 +222,7 @@ The goal of this tutorial now is to find the PSF corresponding to our input coor +++ ```{warning} -In the SPHEREx spectral image versions prior or equal to 6.5.5, there was a missmatch between the spatial layout of the PSF zones and the the indexing of the PSF zones in the image header. This has now been fixed in versions post 6.5.5. +In the SPHEREx spectral image versions prior or equal to 6.5.5, there was a missmatch between the spatial layout of the PSF zones and the the indexing of the PSF zones in the image header. This has now been fixed in versions 6.5.6 and beyond. For more information about these changes, see the following webpage: [PSF Erratum](WEBPAGE) From 39a2b27751ce5bb2d81f631222227582ef3a8aa7 Mon Sep 17 00:00:00 2001 From: Andreas Faisst Date: Thu, 12 Mar 2026 18:59:44 -0700 Subject: [PATCH 08/14] added webpage for PSF Erratum --- tutorials/spherex/spherex_psf.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tutorials/spherex/spherex_psf.md b/tutorials/spherex/spherex_psf.md index 3e6e9092..4375b0ab 100644 --- a/tutorials/spherex/spherex_psf.md +++ b/tutorials/spherex/spherex_psf.md @@ -25,7 +25,7 @@ kernelspec: In the SPHEREx spectral image versions prior or equal to 6.5.5, there was a missmatch between the spatial layout of the PSF zones and the the indexing of the PSF zones in the image header. This has now been fixed in versions post 6.5.5. However, users using the old versions will need to implement and extra step (described below in Section 5.1) to update the image header. -For more information about these changes, see the following webpage: [PSF Erratum](WEBPAGE) +For more information about these changes, see the following webpage: [PSF Erratum](https://irsa.ipac.caltech.edu/data/SPHEREx/docs/psfhdrerr.html) ``` +++ @@ -187,9 +187,9 @@ psfcube.shape ``` The shape of the `psfcube` is output above. -In the QR-2 data, the shape is (121,101,101), which corresponds to a grid of 11x11 PSF zones across the image. ```{note} +In the QR-2 data, the shape is (121,101,101), which corresponds to a grid of 11x11 PSF zones across the image. The number of PSF zones may change in later versions of data products. ``` @@ -224,7 +224,7 @@ The goal of this tutorial now is to find the PSF corresponding to our input coor ```{warning} In the SPHEREx spectral image versions prior or equal to 6.5.5, there was a missmatch between the spatial layout of the PSF zones and the the indexing of the PSF zones in the image header. This has now been fixed in versions 6.5.6 and beyond. -For more information about these changes, see the following webpage: [PSF Erratum](WEBPAGE) +For more information about these changes, see the following webpage: [PSF Erratum](https://irsa.ipac.caltech.edu/data/SPHEREx/docs/psfhdrerr.html) **Users using the old versions will need to implement and extra step (described below) to update the image header.** ``` @@ -270,7 +270,7 @@ If the version of the SPHEREx spectral image is less or equal than `6.5.5`, we w +++ -### 5.1 Updating Old SPHEREx Spectral Image Data (if version is $\leq$ 6.5.5) +### 5.1 Updating PSF Header (for SPHEREx Spectral Image versions $\leq$ 6.5.5) +++ From 9556d3beaa7b0594d04a22c2eb188ccb7aa2287a Mon Sep 17 00:00:00 2001 From: Andreas Faisst Date: Fri, 13 Mar 2026 07:58:16 -0700 Subject: [PATCH 09/14] removed first warning. Added note that not intended for use on QR-1; added link to function to update header. --- tutorials/spherex/spherex_psf.md | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/tutorials/spherex/spherex_psf.md b/tutorials/spherex/spherex_psf.md index 4375b0ab..b1cbf797 100644 --- a/tutorials/spherex/spherex_psf.md +++ b/tutorials/spherex/spherex_psf.md @@ -21,15 +21,6 @@ kernelspec: +++ -```{warning} -In the SPHEREx spectral image versions prior or equal to 6.5.5, there was a missmatch between the spatial layout of the PSF zones and the the indexing of the PSF zones in the image header. This has now been fixed in versions post 6.5.5. -However, users using the old versions will need to implement and extra step (described below in Section 5.1) to update the image header. - -For more information about these changes, see the following webpage: [PSF Erratum](https://irsa.ipac.caltech.edu/data/SPHEREx/docs/psfhdrerr.html) -``` - -+++ - ## 1. Learning Goals * Determine how pixels in a SPHEREx cutout map to the pixels in the parent SPHEREx spectral image. @@ -39,6 +30,12 @@ For more information about these changes, see the following webpage: [PSF Erratu +++ +```{note} +This notebook is not intended for use of QR-1 data. +``` + ++++ + ## 2. SPHEREx Overview SPHEREx is a NASA Astrophysics Medium Explorer mission that launched in March 2025. @@ -226,7 +223,7 @@ In the SPHEREx spectral image versions prior or equal to 6.5.5, there was a miss For more information about these changes, see the following webpage: [PSF Erratum](https://irsa.ipac.caltech.edu/data/SPHEREx/docs/psfhdrerr.html) -**Users using the old versions will need to implement and extra step (described below) to update the image header.** +**Users using the old versions will need to implement and extra step to update the image header. A function to update the header is given [in Section 5.1 below](#update_psf_header_function). ** ``` Let's first check here if a header update is necessary. We can do that by printing the `VERSION` keyword in the header. @@ -282,6 +279,7 @@ The function that can be used to update the header is shown below. The function Note that this function an work as standalone function to process many images. ```{code-cell} ipython3 + def update_psf_header(old_hdul): """ Fix a old PSF FITS file header by rewriting only the per-plane header metadata From 09585845ab264be62b2bd531e500645626576aa1 Mon Sep 17 00:00:00 2001 From: Troy Raen Date: Fri, 13 Mar 2026 11:38:07 -0700 Subject: [PATCH 10/14] Bugfix authors, bold, and anchor --- tutorials/spherex/spherex_psf.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tutorials/spherex/spherex_psf.md b/tutorials/spherex/spherex_psf.md index b1cbf797..f323cf48 100644 --- a/tutorials/spherex/spherex_psf.md +++ b/tutorials/spherex/spherex_psf.md @@ -1,10 +1,4 @@ --- -authors: -- name: Vandana Desai -- name: Jessica Krick -- name: Andreas Faisst -- name: "Brigitta Sip\u0151cz" -- name: Troy Raen jupytext: text_representation: extension: .md @@ -15,6 +9,12 @@ kernelspec: display_name: Python 3 (ipykernel) language: python name: python3 +authors: + - name: Vandana Desai + - name: Jessica Krick + - name: Andreas Faisst + - name: Brigitta Sipőcz + - name: Troy Raen --- # Understanding and Extracting the PSF Extension in a SPHEREx Cutout @@ -223,7 +223,7 @@ In the SPHEREx spectral image versions prior or equal to 6.5.5, there was a miss For more information about these changes, see the following webpage: [PSF Erratum](https://irsa.ipac.caltech.edu/data/SPHEREx/docs/psfhdrerr.html) -**Users using the old versions will need to implement and extra step to update the image header. A function to update the header is given [in Section 5.1 below](#update_psf_header_function). ** +**Users using the old versions will need to implement an extra step to update the image header. A function to update the header is given [in Section 5.1 below](#update-psf-header-function).** ``` Let's first check here if a header update is necessary. We can do that by printing the `VERSION` keyword in the header. @@ -267,6 +267,7 @@ If the version of the SPHEREx spectral image is less or equal than `6.5.5`, we w +++ +(update-psf-header-function)= ### 5.1 Updating PSF Header (for SPHEREx Spectral Image versions $\leq$ 6.5.5) +++ @@ -279,7 +280,6 @@ The function that can be used to update the header is shown below. The function Note that this function an work as standalone function to process many images. ```{code-cell} ipython3 - def update_psf_header(old_hdul): """ Fix a old PSF FITS file header by rewriting only the per-plane header metadata From 9df125552dbddfc27ea5a99c16c93a222942e074 Mon Sep 17 00:00:00 2001 From: Troy Raen Date: Fri, 13 Mar 2026 11:41:07 -0700 Subject: [PATCH 11/14] Clean up type spec, spelling, and whitespace --- tutorials/spherex/spherex_psf.md | 61 +++++++++++++++----------------- 1 file changed, 28 insertions(+), 33 deletions(-) diff --git a/tutorials/spherex/spherex_psf.md b/tutorials/spherex/spherex_psf.md index f323cf48..53006b25 100644 --- a/tutorials/spherex/spherex_psf.md +++ b/tutorials/spherex/spherex_psf.md @@ -219,7 +219,7 @@ The goal of this tutorial now is to find the PSF corresponding to our input coor +++ ```{warning} -In the SPHEREx spectral image versions prior or equal to 6.5.5, there was a missmatch between the spatial layout of the PSF zones and the the indexing of the PSF zones in the image header. This has now been fixed in versions 6.5.6 and beyond. +In the SPHEREx spectral image versions prior or equal to 6.5.5, there was a mismatch between the spatial layout of the PSF zones and the indexing of the PSF zones in the image header. This has now been fixed in versions 6.5.6 and beyond. For more information about these changes, see the following webpage: [PSF Erratum](https://irsa.ipac.caltech.edu/data/SPHEREx/docs/psfhdrerr.html) @@ -235,7 +235,7 @@ def parse_version(v): # detect modifiers modifier = None base = v - + if "+" in v: base, modifier = v.split("+", 1) @@ -277,12 +277,12 @@ The function that can be used to update the header is shown below. The function * changes the PSF zone indexing and * changes the version of the header such that it is consistent with the new released images -Note that this function an work as standalone function to process many images. +Note that this function can work as standalone function to process many images. ```{code-cell} ipython3 def update_psf_header(old_hdul): """ - Fix a old PSF FITS file header by rewriting only the per-plane header metadata + Fix an old PSF FITS file header by rewriting only the per-plane header metadata so that plane k corresponds to x-fast ordering: k0 = iy * bins_x + ix @@ -290,33 +290,32 @@ def update_psf_header(old_hdul): Parameters ---------- - old_hdul : astropy hdul + old_hdul : fits.HDUList Old SPHEREx Spectral Image HDUL Return ---------- - new_hdul : astropy hdul + new_hdul : fits.HDUList New SPHEREx Spectral Image HDUL with updated PSF zone data in header and updated version number - """ def parse_version(v): # detect modifiers modifier = None base = v - + if "+" in v: base, modifier = v.split("+", 1) - + base_version = Version(base) - + if modifier is None: return (0, base_version, 0) - + # extract numeric part if present m = re.search(r'\d+', modifier) modnum = int(m.group()) if m else 0 - + return (1, base_version, modnum) ## Check if old version @@ -327,18 +326,18 @@ def update_psf_header(old_hdul): print(f"New version detected ({this_version}) -> Do not update header.") return(old_hdul) - ## Define some auxillary functions ------- + ## Define some auxiliary functions ------- def parse_ixiy_from_comment(comment): _zone_pat = re.compile(r"\((\d+)\s*,\s*(\d+)\)") m = _zone_pat.search(str(comment)) if not m: raise ValueError(f"Could not parse zone indices from comment: {comment!r}") return int(m.group(1)), int(m.group(2)) - + def infer_grid_shape_from_header_comments(hdr, nzone): max_ix = -1 max_iy = -1 - + for k1 in range(1, nzone + 1): key = f"XCTR_{k1}" if key not in hdr: @@ -346,23 +345,23 @@ def update_psf_header(old_hdul): ix, iy = parse_ixiy_from_comment(hdr.comments[key]) max_ix = max(max_ix, ix) max_iy = max(max_iy, iy) - + bins_x = max_ix + 1 bins_y = max_iy + 1 - + if bins_x * bins_y != nzone: raise ValueError( f"Inconsistent grid inferred from comments: " f"bins_x={bins_x}, bins_y={bins_y}, nzone={nzone}" ) - + return bins_x, bins_y - + def collect_axis_values_by_zone(hdr, nzone): """ Read the old header and collect unique x/y centers and widths by zone index labels found in the comments. - + This uses the old header only to recover the per-axis values for each ix, iy. It does NOT use the old plane ordering as truth. """ @@ -370,15 +369,15 @@ def update_psf_header(old_hdul): y_center_by_iy = {} x_width_by_ix = {} y_width_by_iy = {} - + for k1 in range(1, nzone + 1): ix, iy = parse_ixiy_from_comment(hdr.comments[f"XCTR_{k1}"]) - + xck = f"XCTR_{k1}" yck = f"YCTR_{k1}" xwk = f"XWID_{k1}" ywk = f"YWID_{k1}" - + if xck in hdr: val = hdr[xck] if ix in x_center_by_ix and not np.isclose(x_center_by_ix[ix], val): @@ -387,7 +386,7 @@ def update_psf_header(old_hdul): f"{x_center_by_ix[ix]} vs {val}" ) x_center_by_ix[ix] = val - + if yck in hdr: val = hdr[yck] if iy in y_center_by_iy and not np.isclose(y_center_by_iy[iy], val): @@ -396,7 +395,7 @@ def update_psf_header(old_hdul): f"{y_center_by_iy[iy]} vs {val}" ) y_center_by_iy[iy] = val - + if xwk in hdr: val = hdr[xwk] if ix in x_width_by_ix and not np.isclose(x_width_by_ix[ix], val): @@ -405,7 +404,7 @@ def update_psf_header(old_hdul): f"{x_width_by_ix[ix]} vs {val}" ) x_width_by_ix[ix] = val - + if ywk in hdr: val = hdr[ywk] if iy in y_width_by_iy and not np.isclose(y_width_by_iy[iy], val): @@ -414,9 +413,9 @@ def update_psf_header(old_hdul): f"{y_width_by_iy[iy]} vs {val}" ) y_width_by_iy[iy] = val - + return x_center_by_ix, y_center_by_iy, x_width_by_ix, y_width_by_iy - ## End defining some auxillary functions -------- + ## End defining some auxiliary functions -------- ## Get Header extname = "PSF" @@ -484,7 +483,7 @@ def update_psf_header(old_hdul): hdr_out["HISTORY"] = "Rewrote PSF per-plane zone metadata to x-fast ordering." hdr_out["HISTORY"] = "Cube plane data left unchanged." - + new_hdu = fits.ImageHDU(data=cube, header=hdr_out, name=hdu.name) @@ -658,7 +657,3 @@ To use this PSF for forward modeling or fitting, you must: **Contact:** Contact [IRSA Helpdesk](https://irsa.ipac.caltech.edu/docs/help_desk.html) with questions or problems. **Runtime:** Approximately 30 seconds. - -```{code-cell} ipython3 - -``` From e2b4ab7c37e0773119430deaf6f5de525d8640d4 Mon Sep 17 00:00:00 2001 From: Troy Raen Date: Fri, 13 Mar 2026 12:26:55 -0700 Subject: [PATCH 12/14] Apply feedback from tgoldina review --- tutorials/spherex/spherex_psf.md | 72 +++++++++++--------------------- 1 file changed, 25 insertions(+), 47 deletions(-) diff --git a/tutorials/spherex/spherex_psf.md b/tutorials/spherex/spherex_psf.md index 53006b25..decab29e 100644 --- a/tutorials/spherex/spherex_psf.md +++ b/tutorials/spherex/spherex_psf.md @@ -228,42 +228,20 @@ For more information about these changes, see the following webpage: [PSF Erratu Let's first check here if a header update is necessary. We can do that by printing the `VERSION` keyword in the header. -For comparisons versions, we can use the Python-internal `Version()` function from the `packaging.version` package. However, since reprocessed images can have version names such as `6.5.4+psffix1` (which are superior to `6.5.4`, for example), we have to write a little wrapper function such that `Version()` can interpret these correctly. +For comparing versions, we can use the Python-internal `Version()` function from the `packaging.version` package. Images that have already been reprocessed can have version names such as `6.5.4+psffix1` (which are superior to `6.5.4`, for example), and we can use `Version().local` to check for those. ```{code-cell} ipython3 -def parse_version(v): - # detect modifiers - modifier = None - base = v - - if "+" in v: - base, modifier = v.split("+", 1) - - base_version = Version(base) - - if modifier is None: - return (0, base_version, 0) - - # extract numeric part if present - m = re.search(r'\d+', modifier) - modnum = int(m.group()) if m else 0 - - return (1, base_version, modnum) -``` - -Now, we can use this function to properly compare versions. - -```{code-cell} ipython3 -this_version = parse_version( image_hdul['PRIMARY'].header["VERSION"] ) +this_version = Version(image_hdul['PRIMARY'].header["VERSION"]) +contains_psffix1 = this_version.local is not None and "psffix1" in this_version.local print(f"Current version is {this_version}") -if this_version <= parse_version("6.5.5"): +if this_version <= Version("6.5.5") and not contains_psffix1: print("PSF header needs to be updated! -> Go to Section 5.1 :(") else: print("PSF header is already up-to-date! -> Proceed to Section 6 :)") ``` -If the version of the SPHEREx spectral image is less or equal than `6.5.5`, we will have to update the header. This is explained in Section 5.1. If the version is later than `6.5.5`, the header is already updated and the PSF issue is fixed. In this case, proceed to Section 6 directly. +If the version of the SPHEREx spectral image is less or equal than `6.5.5` and hasn't already been reprocessed, we will have to update the header. This is explained in Section 5.1. If the version is later than `6.5.5` or includes `"psffix1"`, the header is already updated and the PSF issue is fixed. In this case, proceed to Section 6 directly. +++ @@ -299,32 +277,32 @@ def update_psf_header(old_hdul): New SPHEREx Spectral Image HDUL with updated PSF zone data in header and updated version number """ - def parse_version(v): - # detect modifiers - modifier = None - base = v + VERSION_FIXED = Version("6.5.6") + PSF_FIX_TAG = "psffix1" - if "+" in v: - base, modifier = v.split("+", 1) + def psf_fix_applied(hdul) -> bool: + """ + Return True if the PSF fix has been applied. + + Rules: + - If the VERSION header is missing in the primary HDU, the fix is not applied. + - If VERSION >= VERSION_FIXED, the fix is included in the software release. + - Otherwise the local version tag (+...) must contain PSF_FIX_TAG. + """ + header = hdul[0].header - base_version = Version(base) + if "VERSION" not in header: + return False - if modifier is None: - return (0, base_version, 0) + v = Version(header["VERSION"]) - # extract numeric part if present - m = re.search(r'\d+', modifier) - modnum = int(m.group()) if m else 0 + if v >= VERSION_FIXED: + return True - return (1, base_version, modnum) + return v.local is not None and PSF_FIX_TAG in v.local - ## Check if old version - this_version = parse_version( old_hdul['PRIMARY'].header["VERSION"] ) - if this_version <= parse_version("6.5.5"): - print(f"Old version detected ({this_version}) -> Update header.") - elif this_version > Version("6.5.5"): - print(f"New version detected ({this_version}) -> Do not update header.") - return(old_hdul) + if psf_fix_applied(old_hdul): + return old_hdul ## Define some auxiliary functions ------- def parse_ixiy_from_comment(comment): From 5daa0b5a103d36cca7c760bd93725dc5e514b779 Mon Sep 17 00:00:00 2001 From: Troy Raen Date: Fri, 13 Mar 2026 13:06:12 -0700 Subject: [PATCH 13/14] Apply feedback from christinanelson review --- tutorials/spherex/spherex_psf.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tutorials/spherex/spherex_psf.md b/tutorials/spherex/spherex_psf.md index decab29e..532f75c9 100644 --- a/tutorials/spherex/spherex_psf.md +++ b/tutorials/spherex/spherex_psf.md @@ -558,7 +558,7 @@ for key, val in psf_header.items(): ym = re.match(r'(YCTR*)', key) if ym: yplane = int(key.split("_")[1]) - yctr[xplane] = val + yctr[yplane] = val ``` Check that we got all of them! From ddeb667952096d7e5bd033c7ab61b5ecad7cc4f3 Mon Sep 17 00:00:00 2001 From: Troy Raen Date: Fri, 13 Mar 2026 16:57:25 -0700 Subject: [PATCH 14/14] Add matplotlib install and bump updated date --- tutorials/spherex/spherex_psf.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tutorials/spherex/spherex_psf.md b/tutorials/spherex/spherex_psf.md index 532f75c9..92443c95 100644 --- a/tutorials/spherex/spherex_psf.md +++ b/tutorials/spherex/spherex_psf.md @@ -58,7 +58,7 @@ The following packages must be installed to run this notebook. ```{code-cell} ipython3 # Uncomment the next line to install dependencies if needed. -# !pip install astropy numpy pyvo +# !pip install astropy matplotlib numpy pyvo ``` ```{code-cell} ipython3 @@ -630,7 +630,7 @@ To use this PSF for forward modeling or fitting, you must: ## About this notebook -**Updated:** 10 March 2026 +**Updated:** 13 March 2026 **Contact:** Contact [IRSA Helpdesk](https://irsa.ipac.caltech.edu/docs/help_desk.html) with questions or problems.