Skip to content

Commit fb6f95e

Browse files
committed
first version of PSF header fix
1 parent b653281 commit fb6f95e

1 file changed

Lines changed: 288 additions & 4 deletions

File tree

tutorials/spherex/spherex_psf.md

Lines changed: 288 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,20 @@ kernelspec:
2121

2222
+++
2323

24+
```{warning}
25+
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.
26+
However, users using the old versions will need to implement and extra step (described below in Section 5) to update the image header.
27+
28+
For more information, see the following webpage: [PSF Erratum](WEBPAGE)
29+
```
30+
31+
+++
32+
2433
## 1. Learning Goals
2534

2635
* Determine how pixels in a SPHEREx cutout map to the pixels in the parent SPHEREx spectral image.
2736
* Understand the structure of the PSF extension in a SPHEREx cutout (which is the same as the PSF extension in the parent spectral image)
37+
* 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.
2838
* Learn which plane in a SPHEREx cutout PSF extension cube most accurately describes the coordinates you are interested in.
2939

3040
+++
@@ -59,6 +69,7 @@ import http.client
5969
import re
6070
import time
6171
import urllib.error
72+
import copy
6273
6374
import astropy.units as u
6475
import matplotlib.pyplot as plt
@@ -120,10 +131,10 @@ print("Time to do TAP query: {:2.2f} seconds.".format(time.time() - t1))
120131
print("Number of images found: {}".format(len(results)))
121132
```
122133

123-
:::{note}
134+
```{note}
124135
SPHEREx data are also available via SIA which can provide a simpler interface for many queries, as demonstrated in {ref}`spherex-intro`.
125136
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.
126-
:::
137+
```
127138

128139
For this example, we focus on the first one of the retrieved SPHEREx spectral images.
129140

@@ -148,6 +159,7 @@ for attempt in range(max_retries):
148159
try:
149160
# Read the data.
150161
with fits.open(spectral_image_url) as hdul:
162+
image_hdul = copy.deepcopy(hdul)
151163
cutout_header = hdul['IMAGE'].header
152164
psf_header = hdul['PSF'].header
153165
cutout = hdul['IMAGE'].data
@@ -183,10 +195,282 @@ This means that the actual size of the PSFs is about 10x10 SPHEREx pixels, which
183195

184196
+++
185197

186-
Let's look at a small part of the PSF header to understand its format:
198+
Let's look at a small part of the PSF header to understand its format.
199+
200+
```{code-cell} ipython3
201+
psf_header[0:30]
202+
```
203+
204+
We confirm that the oversampling factor (`OVERSAMP`) is 10.
205+
The PSFs are distributed in an even grid with 11x11 zones.
206+
Each of the 121 PSFs is responsible for one of these zones.
207+
The PSF header therefore includes the center position of these zones as well as the width of the zones.
208+
These center coordinate are specified with `XCTR_i` and `YCTR_i`, respectively, where i = 1...121.
209+
The widths are specified with `XWID_i` and `YWID_i`, respectively, where again i = 1...121.
210+
The zones have approximately equal widths and are arranged in an even grid.
211+
The size of the zones is sufficient to capture well the changes of the PSF size and structure with wavelength and spatial coordinates.
212+
213+
The goal of this tutorial now is to find the PSF corresponding to our input coordinates of interest.
214+
215+
+++
216+
217+
```{warning}
218+
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.
219+
220+
For more information, see the following webpage: [PSF Erratum](WEBPAGE)
221+
222+
**Users using the old versions will need to implement and extra step (described below) to update the image header.**
223+
```
224+
225+
Let's first check if a header update is necessary. We can do that by checking the `VERSION` keyword in the header.
226+
227+
```{code-cell} ipython3
228+
image_hdul['PRIMARY'].header["VERSION"]
229+
```
230+
231+
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.
232+
233+
+++
234+
235+
### 5.1 Updating Old SPHEREx Spectral Image Data (`VERSIONS` < XXX)
236+
237+
+++
238+
239+
The function that can be used to update the header is shown below. The function
240+
* first checks if a header update is necessary
241+
* changes the PSF zone indexing and
242+
* changes the version of the header such that it is consistent with the new released images
243+
244+
Note that this function an work as standalone function to process many images.
245+
246+
```{code-cell} ipython3
247+
def update_psf_header(old_hdul):
248+
"""
249+
Fix a old PSF FITS file header by rewriting only the per-plane header metadata
250+
so that plane k corresponds to x-fast ordering:
251+
k0 = iy * bins_x + ix
252+
253+
The cube data are left untouched.
254+
255+
Parameters
256+
----------
257+
old_hdul : astropy hdul
258+
Old SPHEREx Spectral Image HDUL
259+
260+
Return
261+
----------
262+
new_hdul : astropy hdul
263+
New SPHEREx Spectral Image HDUL with updated PSF zone data in header and updated version number
264+
265+
"""
266+
267+
## Check if old version
268+
this_version = float( old_hdul['PRIMARY'].header["VERSION"] )
269+
if this_version <= 6.4:
270+
print(f"Old version detected ({this_version}) -> Update header.")
271+
elif this_version > 6.4:
272+
print(f"New version detected ({this_version}) -> Do not update header.")
273+
return(old_hdul)
274+
275+
## Define some auxillary functions -------
276+
def parse_ixiy_from_comment(comment):
277+
_zone_pat = re.compile(r"\((\d+)\s*,\s*(\d+)\)")
278+
m = _zone_pat.search(str(comment))
279+
if not m:
280+
raise ValueError(f"Could not parse zone indices from comment: {comment!r}")
281+
return int(m.group(1)), int(m.group(2))
282+
283+
def infer_grid_shape_from_header_comments(hdr, nzone):
284+
max_ix = -1
285+
max_iy = -1
286+
287+
for k1 in range(1, nzone + 1):
288+
key = f"XCTR_{k1}"
289+
if key not in hdr:
290+
raise KeyError(f"Missing required key: {key}")
291+
ix, iy = parse_ixiy_from_comment(hdr.comments[key])
292+
max_ix = max(max_ix, ix)
293+
max_iy = max(max_iy, iy)
294+
295+
bins_x = max_ix + 1
296+
bins_y = max_iy + 1
297+
298+
if bins_x * bins_y != nzone:
299+
raise ValueError(
300+
f"Inconsistent grid inferred from comments: "
301+
f"bins_x={bins_x}, bins_y={bins_y}, nzone={nzone}"
302+
)
303+
304+
return bins_x, bins_y
305+
306+
def collect_axis_values_by_zone(hdr, nzone):
307+
"""
308+
Read the old header and collect unique x/y centers and widths by zone index
309+
labels found in the comments.
310+
311+
This uses the old header only to recover the per-axis values for each ix, iy.
312+
It does NOT use the old plane ordering as truth.
313+
"""
314+
x_center_by_ix = {}
315+
y_center_by_iy = {}
316+
x_width_by_ix = {}
317+
y_width_by_iy = {}
318+
319+
for k1 in range(1, nzone + 1):
320+
ix, iy = parse_ixiy_from_comment(hdr.comments[f"XCTR_{k1}"])
321+
322+
xck = f"XCTR_{k1}"
323+
yck = f"YCTR_{k1}"
324+
xwk = f"XWID_{k1}"
325+
ywk = f"YWID_{k1}"
326+
327+
if xck in hdr:
328+
val = hdr[xck]
329+
if ix in x_center_by_ix and not np.isclose(x_center_by_ix[ix], val):
330+
raise ValueError(
331+
f"Inconsistent XCTR for ix={ix}: "
332+
f"{x_center_by_ix[ix]} vs {val}"
333+
)
334+
x_center_by_ix[ix] = val
335+
336+
if yck in hdr:
337+
val = hdr[yck]
338+
if iy in y_center_by_iy and not np.isclose(y_center_by_iy[iy], val):
339+
raise ValueError(
340+
f"Inconsistent YCTR for iy={iy}: "
341+
f"{y_center_by_iy[iy]} vs {val}"
342+
)
343+
y_center_by_iy[iy] = val
344+
345+
if xwk in hdr:
346+
val = hdr[xwk]
347+
if ix in x_width_by_ix and not np.isclose(x_width_by_ix[ix], val):
348+
raise ValueError(
349+
f"Inconsistent XWID for ix={ix}: "
350+
f"{x_width_by_ix[ix]} vs {val}"
351+
)
352+
x_width_by_ix[ix] = val
353+
354+
if ywk in hdr:
355+
val = hdr[ywk]
356+
if iy in y_width_by_iy and not np.isclose(y_width_by_iy[iy], val):
357+
raise ValueError(
358+
f"Inconsistent YWID for iy={iy}: "
359+
f"{y_width_by_iy[iy]} vs {val}"
360+
)
361+
y_width_by_iy[iy] = val
362+
363+
return x_center_by_ix, y_center_by_iy, x_width_by_ix, y_width_by_iy
364+
## End defining some auxillary functions --------
365+
366+
## Get Header
367+
extname = "PSF"
368+
hdu = old_hdul[extname]
369+
cube = np.asarray(hdu.data)
370+
hdr_in = hdu.header.copy()
371+
372+
if cube.ndim != 3:
373+
raise ValueError(f"Expected 3D PSF cube, got shape {cube.shape}")
374+
375+
nzone = cube.shape[0]
376+
bins_x, bins_y = infer_grid_shape_from_header_comments(hdr_in, nzone)
377+
378+
print(f"Detected bins_x={bins_x}, bins_y={bins_y}, nzone={nzone}")
379+
380+
x_center_by_ix, y_center_by_iy, x_width_by_ix, y_width_by_iy = collect_axis_values_by_zone(
381+
hdr_in, nzone
382+
)
383+
384+
# Validate that all needed axis values were recovered
385+
missing = []
386+
for ix in range(bins_x):
387+
if ix not in x_center_by_ix:
388+
missing.append(f"x_center[{ix}]")
389+
if ix not in x_width_by_ix:
390+
missing.append(f"x_width[{ix}]")
391+
for iy in range(bins_y):
392+
if iy not in y_center_by_iy:
393+
missing.append(f"y_center[{iy}]")
394+
if iy not in y_width_by_iy:
395+
missing.append(f"y_width[{iy}]")
396+
397+
if missing:
398+
raise ValueError(f"Missing axis metadata recovered from old header: {missing}")
399+
400+
hdr_out = hdr_in.copy()
401+
402+
# Rewrite only the per-plane metadata so plane k matches x-fast ordering.
403+
# plane k0 should correspond to:
404+
# ix = k0 % bins_x
405+
# iy = k0 // bins_x
406+
for k0 in range(nzone):
407+
ix = k0 % bins_x
408+
iy = k0 // bins_x
409+
k1 = k0 + 1
410+
411+
hdr_out[f"XCTR_{k1}"] = (
412+
x_center_by_ix[ix],
413+
f"Center of x zone ({ix}, {iy})"
414+
)
415+
hdr_out[f"YCTR_{k1}"] = (
416+
y_center_by_iy[iy],
417+
f"Center of y zone ({ix}, {iy})"
418+
)
419+
hdr_out[f"XWID_{k1}"] = (
420+
x_width_by_ix[ix],
421+
f"Width of x zone ({ix}, {iy})"
422+
)
423+
hdr_out[f"YWID_{k1}"] = (
424+
y_width_by_iy[iy],
425+
f"Width of y zone ({ix}, {iy})"
426+
)
427+
428+
# Optional but useful provenance note
429+
hdr_out["HISTORY"] = "Rewrote PSF per-plane zone metadata to x-fast ordering."
430+
hdr_out["HISTORY"] = "Cube plane data left unchanged."
431+
432+
433+
434+
new_hdu = fits.ImageHDU(data=cube, header=hdr_out, name=hdu.name)
435+
436+
ext_index = old_hdul.index_of(extname)
437+
new_hdul = fits.HDUList()
438+
for i, old in enumerate(old_hdul):
439+
if i == ext_index:
440+
new_hdul.append(new_hdu)
441+
else:
442+
new_hdul.append(old.copy())
443+
444+
## TO DO: UPDATE VERSION
445+
#new_hdul['PRIMARY'].header["VERSION"] = '6.5' # SET NEW VERSION HERE
446+
447+
return(new_hdul)
448+
```
449+
450+
We now run this function to create a new HDU list that we will use later.
451+
452+
```{code-cell} ipython3
453+
new_image_hdul = update_psf_header(old_hdul=image_hdul)
454+
```
455+
456+
Let's compare the new and old PSF headers to see the difference.
457+
458+
```{code-cell} ipython3
459+
image_hdul['PSF'].header[22:40]
460+
```
461+
462+
```{code-cell} ipython3
463+
new_image_hdul['PSF'].header[22:40]
464+
```
465+
466+
Now we have to update the variables we have set above.
467+
468+
```{code-cell} ipython3
469+
### TODO UPDATE VARIABLES.
470+
```
187471

188472
```{code-cell} ipython3
189-
psf_header[22:40]
473+
hdul['PSF'].header[22:40]
190474
```
191475

192476
We confirm that the oversampling factor (`OVERSAMP`) is 10.

0 commit comments

Comments
 (0)