pyimcom 1.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. pyimcom/__init__.py +1 -0
  2. pyimcom/_version.py +24 -0
  3. pyimcom/analysis.py +1480 -0
  4. pyimcom/coadd.py +2331 -0
  5. pyimcom/compress/__init__.py +0 -0
  6. pyimcom/compress/compressutils.py +506 -0
  7. pyimcom/compress/compressutils_wrapper.py +116 -0
  8. pyimcom/compress/i24.py +514 -0
  9. pyimcom/config.py +1245 -0
  10. pyimcom/diagnostics/__init__.py +0 -0
  11. pyimcom/diagnostics/context_figure.py +58 -0
  12. pyimcom/diagnostics/dynrange.py +274 -0
  13. pyimcom/diagnostics/layer_diagnostics.py +208 -0
  14. pyimcom/diagnostics/mosaicimage.py +80 -0
  15. pyimcom/diagnostics/noise/stability.py +126 -0
  16. pyimcom/diagnostics/noise_diagnostics.py +709 -0
  17. pyimcom/diagnostics/outimage_utils/__init__.py +0 -0
  18. pyimcom/diagnostics/outimage_utils/helper.py +82 -0
  19. pyimcom/diagnostics/report.py +366 -0
  20. pyimcom/diagnostics/run.py +64 -0
  21. pyimcom/diagnostics/starcube_nonoise.py +264 -0
  22. pyimcom/diagnostics/starcube_nonoise_coldescr.txt +24 -0
  23. pyimcom/diagnostics/stars.py +469 -0
  24. pyimcom/imdestripe.py +2454 -0
  25. pyimcom/lakernel.py +805 -0
  26. pyimcom/layer.py +1439 -0
  27. pyimcom/layer_wrapper.py +96 -0
  28. pyimcom/meta/__init__.py +0 -0
  29. pyimcom/meta/distortimage.py +748 -0
  30. pyimcom/meta/ginterp.py +340 -0
  31. pyimcom/pictures/__init__.py +0 -0
  32. pyimcom/pictures/genpic.py +229 -0
  33. pyimcom/psfutil.py +2199 -0
  34. pyimcom/routine.py +588 -0
  35. pyimcom/splitpsf/__init__.py +0 -0
  36. pyimcom/splitpsf/imsubtract.py +793 -0
  37. pyimcom/splitpsf/imsubtract_wrapper.py +107 -0
  38. pyimcom/splitpsf/splitpsf.py +497 -0
  39. pyimcom/splitpsf/splitpsf_wrapper.py +161 -0
  40. pyimcom/splitpsf/update_cube.py +136 -0
  41. pyimcom/truthcats.py +396 -0
  42. pyimcom/utils/__init__.py +0 -0
  43. pyimcom/utils/compareutils.py +207 -0
  44. pyimcom/utils/piffutils.py +223 -0
  45. pyimcom/wcsutil.py +839 -0
  46. pyimcom-1.2.1.dist-info/METADATA +67 -0
  47. pyimcom-1.2.1.dist-info/RECORD +52 -0
  48. pyimcom-1.2.1.dist-info/WHEEL +5 -0
  49. pyimcom-1.2.1.dist-info/licenses/LICENSE +21 -0
  50. pyimcom-1.2.1.dist-info/scm_file_list.json +285 -0
  51. pyimcom-1.2.1.dist-info/scm_version.json +8 -0
  52. pyimcom-1.2.1.dist-info/top_level.txt +1 -0
pyimcom/coadd.py ADDED
@@ -0,0 +1,2331 @@
1
+ """
2
+ Driver to coadd a block (2D array of postage stamps).
3
+
4
+ Classes
5
+ -------
6
+ InImage
7
+ Input image attached to a Block instance.
8
+ InStamp
9
+ Data structure for input pixel positions and signals.
10
+ OutStamp
11
+ Driver for postage stamp coaddition.
12
+ Block
13
+ Driver for block coaddition.
14
+
15
+ """
16
+
17
+ import datetime
18
+ import gc
19
+ import sys
20
+ from itertools import combinations, product
21
+ from os.path import exists
22
+ from pathlib import Path
23
+
24
+ # F401-flagged imports are important for getting a version number
25
+ import asdf
26
+ import astropy # noqa: F401
27
+ import fitsio
28
+ import matplotlib as mpl
29
+ import matplotlib.pyplot as plt
30
+ import numpy as np
31
+ import pytz
32
+ import scipy # noqa: F401
33
+ from astropy import units as u
34
+ from astropy import wcs
35
+ from astropy.io import fits
36
+ from astropy.table import Table
37
+ from filelock import FileLock, Timeout
38
+ from scipy.special import legendre
39
+
40
+ from .config import Config, Timer, format_axis, format_axis_pars
41
+ from .config import Settings as Stn
42
+ from .lakernel import CholKernel, EigenKernel, EmpirKernel, IterKernel
43
+ from .layer import Mask, check_if_idsca_exists, get_all_data
44
+ from .psfutil import PSFGrp, PSFInterpolator, PSFOvl, SysMatA, SysMatB
45
+ from .wcsutil import PyIMCOM_WCS
46
+
47
+
48
+ class InImage:
49
+ """
50
+ Input image attached to a Block instance.
51
+
52
+ Parameters
53
+ ----------
54
+ blk : Block
55
+ The Block instance to which this InImage instance is attached.
56
+ idsca : (int, int)
57
+ ID of observation and SCA used.
58
+
59
+ Methods
60
+ -------
61
+ __init__
62
+ Constructor.
63
+ generate_idx_grid
64
+ Generate a grid of indices (staticmethod).
65
+ _inpix2world2outpix
66
+ Composition of pix2world and world2pix.
67
+ outpix2world2inpix
68
+ Inverse function of _inpix2world2outpix.
69
+ partition_pixels
70
+ Partition input pixels into postage stamps.
71
+ extract_layers
72
+ Extract input layers.
73
+ clear
74
+ Free up memory space.
75
+ smooth_and_pad
76
+ Utility to smear a PSF with a tophat and a Gaussian (staticmethod).
77
+ LPolyArr
78
+ Utility to generate an array of Legendre polynomials (staticmethod).
79
+ psf_filename
80
+ PSF file name broker (staticmethod).
81
+ get_psf_pos
82
+ Get input PSF array at given position.
83
+
84
+ """
85
+
86
+ def __init__(self, blk: "Block", idsca: tuple[int, int]) -> None:
87
+ self.blk = blk
88
+ self.idsca = idsca
89
+
90
+ self.exists_, self.infile = check_if_idsca_exists(blk.cfg, blk.obsdata, idsca)
91
+ if self.exists_:
92
+ # accept either a FITS Header or ASDF + GWCS
93
+
94
+ if self.infile[-5:] == ".fits":
95
+ with fits.open(self.infile) as f:
96
+ self.inwcs = wcs.WCS(f[Stn.hdu_with_wcs].header)
97
+ # print('read WCS:', self.infile, 'HDU', Stn.hdu_with_wcs)
98
+
99
+ if self.infile[-5:] == ".asdf":
100
+ with asdf.open(self.infile) as f:
101
+ self.inwcs = PyIMCOM_WCS(f["roman"]["meta"]["wcs"])
102
+
103
+ @staticmethod
104
+ def generate_idx_grid(xs: np.array, ys: np.array) -> np.array:
105
+ """
106
+ Generate a grid of indices.
107
+
108
+ Parameters
109
+ ----------
110
+ xs : np.array
111
+ x values of the grid, length (nx,)
112
+ ys : np.array, shape : (ny,)
113
+ y values of the grid, length (ny,)
114
+
115
+ Returns
116
+ -------
117
+ np.array
118
+ All combinations of xs elements and ys elements. Shape (nx*ny,2)
119
+
120
+ """
121
+
122
+ return np.moveaxis(np.array(np.meshgrid(xs, ys)), 0, -1).reshape(-1, 2)
123
+
124
+ def _inpix2world2outpix(self, inxys: np.array) -> np.array:
125
+ """
126
+ Composition of pix2world and world2pix.
127
+
128
+ Parameters
129
+ ----------
130
+ inxys : np.array
131
+ x and y positions in the input image coordinates, shape (npix, 2)
132
+
133
+ Returns
134
+ -------
135
+ np.array
136
+ x and y positions in the output block coordinates, shape (npix, 2).
137
+
138
+ """
139
+
140
+ return self.blk.outwcs.all_world2pix(self.inwcs.all_pix2world(inxys, 0), 0)
141
+
142
+ def outpix2world2inpix(self, outxys: np.array) -> np.array:
143
+ """
144
+ Inverse function of _inpix2world2outpix.
145
+
146
+ Parameters
147
+ ----------
148
+ outxys : np.array
149
+ x and y positions in the output block coordinates, shape (npix, 2).
150
+
151
+ Returns
152
+ -------
153
+ np.array
154
+ x and y positions in the input image coordinates, shape (npix, 2).
155
+
156
+ """
157
+
158
+ return self.inwcs.all_world2pix(self.blk.outwcs.all_pix2world(outxys, 0), 0)
159
+
160
+ def partition_pixels(
161
+ self, sp_res: int = 90, relax_coef: float = 1.05, verbose: bool = False, visualize: bool = False
162
+ ) -> None:
163
+ """
164
+ Partition input pixels into postage stamps.
165
+
166
+ Parameters
167
+ ----------
168
+ sp_res : int, optional
169
+ Resolution of the sparse grid.
170
+ relax_coef : float, optional
171
+ Coefficient to create enough space for input pixels.
172
+ verbose : bool, optional
173
+ Whether to print verbose output.
174
+ visualize : bool, optional
175
+ Whether to visualize the partition process and results.
176
+
177
+ Returns
178
+ -------
179
+ None
180
+
181
+ """
182
+
183
+ if verbose:
184
+ print(f"> partitioning pixels from InImage {self.idsca}", "@", self.blk.timer(), "s")
185
+
186
+ # create a sparse grid of pixels to locate regions of interest
187
+ sp_arr = np.linspace(0, Stn.sca_nside, sp_res + 1, dtype=np.uint16)
188
+ sp_inxys = InImage.generate_idx_grid(sp_arr, sp_arr)
189
+ sp_outxys = self._inpix2world2outpix(sp_inxys).T.reshape(2, sp_res + 1, sp_res + 1)
190
+ del sp_inxys
191
+
192
+ # limits for input pixel positions in output pixel coordinates
193
+ pix_lower = -self.blk.cfg.n2 - 0.5
194
+ pix_upper = self.blk.cfg.NsideP + self.blk.cfg.n2 - 0.5
195
+
196
+ self.is_relevant = False # whether the input image is relevant to the output block
197
+ relevant_matrix = np.zeros((sp_res, sp_res), dtype=bool)
198
+ for j in range(1, sp_res):
199
+ for i in range(1, sp_res):
200
+ if not (
201
+ pix_lower < sp_outxys[0, j, i] < pix_upper and pix_lower < sp_outxys[1, j, i] < pix_upper
202
+ ):
203
+ continue
204
+ i_st = int((sp_outxys[0, j, i] - pix_lower) // self.blk.cfg.n2) # st stands for stamp
205
+ j_st = int((sp_outxys[1, j, i] - pix_lower) // self.blk.cfg.n2)
206
+ # assert i_st >= 0 and j_st >= 0, 'i_st < 0 or j_st < 0'
207
+
208
+ if np.any(
209
+ self.blk.use_instamps[
210
+ max(j_st - 2, 0) : min(j_st + 3, self.blk.cfg.n1P + 2),
211
+ max(i_st - 2, 0) : min(i_st + 3, self.blk.cfg.n1P + 2),
212
+ ]
213
+ ):
214
+ # at least some of the input pixels are relevant
215
+ self.is_relevant = True
216
+ # we will study all the adjacent input pixels
217
+ relevant_matrix[
218
+ max(j - 2, 0) : min(j + 3, sp_res), max(i - 2, 0) : min(i + 3, sp_res)
219
+ ] = True
220
+
221
+ if visualize and self.is_relevant:
222
+ with mpl.rc_context(format_axis_pars):
223
+ fig, axs = plt.subplots(2, 2, figsize=(10.8, 9.6))
224
+
225
+ for i in range(2):
226
+ ax = axs[0, i]
227
+ im = ax.imshow(sp_outxys[i] / self.blk.cfg.n2, origin="lower")
228
+ plt.colorbar(im, ax=ax)
229
+ ax.contour(sp_outxys[i], levels=[pix_lower, pix_upper], colors="r")
230
+
231
+ im = axs[1, 0].imshow(relevant_matrix, origin="lower", cmap="YlGn")
232
+ plt.colorbar(im, ax=axs[1, 0])
233
+
234
+ for ax, title in zip(
235
+ [*axs[0], axs[1, 0]],
236
+ ["stamp index $i$", "stamp index $j$", "relevant matrix"],
237
+ strict=False,
238
+ ):
239
+ ax.set_xlabel("sparse grid $i$")
240
+ ax.set_ylabel("sparse grid $j$")
241
+ ax.set_title(title)
242
+ format_axis(ax, False)
243
+
244
+ del sp_outxys
245
+
246
+ if not self.is_relevant:
247
+ del sp_arr, relevant_matrix
248
+ return
249
+ print("input image", self.idsca, flush=True)
250
+
251
+ # maximum number of input pixels per postage stamp (from this InImage)
252
+ # relax_coef: the actual maximum may be larger due to distortions
253
+ npixmax = int(
254
+ (
255
+ (self.blk.cfg.n2 * self.blk.cfg.dtheta * u.degree.to("arcsec"))
256
+ / (Stn.pixscale_native / Stn.arcsec)
257
+ + 1
258
+ )
259
+ ** 2
260
+ * relax_coef
261
+ ) # default: about 160
262
+
263
+ # arrays for indices (in the input image grid),
264
+ self.y_idx = np.zeros((self.blk.cfg.n1P + 2, self.blk.cfg.n1P + 2, npixmax), dtype=np.uint16)
265
+ self.x_idx = np.zeros((self.blk.cfg.n1P + 2, self.blk.cfg.n1P + 2, npixmax), dtype=np.uint16)
266
+ # positions (in the output block coordinates),
267
+ self.y_val = np.zeros((self.blk.cfg.n1P + 2, self.blk.cfg.n1P + 2, npixmax), dtype=np.float64)
268
+ self.x_val = np.zeros((self.blk.cfg.n1P + 2, self.blk.cfg.n1P + 2, npixmax), dtype=np.float64)
269
+ # and number of pixels in each postage stamp (from this InImage)
270
+ self.pix_count = np.zeros((self.blk.cfg.n1P + 2, self.blk.cfg.n1P + 2), dtype=np.uint32)
271
+
272
+ # load masks here
273
+ if self.blk.pmask is not None:
274
+ mask = self.blk.pmask[self.idsca[1] - 1]
275
+ else:
276
+ mask = np.ones((Stn.sca_nside, Stn.sca_nside), dtype=bool)
277
+
278
+ get_all_data(self) # shape : (n_inframe, Stn.sca_nside, Stn.sca_nside)
279
+
280
+ cr_mask = Mask.load_cr_mask(self)
281
+ if cr_mask is not None:
282
+ mask = np.logical_and(mask, cr_mask)
283
+ del cr_mask
284
+
285
+ # extract mask from file
286
+ mask &= Mask.load_mask_from_maskfile(self.blk.cfg, self.blk.obsdata, self.idsca)
287
+
288
+ # if there's already a mask, *REPLACE* it here
289
+ # This is important in the iterative PSF approach because we may need to change the input
290
+ # layers at various stages of the calculation.
291
+ if bool(self.blk.cfg.inlayercache):
292
+ inlayer_mask_filepath = (
293
+ self.blk.cfg.inlayercache + f"_{self.idsca[0]:08d}_{self.idsca[1]:02d}_mask.fits"
294
+ )
295
+ inlayer_mask_lockpath = inlayer_mask_filepath + ".lock"
296
+ lock = FileLock(inlayer_mask_lockpath)
297
+ if exists(inlayer_mask_filepath):
298
+ try:
299
+ with lock.acquire(timeout=300):
300
+ print("loading input mask <<", inlayer_mask_filepath)
301
+ with fits.open(inlayer_mask_filepath) as f:
302
+ mask = f[0].data > 0
303
+ except Timeout:
304
+ raise Exception("timeout while waiting for file:", inlayer_mask_filepath) from None
305
+ else:
306
+ try:
307
+ with lock.acquire(timeout=300):
308
+ print("saving input mask >>", inlayer_mask_filepath)
309
+ fits.PrimaryHDU(np.where(mask, 1, 0).astype(np.uint8)).writeto(
310
+ inlayer_mask_filepath, overwrite=True
311
+ )
312
+ except Timeout:
313
+ raise Exception("timeout while waiting for file:", inlayer_mask_filepath) from None
314
+ sys.stdout.flush()
315
+
316
+ # now loop over the regions set by the sparse grid
317
+ for j_sp in range(sp_res):
318
+ for i_sp in range(sp_res):
319
+ if not relevant_matrix[j_sp, i_sp]:
320
+ continue
321
+ left, right = sp_arr[i_sp : i_sp + 2]
322
+ bottom, top = sp_arr[j_sp : j_sp + 2]
323
+ inxys = InImage.generate_idx_grid(np.arange(left, right), np.arange(bottom, top))
324
+ outxys = self._inpix2world2outpix(inxys).T.reshape(2, top - bottom, right - left)
325
+
326
+ for j in range(top - bottom):
327
+ for i in range(right - left):
328
+ my_x, my_y = outxys[:, j, i]
329
+ if not (pix_lower < my_x < pix_upper and pix_lower < my_y < pix_upper):
330
+ continue
331
+ if not mask[bottom + j, left + i]:
332
+ continue
333
+
334
+ i_st = int((my_x - pix_lower) // self.blk.cfg.n2) # st stands for stamp
335
+ j_st = int((my_y - pix_lower) // self.blk.cfg.n2)
336
+ if not self.blk.use_instamps[j_st, i_st]:
337
+ continue
338
+
339
+ my_idx = self.pix_count[j_st, i_st]
340
+ self.y_idx[j_st, i_st, my_idx] = bottom + j
341
+ self.x_idx[j_st, i_st, my_idx] = left + i
342
+ self.y_val[j_st, i_st, my_idx] = my_y
343
+ self.x_val[j_st, i_st, my_idx] = my_x
344
+ self.pix_count[j_st, i_st] += 1
345
+
346
+ del inxys, outxys
347
+
348
+ if visualize and self.is_relevant:
349
+ with mpl.rc_context(format_axis_pars):
350
+ ax = axs[1, 1]
351
+ im = ax.imshow(self.pix_count, origin="lower", cmap="plasma")
352
+ plt.colorbar(im, ax=ax)
353
+
354
+ ax.set_xlabel("stamp index $i$")
355
+ ax.set_ylabel("stamp index $j$")
356
+ ax.set_title("pixel count")
357
+ format_axis(ax, False)
358
+
359
+ plt.show()
360
+
361
+ if verbose:
362
+ print("-->", np.sum(self.pix_count), "pixels selected from idsca", self.idsca, end="; ")
363
+ self.max_count = np.max(self.pix_count)
364
+ if verbose:
365
+ print("the most populous stamp has", self.max_count, "pixels")
366
+ del sp_arr, relevant_matrix, mask
367
+
368
+ def extract_layers(self, verbose: bool = False) -> None:
369
+ """
370
+ Extract input layers.
371
+
372
+ Returns
373
+ -------
374
+ None
375
+
376
+ """
377
+
378
+ assert self.exists_, "Error: input image and/or input psf do(es) not exist"
379
+
380
+ self.data = np.zeros(
381
+ (self.blk.cfg.n_inframe, self.blk.cfg.n1P + 2, self.blk.cfg.n1P + 2, self.max_count),
382
+ dtype=np.float32,
383
+ )
384
+
385
+ for j_st in range(self.blk.cfg.n1P + 2):
386
+ for i_st in range(self.blk.cfg.n1P + 2):
387
+ n_pix = self.pix_count[j_st, i_st]
388
+ self.data[:, j_st, i_st, :n_pix] = self.indata[
389
+ :, self.y_idx[j_st, i_st, :n_pix], self.x_idx[j_st, i_st, :n_pix]
390
+ ]
391
+
392
+ del self.indata, self.y_idx, self.x_idx
393
+ if verbose:
394
+ print(f"--> finished extracting layers for InImage {self.idsca}", "@", self.blk.timer(), "s")
395
+
396
+ def clear(self) -> None:
397
+ """
398
+ Free up memory space.
399
+
400
+ Returns
401
+ -------
402
+ None.
403
+
404
+ """
405
+
406
+ if self.is_relevant:
407
+ del self.y_val, self.x_val, self.pix_count, self.data
408
+
409
+ if hasattr(self, "inpsf_arr"):
410
+ del self.inpsf_arr
411
+ if hasattr(self, "inpsf_cube"):
412
+ del self.inpsf_cube
413
+
414
+ @staticmethod
415
+ def smooth_and_pad(inArray: np.array, tophatwidth: float = 0.0, gaussiansigma: float = 0.0) -> np.array:
416
+ """
417
+ Utility to smear a PSF with a tophat and a Gaussian.
418
+
419
+ Parameters
420
+ ----------
421
+ inArray : np.array
422
+ Input PSF array to be smeared. Shape (ny, nx)
423
+ tophatwidth : float, optional
424
+ Width of the tophat in pixels. The default is 0.0.
425
+ gaussiansigma : float, optional
426
+ Both in units of the pixels given (not native pixel).
427
+
428
+ Returns
429
+ -------
430
+ outArray : np.array
431
+ Smeared input PSF array, shape (ny+npad*2, nx+npad*2)
432
+
433
+ """
434
+
435
+ npad = int(np.ceil(tophatwidth + 6 * gaussiansigma + 1))
436
+ npad += (4 - npad) % 4 # make a multiple of 4
437
+ (ny, nx) = np.shape(inArray)
438
+ nyy = ny + npad * 2
439
+ nxx = nx + npad * 2
440
+ outArray = np.zeros((nyy, nxx))
441
+ outArray[npad:-npad, npad:-npad] = inArray
442
+ outArrayFT = np.fft.fft2(outArray)
443
+
444
+ # convolution
445
+ uy = np.linspace(0, nyy - 1, nyy) / nyy
446
+ uy = np.where(uy > 0.5, uy - 1, uy)
447
+ ux = np.linspace(0, nxx - 1, nxx) / nxx
448
+ ux = np.where(ux > 0.5, ux - 1, ux)
449
+ outArrayFT *= (
450
+ np.sinc(ux[None, :] * tophatwidth)
451
+ * np.sinc(uy[:, None] * tophatwidth)
452
+ * np.exp(-2.0 * np.pi**2 * gaussiansigma**2 * (ux[None, :] ** 2 + uy[:, None] ** 2))
453
+ )
454
+
455
+ outArray = np.real(np.fft.ifft2(outArrayFT))
456
+ return outArray
457
+
458
+ @staticmethod
459
+ def LPolyArr(PORDER, u_, v_):
460
+ """
461
+ Generates an array of the Legendre polynomials.
462
+
463
+ Parameters
464
+ ----------
465
+ PORDER : int
466
+ >=0, order in each axis
467
+ u_ : float
468
+ x-position on chip scaled to -1..+1
469
+ v_ : float
470
+ y-position on chip scaled to -1..+1
471
+
472
+ Returns
473
+ -------
474
+ arr: np.array, shape : ((PORDER+1)**2)
475
+ the array of Legendre polynomial products, shape : ((PORDER+1)**2)
476
+
477
+ Notes
478
+ -----
479
+ The returned array has length (PORDER+1)**2.
480
+ The constant (1) is first, then increasing x-order, then increasing y-order:
481
+ for n=0..PORDER { for m=0..PORDER { coef P_m(u_) P_n(v_) }}.
482
+
483
+ """
484
+
485
+ ua = np.ones(PORDER + 1)
486
+ va = np.ones(PORDER + 1)
487
+ for m in range(1, PORDER + 1):
488
+ L = legendre(m)
489
+ ua[m] = L(u_)
490
+ va[m] = L(v_)
491
+ arr = np.outer(va, ua).flatten()
492
+ return arr
493
+
494
+ @staticmethod
495
+ def psf_filename(inpsf_format, obsid):
496
+ """PSF file name broker.
497
+
498
+ Parameters
499
+ ----------
500
+ inpsf_format : str
501
+ Format for input PSFs.
502
+ obsid : int
503
+ The observation ID number.
504
+
505
+ Returns
506
+ -------
507
+ str
508
+ File name.
509
+
510
+ """
511
+
512
+ if inpsf_format == "dc2_imsim":
513
+ return f"dc2_psf_{obsid:d}.fits"
514
+ if inpsf_format in ["anlsim", "L2_2506"]:
515
+ return f"psf_polyfit_{obsid:d}.fits"
516
+
517
+ raise AssertionError("psf_filename: should not get here")
518
+
519
+ def get_psf_pos(self, psf_compute_point: np.array, use_shortrange: bool = False) -> np.array:
520
+ """
521
+ Get input PSF array at given position.
522
+
523
+ This is an interface for layer.get_all_data and psfutil.PSFGrp._build_inpsfgrp.
524
+
525
+ Parameters
526
+ ----------
527
+ psf_compute_point : np.array
528
+ Length 2 array, point to compute PSF in RA and Dec.
529
+ use_shortrange : bool, optional
530
+ If True and PSFSPLIT is set in the configuration file, then pulls only the short-range PSF G^(S).
531
+
532
+ Returns
533
+ -------
534
+ np.array
535
+ Input PSF array at given position (see smooth_and_pad for the shape).
536
+
537
+ """
538
+
539
+ # The tophat width: in use_shortrange, the psfsplit module has already included this,
540
+ # so we set it to 0 so as to not double-count this contribution.
541
+ tophatwidth_use = self.blk.cfg.inpsf_oversamp
542
+ if use_shortrange and self.blk.cfg.psfsplit:
543
+ tophatwidth_use = 0
544
+
545
+ # get the pixel location on the input image
546
+ # (moved this up since some PSF models need it)
547
+ # pixloc = self.inwcs.all_world2pix(np.array([[*psf_compute_point]]).astype(np.float64), 0)[0]
548
+ pixloc = self.inwcs.all_world2pix(psf_compute_point[0], psf_compute_point[1], 0)
549
+
550
+ if self.blk.cfg.inpsf_format == "dc2_imsim":
551
+ if not hasattr(self, "inpsf_arr"):
552
+ fname = (
553
+ self.blk.cfg.inpsf_path
554
+ + "/"
555
+ + InImage.psf_filename(self.blk.cfg.inpsf_format, self.idsca[0])
556
+ )
557
+ assert exists(fname), "Error: input psf does not exist"
558
+ with fitsio.FITS(fname) as fileh:
559
+ self.inpsf_arr = InImage.smooth_and_pad(
560
+ fileh[self.idsca[1]][:, :], tophatwidth=tophatwidth_use
561
+ )
562
+
563
+ this_psf = self.inpsf_arr
564
+
565
+ elif self.blk.cfg.inpsf_format == "anlsim" or self.blk.cfg.inpsf_format == "L2_2506":
566
+ if not hasattr(self, "inpsf_cube"):
567
+ fname = (
568
+ self.blk.cfg.inpsf_path
569
+ + "/"
570
+ + InImage.psf_filename(self.blk.cfg.inpsf_format, self.idsca[0])
571
+ )
572
+ sskip = 0
573
+ readskip = False
574
+ if use_shortrange and self.blk.cfg.psfsplit:
575
+ fname = self.blk.cfg.inlayercache + f".psf/psf_{self.idsca[0]:d}.fits"
576
+ readskip = True
577
+ assert exists(fname), "Error: input psf does not exist"
578
+ with fits.open(fname) as f:
579
+ if readskip:
580
+ sskip = int(f[0].header["GSSKIP"])
581
+ self.inpsf_cube = f[self.idsca[1] + sskip].data[:, :, :]
582
+ print(" <<", fname, sskip)
583
+
584
+ # Legendre polynomial order
585
+ lporder = int(np.round(np.sqrt(np.shape(self.inpsf_cube)[0]))) - 1
586
+ lpoly = InImage.LPolyArr(lporder, (pixloc[0] - 2043.5) / 2044.0, (pixloc[1] - 2043.5) / 2044.0)
587
+ # pixels are in C/Python convention since pixloc was set this way
588
+ if self.blk.cfg.inpsf_format == "anlsim":
589
+ this_psf = (
590
+ InImage.smooth_and_pad(
591
+ np.einsum("a,aij->ij", lpoly, self.inpsf_cube), tophatwidth=tophatwidth_use
592
+ )
593
+ / 64
594
+ )
595
+ # divide by 64=8**2 since anlsim files are in fractional intensity per s_in**2 instead of
596
+ # per (s_in/8)**2
597
+ else:
598
+ this_psf = InImage.smooth_and_pad(
599
+ np.einsum("a,aij->ij", lpoly, self.inpsf_cube), tophatwidth=tophatwidth_use
600
+ )
601
+ # L2_2506 and later are per (s_in/ovsamp)**2
602
+
603
+ else:
604
+ raise RuntimeError("Error: input psf does not exist")
605
+
606
+ # test of the astrometry, if requested
607
+ # if np.hypot(pixloc[0]-200, pixloc[1]-3000)<150:
608
+ # print(':::', pixloc, psf_compute_point); sys.stdout.flush()
609
+ return this_psf
610
+
611
+ # when distort_matrice is not required
612
+ # if dWdp_out is None: return this_psf
613
+
614
+ # get the distortion matrices d[(X,Y)perfect]/d[(X,Y)native]
615
+ # Note that rotations and magnifications are included in the distortion matrix, as well as shear
616
+ # Also the distortion is relative to the output grid, not to the tangent plane to the celestial sphere
617
+ # (although we really don't want the difference to be large ...)
618
+ # distort_matrice = np.linalg.inv(dWdp_out) \
619
+ # @ wcs.utils.local_partial_pixel_derivatives(self.inwcs, pixloc[0], pixloc[1]) \
620
+ # * self.blk.cfg.dtheta*Stn.degree/Stn.pixscale_native
621
+
622
+ # print(pixloc, self.blk.cfg.inpsf_oversamp, np.shape(this_psf), np.sum(this_psf))
623
+ # return this_psf, distort_matrice
624
+
625
+
626
+ class InStamp:
627
+ """
628
+ Data structure for input pixel positions and signals.
629
+
630
+ Parameters
631
+ ----------
632
+ blk : Block
633
+ The Block instance to which this InStamp instance is attached.
634
+ j_st : int
635
+ InStamp vertical index.
636
+ i_st : int
637
+ InStamp horizontal index.
638
+
639
+ Methods
640
+ -------
641
+ __init__
642
+ Constructor.
643
+ make_selection
644
+ Return the indices of selected input pixels.
645
+ get_inpsfgrp
646
+ Get the input PSFGrp attached to this InStamp.
647
+ clear
648
+ Free up memory space.
649
+
650
+ """
651
+
652
+ def __init__(self, blk: "Block", j_st: int, i_st: int) -> None:
653
+ self.blk = blk
654
+ self.j_st = j_st
655
+ self.i_st = i_st
656
+
657
+ # numbers of input pixels from input images and the cumulative sum
658
+ self.pix_count = np.array(
659
+ [inimage.pix_count[j_st, i_st] for inimage in blk.inimages], dtype=np.uint32
660
+ )
661
+ self.pix_cumsum = np.cumsum([0] + list(self.pix_count), dtype=np.uint32)
662
+
663
+ # input pixel positions and signals
664
+ self.y_val = np.empty((self.pix_cumsum[-1],), dtype=np.float64)
665
+ self.x_val = np.empty((self.pix_cumsum[-1],), dtype=np.float64)
666
+ self.data = np.empty((blk.cfg.n_inframe, self.pix_cumsum[-1]), dtype=np.float32)
667
+
668
+ for i_im, inimage in enumerate(blk.inimages):
669
+ self.y_val[self.pix_cumsum[i_im] : self.pix_cumsum[i_im + 1]] = inimage.y_val[
670
+ j_st, i_st, : self.pix_count[i_im]
671
+ ]
672
+ self.x_val[self.pix_cumsum[i_im] : self.pix_cumsum[i_im + 1]] = inimage.x_val[
673
+ j_st, i_st, : self.pix_count[i_im]
674
+ ]
675
+ self.data[:, self.pix_cumsum[i_im] : self.pix_cumsum[i_im + 1]] = inimage.data[
676
+ :, j_st, i_st, : self.pix_count[i_im]
677
+ ]
678
+
679
+ if j_st % 2 == 0 and i_st % 2 == 0:
680
+ # get where to compute the PSF and the camera distortion matrix
681
+ # (the center of the 2x2 group of postage stamps)
682
+ self.psf_compute_point_pix = [i_st * blk.cfg.n2 - 0.5, j_st * blk.cfg.n2 - 0.5]
683
+ self.inpsfgrp = None
684
+ self.inpsfgrp_ref = 0
685
+
686
+ def make_selection(self, pivot: tuple[float, float] = (None, None), radius: float = None) -> np.array:
687
+ """
688
+ Return the indices of selected input pixels.
689
+
690
+ This is an interface for OutStamp._process_input_stamps.
691
+
692
+ Parameters
693
+ ----------
694
+ pivot : (float or None, float or None), optional
695
+ Pivot position in the output block coordinates.
696
+ If None in one direction, select input pixels according to the other;
697
+ if None in both directions, select all input pixels.
698
+ radius : float or None, optional
699
+ Select input pixels within this radius.
700
+
701
+ Returns
702
+ -------
703
+ np.array or None
704
+ Indices of selected input pixels (if there are any).
705
+ None if selecting all input pixels.
706
+
707
+ """
708
+
709
+ if pivot == (None, None) or radius is None:
710
+ return None # select all pixels
711
+
712
+ dist_sq = np.zeros((self.pix_cumsum[-1],))
713
+ if pivot[0] is not None:
714
+ dist_sq += np.square(self.x_val - pivot[0])
715
+ if pivot[1] is not None:
716
+ dist_sq += np.square(self.y_val - pivot[1])
717
+
718
+ selection = np.array(np.where(dist_sq < radius**2)[0], dtype=np.uint32)
719
+ return selection if (selection.shape[0] < self.pix_cumsum[-1]) else None
720
+
721
+ def get_inpsfgrp(self, sim_mode: bool = False, visualize: bool = False) -> None:
722
+ """
723
+ Get the input PSFGrp attached to this InStamp.
724
+
725
+ This is an interface for psfutil.SysMatA._compute_iisubmats
726
+ and psfutil.SysMatB.get_iosubmat.
727
+
728
+ Parameters
729
+ ----------
730
+ sim_mode : bool, optional
731
+ Whether to count references without actually making inpsfgrp.
732
+ See the docstring of psfutil.SysMatA._compute_iisubmats.
733
+ visualize : bool, optional
734
+ Whether to visualize the PSF group.
735
+
736
+ Returns
737
+ -------
738
+ None
739
+
740
+ """
741
+
742
+ if sim_mode: # count references, no actual inpsfgrp involved
743
+ self.inpsfgrp_ref += 1
744
+ return
745
+
746
+ if self.inpsfgrp is None:
747
+ self.inpsfgrp = PSFGrp(in_or_out=True, inst=self, visualize=visualize)
748
+
749
+ self.inpsfgrp_ref -= 1
750
+ if self.inpsfgrp_ref > 0:
751
+ return self.inpsfgrp
752
+ else:
753
+ inpsfgrp = self.inpsfgrp
754
+ del self.inpsfgrp
755
+ self.inpsfgrp = None
756
+ return inpsfgrp
757
+
758
+ def clear(self) -> None:
759
+ """Free up memory space."""
760
+
761
+ del self.pix_count, self.pix_cumsum
762
+ del self.y_val, self.x_val, self.data
763
+
764
+
765
+ class OutStamp:
766
+ """
767
+ Driver for postage stamp coaddition.
768
+
769
+ Parameters
770
+ ----------
771
+ blk : Block
772
+ The Block instance to which this InStamp instance is attached.
773
+ j_st : int
774
+ OutStamp index, vertical direction.
775
+ i_st : int
776
+ OutStamp index, horizontal direction.
777
+ visualize : bool, optional
778
+ Whether to run visualizations.
779
+
780
+ Methods
781
+ -------
782
+ __init__
783
+ Constructor.
784
+ _process_input_stamps
785
+ Fetch and process input postage stamps.
786
+ __call__
787
+ Build system matrices and perform coaddition.
788
+ _build_system_matrices
789
+ Build system matrices and coaddition matrices.
790
+ _visualize_system_matrices
791
+ Visualize system matrices.
792
+ _visualize_coadd_matrices
793
+ Visualize coaddition matrices.
794
+ trapezoid
795
+ Apply a trapezoid filter to transition pixels (staticmethod).
796
+ _perform_coaddition
797
+ Perform the actual multiplication.
798
+ _visualize_weight_computations
799
+ Display weight computations.
800
+ _show_in_and_out_images
801
+ Display input and output images.
802
+ _study_individual_pixels
803
+ Study individual input and output pixels.
804
+ clear
805
+ Free up memory space.
806
+
807
+ """
808
+
809
+ LAKERNEL = {
810
+ "Eigen": EigenKernel,
811
+ "Cholesky": CholKernel,
812
+ "Iterative": IterKernel,
813
+ "Empirical": EmpirKernel,
814
+ }
815
+
816
+ def __init__(self, blk: "Block", j_st: int, i_st: int, visualize: bool = False) -> None:
817
+ self.blk = blk
818
+ self.j_st = j_st
819
+ self.i_st = i_st
820
+
821
+ # list of indices of overlapping and adjacent input postage stamps
822
+ # the final _s indicates plural
823
+ self.ji_st_in_s = [(j_st + dj, i_st + di) for dj in range(-1, 2) for di in range(-1, 2)]
824
+
825
+ # no-quality control option of the empirical kernel
826
+ self.no_qlt_ctrl = False
827
+ if blk.cfg.linear_algebra == "Empirical":
828
+ self.no_qlt_ctrl = blk.cfg.no_qlt_ctrl
829
+
830
+ # count references to PSF overlaps and system submatrices
831
+ if not self.no_qlt_ctrl:
832
+ for ji_st_in in self.ji_st_in_s:
833
+ blk.sysmata.get_iisubmat(ji_st_in, ji_st_in, sim_mode=True, visualize=visualize)
834
+ blk.sysmatb.get_iosubmat(ji_st_in, (j_st, i_st), sim_mode=True, visualize=visualize)
835
+
836
+ for ji_st_pair in combinations(self.ji_st_in_s, 2):
837
+ blk.sysmata.get_iisubmat(*ji_st_pair, sim_mode=True, visualize=visualize)
838
+
839
+ # limit y and x positions of this output postage stamp, all integers
840
+ # not including the transition pixels (of which the number
841
+ # of columns or rows on each side is set by fade_kernel)
842
+ self.bottom = (j_st - 1) * blk.cfg.n2
843
+ self.top = self.bottom + blk.cfg.n2 - 1
844
+ self.left = (i_st - 1) * blk.cfg.n2
845
+ self.right = self.left + blk.cfg.n2 - 1
846
+
847
+ fade_kernel = blk.cfg.fade_kernel # shortcut
848
+ # output pixel positions, all integers
849
+ self.yx_val = np.mgrid[
850
+ self.bottom - fade_kernel : self.top + fade_kernel + 1,
851
+ self.left - fade_kernel : self.right + fade_kernel + 1,
852
+ ]
853
+
854
+ self._process_input_stamps(visualize=visualize)
855
+
856
+ def _process_input_stamps(self, visualize: bool = False) -> None:
857
+ """
858
+ Fetch and process input postage stamps.
859
+
860
+ Parameters
861
+ ----------
862
+ visualize : bool, optional
863
+ Whether to visualize the process.
864
+
865
+ Returns
866
+ -------
867
+ None
868
+
869
+ Notes
870
+ -----
871
+
872
+ This method selects input pixels to form a region like this:
873
+ +-----+-----+-----+
874
+ | **|*****|** |
875
+ | ****|*****|**** |
876
+ +-----+-----+-----+
877
+ |*****|*****|*****|
878
+ |*****|*****|*****|
879
+ +-----+-----+-----+
880
+ | ****|*****|**** |
881
+ | **|*****|** |
882
+ +-----+-----+-----+
883
+ where the central postage stamp is the OutStamp we are coadding.
884
+
885
+ """
886
+
887
+ # fetch instamps and select input pixels
888
+ self.instamps = [None for _ in range(9)]
889
+ self.selections = [None for _ in range(9)]
890
+ self.inpix_count = np.zeros((9,), dtype=np.uint32)
891
+
892
+ # acceptance radius in units of output pixels
893
+ rpix_search = (self.blk.cfg.instamp_pad / Stn.arcsec) / (self.blk.cfg.dtheta * u.degree.to("arcsec"))
894
+
895
+ # now select input pixels
896
+ for idx, ji_st_in in enumerate(self.ji_st_in_s):
897
+ self.instamps[idx] = self.blk.instamps[ji_st_in[0]][ji_st_in[1]]
898
+
899
+ x_pivot = [self.left - 0.5, None, self.right + 0.5][ji_st_in[1] - self.i_st + 1]
900
+ y_pivot = [self.bottom - 0.5, None, self.top + 0.5][ji_st_in[0] - self.j_st + 1]
901
+ self.selections[idx] = self.instamps[idx].make_selection((x_pivot, y_pivot), rpix_search)
902
+ if self.selections[idx] is None:
903
+ self.inpix_count[idx] = self.instamps[idx].pix_cumsum[-1]
904
+ else:
905
+ self.inpix_count[idx] = self.selections[idx].shape[0]
906
+
907
+ self.inpix_cumsum = np.cumsum([0] + list(self.inpix_count), dtype=np.uint32)
908
+
909
+ if visualize:
910
+ with mpl.rc_context(format_axis_pars):
911
+ fig, ax = plt.subplots(figsize=(4.8, 4.8))
912
+
913
+ for idx, instamp, selection in zip(range(9), self.instamps, self.selections, strict=False):
914
+ if selection is None:
915
+ plt.scatter(instamp.x_val, instamp.y_val, s=2, c=f"C{idx}")
916
+ ax.scatter(instamp.x_val[selection], instamp.y_val[selection], s=2, c=f"C{idx}")
917
+ ax.axis("equal")
918
+
919
+ ax.set_xlabel("output grid $i$")
920
+ ax.set_ylabel("output grid $j$")
921
+ format_axis(ax)
922
+ plt.show()
923
+
924
+ # read input pixel positions and signals
925
+ iny_val = []
926
+ inx_val = []
927
+ indata = []
928
+
929
+ for inst, selection in zip(self.instamps, self.selections, strict=False):
930
+ if selection is None:
931
+ iny_val.append(inst.y_val)
932
+ inx_val.append(inst.x_val)
933
+ indata.append(inst.data)
934
+ else:
935
+ iny_val.append(inst.y_val[selection])
936
+ inx_val.append(inst.x_val[selection])
937
+ indata.append(inst.data[:, selection])
938
+
939
+ self.iny_val = np.hstack(iny_val)
940
+ iny_val.clear()
941
+ del iny_val
942
+ self.inx_val = np.hstack(inx_val)
943
+ inx_val.clear()
944
+ del inx_val
945
+ self.indata = np.hstack(indata)
946
+ indata.clear()
947
+ del indata
948
+
949
+ def __call__(self, visualize: bool = False, save_abc: bool = False, save_t: bool = False) -> None:
950
+ """
951
+ Build system matrices and perform coaddition.
952
+
953
+ Parameters
954
+ ----------
955
+ visualize : bool, optional
956
+ Whether to visualize the process.
957
+ save_abc : bool, optional
958
+ Whether to save system matrices.
959
+ save_t : bool, optional
960
+ Whether to save coaddition matrices.
961
+
962
+ Returns
963
+ -------
964
+ None
965
+
966
+ """
967
+
968
+ self._build_system_matrices(visualize, save_abc)
969
+ self._perform_coaddition(visualize, save_t)
970
+ print()
971
+
972
+ def _build_system_matrices(self, visualize: bool = False, save_abc: bool = False) -> None:
973
+ """
974
+ Build system matrices and coaddition matrices.
975
+
976
+ Parameters
977
+ ----------
978
+ visualize : bool, optional
979
+ Whether to visualize the process.
980
+ save_abc : bool, optional
981
+ Whether to save system matrices.
982
+
983
+ Returns
984
+ -------
985
+ None
986
+
987
+ """
988
+
989
+ # no-quality control option of the empirical kernel
990
+ if self.no_qlt_ctrl:
991
+ lakernel = OutStamp.LAKERNEL[self.blk.cfg.linear_algebra](self)
992
+ lakernel()
993
+ del lakernel
994
+ # this produces: self.T, (n_out, n_outpix, n_inpix)
995
+ return
996
+
997
+ # the A-matrix first
998
+ self.sysmata = np.zeros((self.inpix_cumsum[-1], self.inpix_cumsum[-1])) # dtype=np.float64
999
+ use_virmem = bool(self.blk.cfg.tempfile)
1000
+
1001
+ for idx, ji_st_in, selection in zip(range(9), self.ji_st_in_s, self.selections, strict=False):
1002
+ iisubmat = self.blk.sysmata.get_iisubmat(
1003
+ ji_st_in, ji_st_in, ji_st_out=(self.j_st, self.i_st) if use_virmem else None
1004
+ )
1005
+ if selection is not None:
1006
+ iisubmat = iisubmat[np.ix_(selection, selection)]
1007
+
1008
+ self.sysmata[
1009
+ self.inpix_cumsum[idx] : self.inpix_cumsum[idx + 1],
1010
+ self.inpix_cumsum[idx] : self.inpix_cumsum[idx + 1],
1011
+ ] = iisubmat
1012
+
1013
+ for idx_s, ji_st_pair, selections_ in zip(
1014
+ combinations(range(9), 2),
1015
+ combinations(self.ji_st_in_s, 2),
1016
+ combinations(self.selections, 2),
1017
+ strict=False,
1018
+ ):
1019
+ iisubmat = self.blk.sysmata.get_iisubmat(
1020
+ *ji_st_pair, ji_st_out=(self.j_st, self.i_st) if use_virmem else None
1021
+ )
1022
+ if selections_[0] is not None:
1023
+ if selections_[1] is not None:
1024
+ iisubmat = iisubmat[np.ix_(selections_[0], selections_[1])]
1025
+ else:
1026
+ iisubmat = iisubmat[selections_[0], :]
1027
+ else:
1028
+ if selections_[1] is not None:
1029
+ iisubmat = iisubmat[:, selections_[1]]
1030
+
1031
+ self.sysmata[
1032
+ self.inpix_cumsum[idx_s[0]] : self.inpix_cumsum[idx_s[0] + 1],
1033
+ self.inpix_cumsum[idx_s[1]] : self.inpix_cumsum[idx_s[1] + 1],
1034
+ ] = iisubmat
1035
+ self.sysmata[
1036
+ self.inpix_cumsum[idx_s[1]] : self.inpix_cumsum[idx_s[1] + 1],
1037
+ self.inpix_cumsum[idx_s[0]] : self.inpix_cumsum[idx_s[0] + 1],
1038
+ ] = iisubmat.T
1039
+ del iisubmat
1040
+
1041
+ # force exact symmetry
1042
+ # A = (A+A.T)/2.
1043
+
1044
+ # now the mBhalf matrix
1045
+ self.mhalfb = np.zeros(
1046
+ (self.blk.outpsfgrp.n_psf, self.blk.cfg.n2f**2, self.inpix_cumsum[-1])
1047
+ ) # dtype=np.float64
1048
+
1049
+ for idx, ji_st_in in zip(range(9), self.ji_st_in_s, strict=False):
1050
+ self.mhalfb[
1051
+ :, :, self.inpix_cumsum[idx] : self.inpix_cumsum[idx + 1]
1052
+ ] = self.blk.sysmatb.get_iosubmat(ji_st_in, (self.j_st, self.i_st), visualize=visualize)
1053
+
1054
+ # and C
1055
+ self.outovlc = self.blk.outpsfovl.outovlc
1056
+
1057
+ if visualize:
1058
+ print()
1059
+ self._visualize_system_matrices()
1060
+
1061
+ lakernel = OutStamp.LAKERNEL[self.blk.cfg.linear_algebra](self)
1062
+ lakernel()
1063
+ del lakernel
1064
+ # this produces: self.T, self.UC, self.Sigma, self.kappa
1065
+ # T: (n_out, n_outpix, n_inpix); others: (n_out, n2f, n2f)
1066
+
1067
+ # now visualize (if requested) and save
1068
+ if visualize:
1069
+ print()
1070
+ self._visualize_coadd_matrices()
1071
+ if not save_abc:
1072
+ del self.sysmata, self.mhalfb, self.outovlc
1073
+
1074
+ if self.blk.cfg.linear_algebra == "Iterative":
1075
+ # these could be negative as the iterative kernel is not exact
1076
+ self.UC = np.maximum(self.UC, 1e-32)
1077
+ self.Sigma = np.maximum(self.Sigma, 1e-32)
1078
+
1079
+ print(" n input pix =", self.T.shape[-1])
1080
+ sumstats = " sqUC,sqSig %iles |"
1081
+ for i in [50, 90, 98, 99]:
1082
+ sumstats += (
1083
+ f" {i:2d}% {np.percentile(np.sqrt(self.UC), i):8.2E} "
1084
+ f"{np.percentile(np.sqrt(self.Sigma), i):8.2E} |"
1085
+ )
1086
+ print(sumstats, flush=True)
1087
+
1088
+ if self.blk.cfg.fade_kernel > 0:
1089
+ fade_kernel = self.blk.cfg.fade_kernel # shortcut
1090
+ OutStamp.trapezoid(self.kappa, fade_kernel)
1091
+ OutStamp.trapezoid(self.Sigma, fade_kernel)
1092
+ OutStamp.trapezoid(self.UC, fade_kernel)
1093
+
1094
+ def _visualize_system_matrices(self) -> None:
1095
+ """Visualize system matrices."""
1096
+
1097
+ with mpl.rc_context(format_axis_pars):
1098
+ print("OutStamp._visualize_system_matrices")
1099
+
1100
+ # the A-matrix first
1101
+ print(f"{self.sysmata.shape=}") # (n_inpix, n_inpix)
1102
+ print(f"{np.all(self.sysmata == self.sysmata.T)=}")
1103
+
1104
+ fig, ax = plt.subplots(figsize=(12.8, 9.6))
1105
+ vmin = self.sysmata.max() / (self.mhalfb.max() / self.mhalfb.min()) ** 2
1106
+ im = ax.imshow(np.log10(np.clip(self.sysmata, a_min=vmin, a_max=None)), vmin=np.log10(vmin))
1107
+ plt.colorbar(im, ax=ax)
1108
+
1109
+ for xy in self.inpix_cumsum[1:-1]:
1110
+ ax.axvline(xy, c="r", ls="--", lw=1.5)
1111
+ ax.axhline(xy, c="r", ls="--", lw=1.5)
1112
+
1113
+ ax.set_xlabel("input pixel $i$")
1114
+ ax.set_ylabel("input pixel $j$")
1115
+ ax.set_title(r"$A$ matrix: $\log_{10} (A_{ij})$")
1116
+ format_axis(ax, False)
1117
+ plt.show()
1118
+
1119
+ # now the mBhalf matrix
1120
+ print(f"{self.mhalfb.shape=}") # (n_out, n_outpix, n_inpix)
1121
+ n_out, n_outpix, n_inpix = self.mhalfb.shape
1122
+ height = 9.6 / n_inpix * n_outpix
1123
+
1124
+ for mhalfb_ in self.mhalfb:
1125
+ fig, ax = plt.subplots(figsize=(12.8, height))
1126
+ im = ax.imshow(np.log10(mhalfb_))
1127
+ plt.colorbar(im, ax=ax)
1128
+
1129
+ for x in self.inpix_cumsum[1:-1]:
1130
+ ax.axvline(x, c="r", ls="--", lw=1.5)
1131
+
1132
+ ax.set_xlabel("input pixel $i$")
1133
+ ax.set_ylabel(r"output pixel $\alpha$")
1134
+ ax.set_title(r"$B$ matrix: $\log_{10} (-B_{\alpha i}/2)$")
1135
+ format_axis(ax, False)
1136
+ plt.show()
1137
+
1138
+ # and C
1139
+ print(f"{self.outovlc.shape=}") # (n_out,)
1140
+
1141
+ def _visualize_coadd_matrices(self) -> None:
1142
+ """Visualize coaddition matrices."""
1143
+ with mpl.rc_context(format_axis_pars):
1144
+ print("OutStamp._visualize_coadd_matrices")
1145
+ fk = self.blk.cfg.fade_kernel # shortcut
1146
+
1147
+ for j_out, T_ in enumerate(self.T):
1148
+ print(f"output PSF: {j_out}")
1149
+
1150
+ fig, axs = plt.subplots(1, 3, figsize=(14.4, 3.6))
1151
+ for ax, map_, title in zip(
1152
+ axs,
1153
+ [self.UC, self.Sigma, self.kappa],
1154
+ [
1155
+ r"PSF leakage: $\log_{10} (U/C)$",
1156
+ r"Noise amplification: $\log_{10} \Sigma$",
1157
+ r"Lagrange multiplier: $\log_{10} \kappa$",
1158
+ ],
1159
+ strict=False,
1160
+ ):
1161
+ im = ax.imshow(
1162
+ np.log10(map_[j_out]),
1163
+ origin="lower",
1164
+ extent=[self.left - fk, self.right + fk, self.bottom - fk, self.top + fk],
1165
+ )
1166
+ plt.colorbar(im, ax=ax)
1167
+
1168
+ ax.set_title(title)
1169
+ ax.set_xlabel("output grid $i$")
1170
+ ax.set_ylabel("output grid $j$")
1171
+ format_axis(ax, False)
1172
+ plt.show()
1173
+
1174
+ vmin, vmax = np.percentile(T_.ravel(), [1, 99])
1175
+ n_out, n_outpix, n_inpix = self.mhalfb.shape
1176
+ height = 9.6 / n_inpix * n_outpix
1177
+
1178
+ fig, ax = plt.subplots(figsize=(12.8, height))
1179
+ im = ax.imshow(T_, vmin=vmin, vmax=vmax)
1180
+ plt.colorbar(im, ax=ax)
1181
+
1182
+ for x in self.inpix_cumsum[1:-1]:
1183
+ ax.axvline(x, c="r", ls="--", lw=1.5)
1184
+
1185
+ ax.set_xlabel("input pixel $i$")
1186
+ ax.set_ylabel(r"output pixel $\alpha$")
1187
+ ax.set_title(r"$T$ matrix: $T_{\alpha i}$")
1188
+ format_axis(ax, False)
1189
+ plt.show()
1190
+
1191
+ @staticmethod
1192
+ def trapezoid(
1193
+ arr: np.array,
1194
+ fade_kernel: int,
1195
+ recover_mode: bool = False,
1196
+ pad_widths: tuple[int, int, int, int] = (0, 0, 0, 0),
1197
+ do_sides: str = "BTLR",
1198
+ use_trunc_sinc: bool = True,
1199
+ ) -> None:
1200
+ """
1201
+ Apply a trapezoid filter of width 2*fade_kernel on each side.
1202
+
1203
+ Parameters
1204
+ ----------
1205
+ arr : np.array
1206
+ The array to apply the trapezoid filter. Shape (..., ny, nx).
1207
+ fade_kernel : int
1208
+ Half the width of the trapezoid filter.
1209
+ recover_mode : bool, optional
1210
+ Whether to recover faded boundaries.
1211
+ pad_widths : (int, int, int, int), optional
1212
+ Padding width on each side (order: bottom, top, left, right).
1213
+ do_sides : str, optional
1214
+ Which sides to apply the trapezoid filter.
1215
+ use_trunc_sinc : bool, optional
1216
+ Whether to use the truncated sinc function.
1217
+
1218
+ Returns
1219
+ -------
1220
+ None
1221
+
1222
+ """
1223
+
1224
+ fk2 = fade_kernel * 2
1225
+ if not fk2 > 0:
1226
+ return
1227
+
1228
+ ny, nx = arr.shape[-2:]
1229
+ # assert ny > fk2 and nx > fk2, 'Fatal error in OutStamp.trapezoid: ' \
1230
+ # f'insufficient patch size, {ny= } {nx= } {fade_kernel= }'
1231
+
1232
+ pb, pt, pl, pr = pad_widths
1233
+ # assert ny > pb and ny > pt, 'Fatal error in OutStamp.trapezoid: ' \
1234
+ # f'insufficient patch size, {ny= } {pb= } {pt= }'
1235
+ # assert nx > pl and nx > pr, 'Fatal error in OutStamp.trapezoid: ' \
1236
+ # f'insufficient patch size, {nx= } {pl= } {pr= }'
1237
+ it, ir = ny - pt - 1, nx - pr - 1 # starting indices on these two sides
1238
+
1239
+ s = np.arange(1, fk2 + 1, dtype=np.float64) / (fk2 + 1)
1240
+ if use_trunc_sinc:
1241
+ s -= np.sin(2 * np.pi * s) / (2 * np.pi)
1242
+ sT = s[None, :].T
1243
+
1244
+ if not recover_mode:
1245
+ if "B" in do_sides:
1246
+ arr[..., pb : pb + fk2, :] *= sT
1247
+ if "T" in do_sides:
1248
+ arr[..., it : it - fk2 : -1, :] *= sT
1249
+ if "L" in do_sides:
1250
+ arr[..., :, pl : pl + fk2] *= s
1251
+ if "R" in do_sides:
1252
+ arr[..., :, ir : ir - fk2 : -1] *= s
1253
+
1254
+ else: # recover block boundaries
1255
+ if "B" in do_sides:
1256
+ arr[..., pb : pb + fk2, :] /= sT
1257
+ if "T" in do_sides:
1258
+ arr[..., it : it - fk2 : -1, :] /= sT
1259
+ if "L" in do_sides:
1260
+ arr[..., :, pl : pl + fk2] /= s
1261
+ if "R" in do_sides:
1262
+ arr[..., :, ir : ir - fk2 : -1] /= s
1263
+
1264
+ def _perform_coaddition(
1265
+ self, visualize: bool = False, save_t: bool = False, use_trunc_sinc: bool = True
1266
+ ) -> None:
1267
+ """
1268
+ Perform the actual multiplication.
1269
+
1270
+ Parameters
1271
+ ----------
1272
+ visualize : bool, optional
1273
+ Whether to visualize the process.
1274
+ save_t : bool, optional
1275
+ Whether to save coaddition matrices.
1276
+ use_trunc_sinc : bool, optional
1277
+ Argument for coadd_utils.trapezoid.
1278
+
1279
+ Returns
1280
+ -------
1281
+ None
1282
+
1283
+ """
1284
+
1285
+ # shortcuts
1286
+ n_out = self.blk.outpsfgrp.n_psf
1287
+ n2f = self.blk.cfg.n2f
1288
+ fade_kernel = self.blk.cfg.fade_kernel
1289
+
1290
+ # multiplication by the trapezoid filter with transition width fade_kernel
1291
+ if fade_kernel > 0:
1292
+ # self.T: (n_out, n_outpix, n_inpix)
1293
+ T_view = np.moveaxis(self.T, 1, -1).reshape((n_out, self.inpix_cumsum[-1], n2f, n2f))
1294
+ OutStamp.trapezoid(T_view, fade_kernel)
1295
+
1296
+ # weight computations
1297
+ Tsum_image = np.zeros(self.T.shape[:2] + (self.blk.n_inimage,)) # (n_out, n_outpix, n_inimage)
1298
+
1299
+ for j_st, inst, selection in zip(range(9), self.instamps, self.selections, strict=False):
1300
+ if selection is None:
1301
+ my_cumsum = inst.pix_cumsum.copy()
1302
+ else:
1303
+ my_cumsum = np.searchsorted(selection, inst.pix_cumsum)
1304
+ my_cumsum += self.inpix_cumsum[j_st]
1305
+
1306
+ for i_im in range(self.blk.n_inimage):
1307
+ Tsum_image[:, :, i_im] += np.sum(self.T[:, :, my_cumsum[i_im] : my_cumsum[i_im + 1]], axis=2)
1308
+
1309
+ self.Tsum_stamp = np.sum(Tsum_image, axis=1) / self.blk.cfg.n2**2 # (n_out, n_inimage)
1310
+ self.Tsum_inpix = np.sum(Tsum_image, axis=2).reshape((n_out, n2f, n2f))
1311
+ Tsum_norm = Tsum_image / np.abs(Tsum_image).sum(axis=2)[:, :, None]
1312
+ self.Neff = 1.0 / np.sum(np.square(Tsum_norm), axis=2).reshape((n_out, n2f, n2f))
1313
+ if fade_kernel > 0:
1314
+ OutStamp.trapezoid(self.Neff, fade_kernel)
1315
+
1316
+ del Tsum_image, Tsum_norm
1317
+ if visualize:
1318
+ print()
1319
+ self._visualize_weight_computations()
1320
+
1321
+ # the actual multiplication
1322
+ self.outimage = np.einsum("oaj,ij->oia", self.T, self.indata).reshape(
1323
+ (n_out, self.blk.cfg.n_inframe, n2f, n2f)
1324
+ )
1325
+
1326
+ if visualize:
1327
+ print()
1328
+ self._show_in_and_out_images()
1329
+ print()
1330
+ self._study_individual_pixels()
1331
+ del self.iny_val, self.inx_val, self.indata
1332
+ if not save_t:
1333
+ del self.T
1334
+
1335
+ def _visualize_weight_computations(self) -> None:
1336
+ """Display weight computations."""
1337
+ with mpl.rc_context(format_axis_pars):
1338
+ print("OutStamp._visualize_weight_computations")
1339
+ fk = self.blk.cfg.fade_kernel # shortcut
1340
+
1341
+ for j_out in range(self.blk.outpsfgrp.n_psf):
1342
+ print(f"output PSF: {j_out}")
1343
+
1344
+ fig, axs = plt.subplots(1, 3, figsize=(14.4, 3.6))
1345
+
1346
+ ax = axs[0]
1347
+ ax.barh(
1348
+ [f"${inimage.idsca}$" for inimage in self.blk.inimages],
1349
+ self.Tsum_stamp[0],
1350
+ color=[f"C{i}" for i in range(self.blk.n_inimage)],
1351
+ )
1352
+
1353
+ ax.set_title("Total contribution")
1354
+ ax.set_xlabel(r"$\sum {}_\alpha \sum {}_{i \in \bar{i}} T_{\alpha i}$")
1355
+ ax.set_ylabel("input image")
1356
+ format_axis(ax, False)
1357
+
1358
+ for ax, map_, title in zip(
1359
+ axs[1:],
1360
+ [self.Tsum_inpix, self.Neff],
1361
+ [r"Total weight: $T_{\rm tot}$", r"Effective coverage: $\bar{n}_{\rm eff}$"],
1362
+ strict=False,
1363
+ ):
1364
+ im = ax.imshow(
1365
+ map_[j_out],
1366
+ origin="lower",
1367
+ extent=[self.left - fk, self.right + fk, self.bottom - fk, self.top + fk],
1368
+ )
1369
+ plt.colorbar(im, ax=ax)
1370
+
1371
+ ax.set_title(title)
1372
+ ax.set_xlabel("output grid $i$")
1373
+ ax.set_ylabel("output grid $j$")
1374
+ format_axis(ax, False)
1375
+ plt.show()
1376
+
1377
+ def _show_in_and_out_images(self) -> None:
1378
+ """Display input and output images."""
1379
+ with mpl.rc_context(format_axis_pars):
1380
+ print("OutStamp._show_in_and_out_images")
1381
+ fk = self.blk.cfg.fade_kernel # shortcut
1382
+
1383
+ for j_in in range(self.blk.cfg.n_inframe):
1384
+ n_out = self.blk.outpsfgrp.n_psf
1385
+ fig, axs = plt.subplots(1, 1 + n_out, figsize=(4.8 * (1 + n_out), 3.6))
1386
+
1387
+ ax = axs[0]
1388
+ im = ax.scatter(self.inx_val, self.iny_val, c=self.indata[j_in], cmap="viridis", s=5)
1389
+ plt.colorbar(im, ax=ax)
1390
+ for x in [self.left - 0.5, self.right + 0.5]:
1391
+ ax.axvline(x, c="r", ls="--")
1392
+ for y in [self.bottom - 0.5, self.top + 0.5]:
1393
+ ax.axhline(y, c="r", ls="--")
1394
+
1395
+ ax.axis("equal")
1396
+ ax.set_xlabel("output grid $i$")
1397
+ ax.set_ylabel("output grid $j$")
1398
+ ax.set_title("layer: " + ("SCI" if j_in == 0 else self.blk.cfg.extrainput[j_in]))
1399
+ format_axis(ax)
1400
+
1401
+ for j_out in range(n_out):
1402
+ ax = axs[1 + j_out]
1403
+ im = ax.imshow(
1404
+ self.outimage[j_out, j_in],
1405
+ origin="lower",
1406
+ extent=[self.left - fk, self.right + fk, self.bottom - fk, self.top + fk],
1407
+ )
1408
+ plt.colorbar(im, ax=ax)
1409
+
1410
+ ax.set_xlabel("output grid $i$")
1411
+ ax.set_ylabel("output grid $j$")
1412
+ ax.set_title(f"output PSF: {j_out}")
1413
+ format_axis(ax, False)
1414
+
1415
+ plt.show()
1416
+
1417
+ def _study_individual_pixels(self) -> None:
1418
+ """Study individual input and output pixels."""
1419
+ with mpl.rc_context(format_axis_pars):
1420
+ print("OutStamp._study_individual_pixels")
1421
+
1422
+ accrad = np.arange(15, 140, 5) # acceptance radius in units of output pixels
1423
+ closest_inpix = [] # indices of input pixels closest to the corners and the center
1424
+
1425
+ fk = self.blk.cfg.fade_kernel
1426
+ fk2 = fk * 2
1427
+ n2f = self.blk.cfg.n2f # shortcuts
1428
+ for j_out, i_out in [
1429
+ (fk2, fk2),
1430
+ (fk2, n2f - 1 - fk2),
1431
+ (n2f - 1 - fk2, fk2),
1432
+ (n2f - 1 - fk2, n2f - 1 - fk2),
1433
+ ((n2f - 1) // 2, (n2f - 1) // 2),
1434
+ ]:
1435
+ out_idx = j_out * self.blk.cfg.n2f + i_out
1436
+ T_elems = self.T[0, out_idx, :]
1437
+
1438
+ plt.figure(figsize=(10.8, 3.6))
1439
+ ax0 = plt.subplot(1, 2, 1)
1440
+ im = ax0.scatter(self.inx_val, self.iny_val, c=T_elems, cmap="viridis", s=5)
1441
+ plt.colorbar(im, ax=ax0)
1442
+
1443
+ ax0.axis("equal")
1444
+ ax0.set_xlabel("output grid $i$")
1445
+ ax0.set_ylabel("output grid $j$")
1446
+ ax0.set_title(r"$T_{\alpha i}$")
1447
+ format_axis(ax0)
1448
+
1449
+ # print(j_out-fk + self.bottom, i_out-fk + self.left)
1450
+ dist = np.sqrt(
1451
+ np.square(j_out - fk + self.bottom - self.iny_val)
1452
+ + np.square(i_out - fk + self.left - self.inx_val)
1453
+ )
1454
+ closest_inpix.append(np.argmin(dist))
1455
+
1456
+ ax1 = plt.subplot(2, 2, 2)
1457
+ ax1.scatter(dist, T_elems, c=T_elems, cmap="viridis", s=5)
1458
+ ax1.axhline(0, c="b", ls="--")
1459
+ ax1.axvline(self.blk.cfg.n2, c="r", ls="--")
1460
+
1461
+ ax1.xaxis.set_ticklabels([])
1462
+ ax1.set_ylabel(r"$T_{\alpha i}$")
1463
+ format_axis(ax1)
1464
+
1465
+ signal = np.empty_like(accrad, dtype=np.float64)
1466
+ for i in range(accrad.shape[0]):
1467
+ T_arr = np.where(dist <= accrad[i], T_elems, 0.0)
1468
+ signal[i] = T_arr @ self.indata[0]
1469
+
1470
+ ax2 = plt.subplot(2, 2, 4)
1471
+ ax2.plot(accrad, signal, "go-")
1472
+ ax2.axvline(self.blk.cfg.n2, c="r", ls="--")
1473
+ ax2.axhline(self.outimage[0, 0].ravel()[out_idx], c="b", ls="--")
1474
+
1475
+ ax2.set_xlim(ax1.get_xlim())
1476
+ ax2.set_xlabel("acceptance radius")
1477
+ ax2.set_ylabel("signal")
1478
+ format_axis(ax2)
1479
+
1480
+ plt.show()
1481
+
1482
+ for in_idx in closest_inpix:
1483
+ print(f"{(self.inx_val[in_idx], self.iny_val[in_idx])=}")
1484
+ fig, ax = plt.subplots(figsize=(4.2, 4.2))
1485
+ im = ax.imshow(
1486
+ self.T[0, :, in_idx].reshape(n2f, n2f),
1487
+ origin="lower",
1488
+ extent=[self.left - fk, self.right + fk, self.bottom - fk, self.top + fk],
1489
+ )
1490
+ plt.colorbar(im, ax=ax)
1491
+ ax.scatter(self.inx_val[in_idx] - self.left, self.iny_val[in_idx] - self.bottom, c="r", s=2)
1492
+
1493
+ ax.set_xlabel("output grid $i$")
1494
+ ax.set_ylabel("output grid $j$")
1495
+ ax.set_title(r"$T_{\alpha i}$")
1496
+ format_axis(ax, False)
1497
+
1498
+ plt.show()
1499
+
1500
+ def clear(self) -> None:
1501
+ """Free up memory space."""
1502
+
1503
+ del self.inpix_count, self.inpix_cumsum
1504
+ self.selections.clear()
1505
+ del self.selections
1506
+
1507
+ if hasattr(self, "sysmata"):
1508
+ del self.sysmata, self.mhalfb, self.outovlc
1509
+ if hasattr(self, "T"):
1510
+ del self.T
1511
+
1512
+ del self.kappa, self.Sigma, self.UC
1513
+ del self.Tsum_stamp, self.Tsum_inpix, self.Neff
1514
+ del self.yx_val, self.outimage
1515
+
1516
+
1517
+ class Block:
1518
+ """
1519
+ Driver for block coaddition.
1520
+
1521
+ Parameters
1522
+ ----------
1523
+ cfg : Config, optional
1524
+ Configuration for this Block.
1525
+ this_sub : int, optional
1526
+ Number determining the location of this Block in the mosaic.
1527
+ run_coadd : bool, optional
1528
+ Whether to coadd this block.
1529
+ Turn this off if you want to perform the procedure manually.
1530
+
1531
+ Methods
1532
+ -------
1533
+ __init__
1534
+ Constructor.
1535
+ __call__
1536
+ Coadd this block.
1537
+ parse_config
1538
+ Parse the configuration.
1539
+ _get_obs_cover
1540
+ Get observations relevant to this Block.
1541
+ _build_use_instamps
1542
+ Build the Boolean array use_instamps.
1543
+ _handle_postage_pad
1544
+ Handle the postage padding.
1545
+ process_input_images
1546
+ Process input images.
1547
+ build_input_stamps
1548
+ Build input stamps from input images.
1549
+ _output_stamp_wrapper
1550
+ Wrapper for output stamp coaddition.
1551
+ coadd_output_stamps
1552
+ Coadd output stamps using input stamps.
1553
+ compress_map
1554
+ Compress float32 map to (u)int16 to save storage.
1555
+ build_output_file
1556
+ Build the output FITS file.
1557
+ clear_all
1558
+ Free up all memory spaces.
1559
+
1560
+ """
1561
+
1562
+ def __init__(self, cfg: Config = None, this_sub: int = 0, run_coadd: bool = True) -> None:
1563
+ self.timer = Timer()
1564
+ cfg()
1565
+ self.cfg = cfg
1566
+ if cfg is None:
1567
+ self.cfg = Config() # use the default config
1568
+
1569
+ if hasattr(cfg, "psf_interp") and cfg.psf_interp.upper() == "G4460":
1570
+ print("Setting PSF interpolation to use G4460.")
1571
+ PSFInterpolator.set_G4460()
1572
+ PSFGrp.setup(
1573
+ npixpsf=cfg.npixpsf, oversamp=cfg.inpsf_oversamp, dtheta=cfg.dtheta, psfsplit=bool(cfg.psfsplit)
1574
+ )
1575
+ PSFOvl.setup(flat_penalty=cfg.flat_penalty)
1576
+ self.this_sub = this_sub
1577
+ if run_coadd:
1578
+ self()
1579
+
1580
+ def __call__(self) -> None:
1581
+ """Coadd this block."""
1582
+
1583
+ self.parse_config()
1584
+ # this produces: obsdata, outwcs, outpsfgrp, outpsfovl
1585
+ self.process_input_images()
1586
+ # this produces: obslist, inimages (1D list), n_inimage
1587
+ self.build_input_stamps()
1588
+ # this produces: instamps (2D list)
1589
+
1590
+ self.coadd_output_stamps(sim_mode=True)
1591
+ # this produces: sysmata (object), sysmatb (object), outstamps (2D list)
1592
+ self.coadd_output_stamps(sim_mode=False)
1593
+ # this produces: out_map, T_weightmap, and additional output maps
1594
+
1595
+ self.build_output_file(is_final=True)
1596
+ self.clear_all()
1597
+ print(f"finished at t = {self.timer():.2f} s")
1598
+
1599
+ def parse_config(self) -> None:
1600
+ """Parse the configuration."""
1601
+
1602
+ print("General input information:")
1603
+ print("number of input frames = ", self.cfg.n_inframe, "type =", self.cfg.extrainput)
1604
+ # search radius for input pixels
1605
+ rpix_search_ = self.cfg.instamp_pad / Stn.arcsec
1606
+ dtheta_ = self.cfg.dtheta * u.degree.to("arcsec")
1607
+ print(
1608
+ f"acceptance radius --> {rpix_search_:.6f} arcsec or {rpix_search_ / dtheta_:.6f} output pixels"
1609
+ )
1610
+ print()
1611
+
1612
+ # Get observation table
1613
+ assert self.cfg.obsfile is not None, "Error: no obsfile found"
1614
+ print(f"Getting observations from {self.cfg.obsfile:s}")
1615
+ with fits.open(self.cfg.obsfile) as myf:
1616
+ # if the filter column is a string, replace with the number
1617
+ if myf[1].columns["filter"].format[-1] == "A":
1618
+ print("converting filter from names to integers:", myf[1].data["filter"])
1619
+ n_obs_tot = len(myf[1].data.field(0))
1620
+ mytable = Table(myf[1].data)
1621
+ fdata = np.zeros(n_obs_tot, dtype=np.uint16)
1622
+ for j in range(len(Stn.RomanFilters)):
1623
+ s = Stn.RomanFilters[j]
1624
+ for i in range(n_obs_tot):
1625
+ if myf[1].data["filter"][i] == s:
1626
+ fdata[i] = j
1627
+ mytable.replace_column("filter", fdata)
1628
+ myf[1] = fits.BinTableHDU(mytable)
1629
+
1630
+ self.obsdata = myf[1].data
1631
+ obscols = myf[1].columns
1632
+ n_obs_tot = len(self.obsdata.field(0))
1633
+ print("Retrieved columns:", obscols.names, f" {n_obs_tot:d} rows")
1634
+
1635
+ # display output information
1636
+ print(
1637
+ f"Output information: ctr at RA={self.cfg.ra:10.6f},DEC={self.cfg.dec:10.6f} "
1638
+ f"LONPOLE={self.cfg.lonpole:10.6f}"
1639
+ )
1640
+ print(
1641
+ "pixel scale={:8.6f} arcsec or {:11.5E} degree".format(
1642
+ self.cfg.dtheta * u.degree.to("arcsec"), self.cfg.dtheta
1643
+ )
1644
+ )
1645
+ print(f"output array size = {self.cfg.NsideP:d} ({self.cfg.n1P:d} postage stamps of {self.cfg.n2:d})")
1646
+ print()
1647
+
1648
+ # block information
1649
+ ibx, iby = divmod(self.this_sub, self.cfg.nblock)
1650
+ self.ibx = ibx # save this information
1651
+ self.iby = iby
1652
+ print(
1653
+ f"sub-block {self.this_sub:4d} <{ibx:2d},{iby:2d}> of "
1654
+ f"{self.cfg.nblock:2d}x{self.cfg.nblock:2d}={self.cfg.nblock**2:2d}"
1655
+ )
1656
+ self.outstem = self.cfg.outstem + f"_{ibx:02d}_{iby:02d}"
1657
+ print("outputs directed to -->", self.outstem)
1658
+ if self.cfg.tempfile is not None:
1659
+ self.cache_dir = Path(
1660
+ self.cfg.tempfile
1661
+ + "_{:04d}_{:s}_cache".format(
1662
+ self.this_sub, datetime.datetime.now(pytz.timezone("UTC")).strftime("%Y%m%d%H%M%S%f")
1663
+ )
1664
+ )
1665
+ self.cache_dir.mkdir(exist_ok=True)
1666
+ print("temporary storage directed to -->", self.cache_dir)
1667
+
1668
+ # make the WCS
1669
+ self.outwcs = wcs.WCS(naxis=2)
1670
+ self.outwcs.wcs.crpix = [
1671
+ (self.cfg.NsideP + 1) / 2.0 - self.cfg.Nside * (ibx - (self.cfg.nblock - 1) / 2.0),
1672
+ (self.cfg.NsideP + 1) / 2.0 - self.cfg.Nside * (iby - (self.cfg.nblock - 1) / 2.0),
1673
+ ]
1674
+ self.outwcs.wcs.cdelt = [-self.cfg.dtheta, self.cfg.dtheta]
1675
+ self.outwcs.wcs.ctype = ["RA---STG", "DEC--STG"]
1676
+ self.outwcs.wcs.crval = [self.cfg.ra, self.cfg.dec]
1677
+ self.outwcs.wcs.lonpole = self.cfg.lonpole
1678
+
1679
+ # print the corners of the square and the center, ordering:
1680
+ # 2 3
1681
+ # 4
1682
+ # 0 1
1683
+ cornerx = [-0.5, self.cfg.NsideP - 0.5, -0.5, self.cfg.NsideP - 0.5, (self.cfg.NsideP - 1) / 2.0]
1684
+ cornery = [-0.5, -0.5, self.cfg.NsideP - 0.5, self.cfg.NsideP - 0.5, (self.cfg.NsideP - 1) / 2.0]
1685
+ for i in range(5):
1686
+ print(i, self.outwcs.all_pix2world(np.array([[cornerx[i], cornery[i]]]), 0))
1687
+ self.centerpos = self.outwcs.all_pix2world(np.array([[cornerx[-1], cornery[-1]]]), 0)[
1688
+ 0
1689
+ ] # [ra,dec] array in degrees
1690
+
1691
+ print("kappa/C array", self.cfg.kappaC_arr)
1692
+
1693
+ # and the output PSFs
1694
+ self.outpsfgrp = PSFGrp(in_or_out=False, blk=self)
1695
+ self.outpsfovl = PSFOvl(self.outpsfgrp, None)
1696
+ print("computed overlap, C=", self.outpsfovl.outovlc)
1697
+ print()
1698
+
1699
+ def _get_obs_cover(self, radius: float) -> None:
1700
+ """
1701
+ Get observations relevant to this Block.
1702
+
1703
+ Parameters
1704
+ ----------
1705
+ radius : float
1706
+ Search for input images within this radius.
1707
+
1708
+ Returns
1709
+ -------
1710
+ None
1711
+
1712
+ Notes
1713
+ -----
1714
+ This uses the observation table (obsdata), center position (ra, dec), and filter information
1715
+ stored internally to the Block.
1716
+
1717
+ """
1718
+
1719
+ self.obslist = []
1720
+ n_obs_tot = len(self.obsdata.field(0))
1721
+
1722
+ # rotate this observation to the (X,Y) of the local FoV for each observation
1723
+ # first rotate the RA direction
1724
+ x1 = np.cos(self.centerpos[1] * Stn.degree) * np.cos(
1725
+ (self.centerpos[0] - self.obsdata["ra"]) * Stn.degree
1726
+ )
1727
+ y1 = np.cos(self.centerpos[1] * Stn.degree) * np.sin(
1728
+ (self.centerpos[0] - self.obsdata["ra"]) * Stn.degree
1729
+ )
1730
+ z1 = np.sin(self.centerpos[1] * Stn.degree) * np.ones((n_obs_tot,))
1731
+ # then rotate the Dec direction
1732
+ x2 = np.sin(self.obsdata["dec"] * Stn.degree) * x1 - np.cos(self.obsdata["dec"] * Stn.degree) * z1
1733
+ y2 = y1
1734
+ z2 = np.cos(self.obsdata["dec"] * Stn.degree) * x1 + np.sin(self.obsdata["dec"] * Stn.degree) * z1
1735
+ # and finally the PA direction
1736
+ X = (
1737
+ -np.sin(self.obsdata["pa"] * Stn.degree) * x2 - np.cos(self.obsdata["pa"] * Stn.degree) * y2
1738
+ ) / Stn.degree
1739
+ Y = (
1740
+ -np.cos(self.obsdata["pa"] * Stn.degree) * x2 + np.sin(self.obsdata["pa"] * Stn.degree) * y2
1741
+ ) / Stn.degree
1742
+ #
1743
+ # throw away points in wrong hemisphere -- important since in orthographic projection,
1744
+ # can have (X,Y)=0 for antipodal point
1745
+ X = np.where(z2 > 0, X, 1e49)
1746
+
1747
+ for isca in range(18):
1748
+ obsgood = np.where(
1749
+ np.logical_and(
1750
+ np.sqrt((X - Stn.SCAFov[isca][0]) ** 2 + (Y - Stn.SCAFov[isca][1]) ** 2) < radius,
1751
+ self.obsdata["filter"] == self.cfg.use_filter,
1752
+ )
1753
+ )
1754
+ for k in range(len(obsgood[0])):
1755
+ self.obslist.append((obsgood[0][k], isca + 1))
1756
+
1757
+ self.obslist.sort()
1758
+
1759
+ def _build_use_instamps(self) -> None:
1760
+ """Build use_instamps, Boolean array indicating whether to use each input postage stamp."""
1761
+
1762
+ self.use_instamps = np.zeros((self.cfg.n1P + 2, self.cfg.n1P + 2), dtype=bool)
1763
+
1764
+ n_coadded = 0 # number of output postage stamps to be coadded
1765
+ for j_st in range(self.j_st_min, self.j_st_max + 1, 2):
1766
+ for i_st in range(self.i_st_min, self.i_st_max + 1, 2):
1767
+ for dj, di in product(range(2), range(2)):
1768
+ self.use_instamps[j_st + dj - 1 : j_st + dj + 2, i_st + di - 1 : i_st + di + 2] = True
1769
+
1770
+ n_coadded += 1
1771
+ if n_coadded == self.nrun:
1772
+ return
1773
+
1774
+ def _handle_postage_pad(self) -> None:
1775
+ """Handle the postage padding."""
1776
+
1777
+ postage_pad = self.cfg.postage_pad # shortcut
1778
+ self.j_st_min = self.i_st_min = postage_pad + 1 # 3 by default
1779
+ self.j_st_max = self.i_st_max = self.j_st_min + self.cfg.n1 - 1 # 50 by default
1780
+ self.pad_sides = "" # will also be used in build_output_file
1781
+
1782
+ # adjust these based on which side(s) to pad on
1783
+ if self.cfg.pad_sides == "all": # pad on all sides
1784
+ self.pad_sides = "BTLR"
1785
+
1786
+ elif self.cfg.pad_sides == "auto": # pad on mosaic boundaries only
1787
+ nblock = self.cfg.nblock # shortcut
1788
+ ibx, iby = divmod(self.this_sub, self.cfg.nblock)
1789
+ if iby == 0:
1790
+ self.pad_sides += "B"
1791
+ elif iby == nblock - 1:
1792
+ self.pad_sides += "T"
1793
+ if ibx == 0:
1794
+ self.pad_sides += "L"
1795
+ elif ibx == nblock - 1:
1796
+ self.pad_sides += "R"
1797
+
1798
+ elif self.cfg.pad_sides != "none": # pad on sides specified by the user
1799
+ self.pad_sides = self.cfg.pad_sides
1800
+
1801
+ if "B" in self.pad_sides:
1802
+ self.j_st_min -= postage_pad
1803
+ if "T" in self.pad_sides:
1804
+ self.j_st_max += postage_pad
1805
+ if "L" in self.pad_sides:
1806
+ self.i_st_min -= postage_pad
1807
+ if "R" in self.pad_sides:
1808
+ self.i_st_max += postage_pad
1809
+
1810
+ self.nrun = (self.j_st_max - self.j_st_min + 1) * (self.i_st_max - self.i_st_min + 1)
1811
+ if self.cfg.stoptile:
1812
+ self.nrun = self.cfg.stoptile
1813
+ self._build_use_instamps()
1814
+
1815
+ def process_input_images(self, visualize: bool = False) -> None:
1816
+ """Process input images."""
1817
+
1818
+ ### Now figure out which observations we need ###
1819
+
1820
+ search_radius = Stn.sca_sidelength / np.sqrt(
1821
+ 2.0
1822
+ ) / Stn.degree + self.cfg.NsideP * self.cfg.dtheta / np.sqrt(2.0)
1823
+ self._get_obs_cover(search_radius)
1824
+ print(
1825
+ len(self.obslist),
1826
+ f"observations within range ({search_radius:7.5f} deg)",
1827
+ "filter =",
1828
+ self.cfg.use_filter,
1829
+ f"({Stn.RomanFilters[self.cfg.use_filter]:s})",
1830
+ )
1831
+
1832
+ self.inimages = [InImage(self, idsca) for idsca in self.obslist]
1833
+ any_exists = False
1834
+ print("The observations -->")
1835
+ print(" OBSID SCA RAWFI DECWFI PA RASCA DECSCA FILE (x=missing)")
1836
+ for idsca, inimage in zip(self.obslist, self.inimages, strict=False):
1837
+ cpos = " "
1838
+ if inimage.exists_:
1839
+ any_exists = True
1840
+ cpos_coord = inimage.inwcs.all_pix2world([[Stn.sca_ctrpix, Stn.sca_ctrpix]], 0)[0]
1841
+ cpos = f"{cpos_coord[0]:8.4f} {cpos_coord[1]:8.4f}"
1842
+ print(
1843
+ "{:7d} {:2d} {:8.4f} {:8.4f} {:6.2f} {:s} {:s} {:s}".format(
1844
+ idsca[0],
1845
+ idsca[1],
1846
+ self.obsdata["ra"][idsca[0]],
1847
+ self.obsdata["dec"][idsca[0]],
1848
+ self.obsdata["pa"][idsca[0]],
1849
+ cpos,
1850
+ " " if inimage.exists_ else "x",
1851
+ inimage.infile,
1852
+ )
1853
+ )
1854
+ print()
1855
+ assert any_exists, "No candidate observations found to stack. Exiting now."
1856
+
1857
+ print("Reading input data ... ")
1858
+ self.pmask = Mask.load_permanent_mask(self)
1859
+ print()
1860
+
1861
+ self._handle_postage_pad()
1862
+ for inimage in self.inimages:
1863
+ if not inimage.exists_:
1864
+ inimage.is_relevant = False
1865
+ continue
1866
+ inimage.partition_pixels(visualize=visualize)
1867
+ # visualize = False # For now, visualize at most one partitioning process.
1868
+ if not inimage.is_relevant:
1869
+ continue
1870
+ inimage.extract_layers()
1871
+ print()
1872
+ del self.pmask
1873
+
1874
+ # remove irrelevant input images
1875
+ self.obslist = [self.obslist[i] for i, inimage in enumerate(self.inimages) if inimage.is_relevant]
1876
+ self.inimages = [inimage for inimage in self.inimages if inimage.is_relevant]
1877
+ self.n_inimage = len(self.inimages)
1878
+
1879
+ def build_input_stamps(self) -> None:
1880
+ """Build input stamps from input images."""
1881
+
1882
+ n1P = self.cfg.n1P # shortcuts
1883
+ pad = self.cfg.postage_pad
1884
+
1885
+ # current version only works when acceptance radius <= postage stamp size
1886
+ self.instamps = [[None for i_st in range(n1P + 2)] for j_st in range(n1P + 2)] # st stands for stamp
1887
+
1888
+ n_inpix_out = 0 # number of input pixels in output region (not including padding)
1889
+ n_inpix_pad = 0 # number of input pixels in padding region
1890
+
1891
+ for j_st in range(n1P + 2):
1892
+ for i_st in range(n1P + 2):
1893
+ if self.use_instamps[j_st, i_st]:
1894
+ self.instamps[j_st][i_st] = InStamp(self, j_st, i_st)
1895
+
1896
+ if (pad < j_st <= n1P - pad) and (pad < i_st <= n1P - pad):
1897
+ n_inpix_out += self.instamps[j_st][i_st].pix_cumsum[-1]
1898
+ elif (0 < j_st <= n1P) and (0 < i_st <= n1P):
1899
+ n_inpix_pad += self.instamps[j_st][i_st].pix_cumsum[-1]
1900
+
1901
+ print(f"number of input pixels in output region: {n_inpix_out = }")
1902
+ print(f"number of input pixels in padding region: {n_inpix_pad = }")
1903
+ print()
1904
+
1905
+ del self.use_instamps
1906
+ for inimage in self.inimages:
1907
+ inimage.clear()
1908
+
1909
+ def _output_stamp_wrapper(self, i_st, j_st, n_coadded, sim_mode: bool = False, visualize: bool = False):
1910
+ """
1911
+ Wrapper for output stamp coaddition.
1912
+
1913
+ Parameters
1914
+ ----------
1915
+ j_st : int
1916
+ Vertical OutStamp index.
1917
+ i_st : int
1918
+ Horizontal OutStamp index.
1919
+ n_coadded : int
1920
+ Number of postage stamps to be coadded.
1921
+ sim_mode : bool, optional
1922
+ Whether to count references without actually making inpsfgrp.
1923
+ See the docstring of psfutil.SysMatA._compute_iisubmats.
1924
+ visualize : bool, optional
1925
+ Perform visualizations? (Usually just for testing.)
1926
+
1927
+ Returns
1928
+ -------
1929
+ None
1930
+
1931
+ """
1932
+
1933
+ assert 1 <= i_st <= self.cfg.n1P and 1 <= j_st <= self.cfg.n1P, "outstamp out of boundary"
1934
+
1935
+ if sim_mode: # count references to PSF overlaps and system submatrices
1936
+ self.outstamps[j_st][i_st] = OutStamp(self, j_st, i_st, visualize=visualize)
1937
+ else:
1938
+ print(
1939
+ f"postage stamp {i_st:2d},{j_st:2d} {100 * n_coadded / self.nrun:6.3f}% "
1940
+ f"t= {self.timer():9.2f} s",
1941
+ flush=True,
1942
+ )
1943
+ outst = self.outstamps[j_st][i_st]
1944
+ outst(visualize=visualize)
1945
+
1946
+ bottom = (j_st - 1) * self.cfg.n2
1947
+ top = j_st * self.cfg.n2 + self.cfg.fade_kernel * 2
1948
+ left = (i_st - 1) * self.cfg.n2
1949
+ right = i_st * self.cfg.n2 + self.cfg.fade_kernel * 2
1950
+
1951
+ self.out_map[:, :, bottom:top, left:right] += outst.outimage
1952
+ self.T_weightmap[:, :, j_st - 1, i_st - 1] = outst.Tsum_stamp # weight computations
1953
+
1954
+ outmaps = self.cfg.outmaps # shortcut
1955
+ if "U" in outmaps:
1956
+ self.UC_map[:, bottom:top, left:right] += outst.UC # fidelity map
1957
+ if "S" in outmaps:
1958
+ self.Sigma_map[:, bottom:top, left:right] += outst.Sigma # noise map
1959
+ if "K" in outmaps:
1960
+ self.kappa_map[:, bottom:top, left:right] += outst.kappa # kappa map
1961
+ if "T" in outmaps:
1962
+ self.Tsum_map[:, bottom:top, left:right] += outst.Tsum_inpix # total weight
1963
+ if "N" in outmaps:
1964
+ self.Neff_map[:, bottom:top, left:right] += outst.Neff # effective coverage
1965
+
1966
+ outst.clear()
1967
+ self.outstamps[j_st][i_st] = None
1968
+ # coadding the OutStamp above is the last show of the InStamp below
1969
+ inst = self.instamps[j_st - 1][i_st - 1]
1970
+ inst.clear()
1971
+ self.instamps[j_st - 1][i_st - 1] = None
1972
+
1973
+ def coadd_output_stamps(self, sim_mode: bool = False, visualize: bool = False) -> None:
1974
+ """
1975
+ Coadd output stamps using input stamps.
1976
+
1977
+ Parameters
1978
+ ----------
1979
+ sim_mode : bool, optional
1980
+ Whether to count references without actually making inpsfgrp.
1981
+ See the docstring of psfutil.SysMatA._compute_iisubmats.
1982
+ visualize : bool, optional
1983
+ Perform visualizations? (Usually just for testing.)
1984
+
1985
+ Returns
1986
+ -------
1987
+ None
1988
+
1989
+ """
1990
+
1991
+ if sim_mode: # count references to PSF overlaps and system submatrices
1992
+ self.sysmata = SysMatA(self)
1993
+ self.sysmatb = SysMatB(self)
1994
+ self.outstamps = [[None for i_st in range(self.cfg.n1P + 2)] for j_st in range(self.cfg.n1P + 2)]
1995
+
1996
+ else:
1997
+ n_out = self.outpsfgrp.n_psf
1998
+ NsidePf = self.cfg.NsideP + self.cfg.fade_kernel * 2
1999
+
2000
+ # make basic output array (including transition pixels on the boundaries)
2001
+ self.out_map = np.zeros((n_out, self.cfg.n_inframe, NsidePf, NsidePf), dtype=np.float32)
2002
+ # allocate ancillary arrays
2003
+ self.T_weightmap = np.zeros((n_out, self.n_inimage, self.cfg.n1P, self.cfg.n1P), dtype=np.float32)
2004
+
2005
+ # additional information (will convert to integer)
2006
+ outmaps = self.cfg.outmaps # shortcut
2007
+ shape = (n_out, NsidePf, NsidePf)
2008
+ if "U" in outmaps:
2009
+ self.UC_map = np.zeros(shape, dtype=np.float32) # fidelity map
2010
+ if "S" in outmaps:
2011
+ self.Sigma_map = np.zeros(shape, dtype=np.float32) # noise map
2012
+ if "K" in outmaps:
2013
+ self.kappa_map = np.zeros(shape, dtype=np.float32) # kappa map
2014
+ if "T" in outmaps:
2015
+ self.Tsum_map = np.zeros(shape, dtype=np.float32) # total weight
2016
+ if "N" in outmaps:
2017
+ self.Neff_map = np.zeros(shape, dtype=np.float32) # effective coverage
2018
+
2019
+ ### Begin loop over all the postage stamps we want to create ###
2020
+
2021
+ n_coadded = 0 # number of coadded postage stamps
2022
+ if self.j_st_max + 1 - self.j_st_min % 2 == 1 or self.i_st_max + 1 - self.i_st_min % 2 == 1:
2023
+ raise ValueError(
2024
+ f"Size must be even: y={self.j_st_min}..{self.j_st_max}, x={self.i_st_min}..{self.i_st_max}"
2025
+ )
2026
+ for j_st in range(self.j_st_min, self.j_st_max + 1, 2):
2027
+ for i_st in range(self.i_st_min, self.i_st_max + 1, 2):
2028
+ for dj, di in product(range(2), range(2)):
2029
+ self._output_stamp_wrapper(i_st + di, j_st + dj, n_coadded, sim_mode, visualize)
2030
+ n_coadded += 1
2031
+
2032
+ if n_coadded == self.nrun:
2033
+ if sim_mode:
2034
+ self.sysmata.iisubmats.clear()
2035
+ self.sysmatb.iopsfovls.clear()
2036
+ else:
2037
+ assert len(self.sysmata.iisubmats) == 0, "self.sysmata.iisubmats is not empty"
2038
+ assert len(self.sysmatb.iopsfovls) == 0, "self.sysmatb.iopsfovls is not empty"
2039
+ return
2040
+
2041
+ if not sim_mode:
2042
+ gc.collect() # force a garbage collection
2043
+
2044
+ if not sim_mode:
2045
+ for i_st in range(self.i_st_min, self.i_st_max + 1, 2):
2046
+ for dj in range(-1, 1):
2047
+ inst = self.instamps[j_st + dj][i_st]
2048
+ if inst is not None:
2049
+ inst.clear()
2050
+ self.instamps[j_st + dj][i_st] = None
2051
+ gc.collect() # force a garbage collection
2052
+
2053
+ # print(" --> intermediate output -->\n")
2054
+ # self.build_output_file(is_final=False)
2055
+
2056
+ @staticmethod
2057
+ def compress_map(
2058
+ map_: np.array,
2059
+ coef: int,
2060
+ dtype: type,
2061
+ header: fits.Header = None,
2062
+ EXTNAME: str = None,
2063
+ UNIT: (str, str) = None,
2064
+ ) -> fits.ImageHDU:
2065
+ """
2066
+ Compress float32 map to (u)int16 to save storage.
2067
+
2068
+ Parameters
2069
+ ----------
2070
+ map_ : np.array
2071
+ Map to be compressed. Shape is usually (NsideP, NsideP).
2072
+ coef : int
2073
+ Coefficient for log10 values.
2074
+ dtype : type
2075
+ Data type of the compressed map, np.(u)int16.
2076
+ header : fits.Header
2077
+ Template header of the HDU.
2078
+ If None, return the compressed map instead of an HDU.
2079
+ EXTNAME : str
2080
+ EXTNAME keyword of the header.
2081
+ If None, return the compressed map instead of an HDU.
2082
+ UNIT : (str, str)
2083
+ UNIT keyword of the header.
2084
+ If None, return the compressed map instead of an HDU.
2085
+
2086
+ Returns
2087
+ -------
2088
+ fits.ImageHDU
2089
+ HDU containing the compressed array.
2090
+
2091
+ """
2092
+
2093
+ if dtype == np.uint16:
2094
+ a_min, a_max = 0, 65535
2095
+ elif dtype == np.int16:
2096
+ a_min, a_max = -32768, 32767
2097
+
2098
+ my_map = np.clip(np.floor(coef * np.log10(np.clip(map_, 1e-32, None)) + 0.5), a_min, a_max).astype(
2099
+ dtype
2100
+ )
2101
+ if header is None or EXTNAME is None or UNIT is None:
2102
+ return my_map
2103
+
2104
+ my_hdu = fits.ImageHDU(my_map, header=header)
2105
+ del my_map
2106
+ my_hdu.header["EXTNAME"] = EXTNAME
2107
+ my_hdu.header["UNIT"] = UNIT
2108
+ return my_hdu
2109
+
2110
+ def build_output_file(self, is_final: bool = False) -> None:
2111
+ """
2112
+ Build the output FITS file.
2113
+
2114
+ The kappa maps have been unified and merged into the main FITS file.
2115
+
2116
+ Parameters
2117
+ ----------
2118
+ is_final : bool, optional
2119
+ Whether this is the final (i.e., not intermediate) output.
2120
+ If so, recover the faded block boundaries.
2121
+
2122
+ Returns
2123
+ -------
2124
+ None
2125
+
2126
+ """
2127
+
2128
+ # shortcuts
2129
+ fk = self.cfg.fade_kernel
2130
+ NsidePf = self.cfg.NsideP + fk * 2
2131
+ outmaps = self.cfg.outmaps
2132
+
2133
+ if is_final: # recover block boundaries
2134
+ OutStamp.trapezoid(self.out_map, fk, recover_mode=True)
2135
+ width = self.cfg.postage_pad * self.cfg.n2 # width of padding region
2136
+ pad_widths = (
2137
+ width * ("B" not in self.pad_sides),
2138
+ width * ("T" not in self.pad_sides),
2139
+ width * ("L" not in self.pad_sides),
2140
+ width * ("R" not in self.pad_sides),
2141
+ )
2142
+ if "U" in outmaps:
2143
+ OutStamp.trapezoid(self.UC_map, fk, True, pad_widths)
2144
+ if "S" in outmaps:
2145
+ OutStamp.trapezoid(self.Sigma_map, fk, True, pad_widths)
2146
+ if "K" in outmaps:
2147
+ OutStamp.trapezoid(self.kappa_map, fk, True, pad_widths)
2148
+ if "T" in outmaps:
2149
+ OutStamp.trapezoid(self.Tsum_map, fk, True, pad_widths)
2150
+ if "N" in outmaps:
2151
+ OutStamp.trapezoid(self.Neff_map, fk, True, pad_widths)
2152
+
2153
+ my_header = self.outwcs.to_header()
2154
+ maphdu = fits.PrimaryHDU(self.out_map[:, :, fk : NsidePf - fk, fk : NsidePf - fk], header=my_header)
2155
+ config_hdu = fits.TableHDU.from_columns(
2156
+ [fits.Column(name="text", array=self.cfg.to_file(None).splitlines(), format="A512", ascii=True)]
2157
+ )
2158
+ config_hdu.header["EXTNAME"] = "CONFIG"
2159
+ config_hdu.header["TILESCHM"] = (self.cfg.tileschm, "Tiling scheme name")
2160
+ config_hdu.header["RERUN"] = (self.cfg.rerun, "Rerun name")
2161
+ config_hdu.header["MOSAIC"] = (self.cfg.mosaic, "Mosaic number")
2162
+ config_hdu.header["FILTER"] = (Stn.RomanFilters[self.cfg.use_filter], "Filter code")
2163
+ config_hdu.header["BLOCKX"] = self.ibx
2164
+ config_hdu.header["BLOCKY"] = self.iby
2165
+ if is_final:
2166
+ for package in ["numpy", "scipy", "astropy", "fitsio", "asdf", "pyimcom", "furry_parakeet"]:
2167
+ keyword = "V" + package.upper()[:7]
2168
+ pkgname = package
2169
+ if package == "numpy":
2170
+ pkgname = "np"
2171
+ try:
2172
+ config_hdu.header[keyword] = (
2173
+ str(globals()[pkgname].__version__),
2174
+ f"Current version of {package}",
2175
+ )
2176
+ except (KeyError, AttributeError):
2177
+ config_hdu.header[keyword] = ("N/A", f"{package} had no version number")
2178
+ inlist_hdu = fits.BinTableHDU.from_columns(
2179
+ [
2180
+ fits.Column(name="obsid", array=np.array([obs[0] for obs in self.obslist]), format="J"),
2181
+ fits.Column(name="sca", array=np.array([obs[1] for obs in self.obslist]), format="I"),
2182
+ fits.Column(
2183
+ name="ra",
2184
+ array=np.array([self.obsdata["ra"][obs[0]] for obs in self.obslist]),
2185
+ format="D",
2186
+ unit="degree",
2187
+ ),
2188
+ fits.Column(
2189
+ name="dec",
2190
+ array=np.array([self.obsdata["dec"][obs[0]] for obs in self.obslist]),
2191
+ format="D",
2192
+ unit="degree",
2193
+ ),
2194
+ fits.Column(
2195
+ name="pa",
2196
+ array=np.array([self.obsdata["pa"][obs[0]] for obs in self.obslist]),
2197
+ format="D",
2198
+ unit="degree",
2199
+ ),
2200
+ fits.Column(
2201
+ name="valid", array=np.array([inimage.exists_ for inimage in self.inimages]), format="L"
2202
+ ),
2203
+ ]
2204
+ )
2205
+ inlist_hdu.header["EXTNAME"] = "INDATA"
2206
+ T_hdu = fits.ImageHDU(self.T_weightmap)
2207
+ T_hdu.header["EXTNAME"] = "INWEIGHT"
2208
+ T_hdu2 = fits.ImageHDU(
2209
+ np.transpose(self.T_weightmap, axes=(0, 2, 1, 3)).reshape(
2210
+ (self.outpsfgrp.n_psf * self.cfg.n1P, self.n_inimage * self.cfg.n1P)
2211
+ )
2212
+ )
2213
+ T_hdu2.header["EXTNAME"] = "INWTFLAT"
2214
+
2215
+ hdulist = [maphdu, config_hdu, inlist_hdu, T_hdu, T_hdu2]
2216
+
2217
+ if "U" in outmaps:
2218
+ hdulist.append(
2219
+ Block.compress_map(
2220
+ self.UC_map[:, fk : NsidePf - fk, fk : NsidePf - fk],
2221
+ -5000,
2222
+ np.uint16,
2223
+ my_header,
2224
+ "FIDELITY",
2225
+ ("-0.2mB", "-5000*log10(U/C)"),
2226
+ )
2227
+ )
2228
+
2229
+ if "S" in outmaps:
2230
+ hdulist.append(
2231
+ Block.compress_map(
2232
+ self.Sigma_map[:, fk : NsidePf - fk, fk : NsidePf - fk],
2233
+ -10000,
2234
+ np.int16,
2235
+ my_header,
2236
+ "SIGMA",
2237
+ ("-0.1mB", "-10000*log10(Sigma)"),
2238
+ )
2239
+ )
2240
+
2241
+ if "K" in outmaps:
2242
+ hdulist.append(
2243
+ Block.compress_map(
2244
+ self.kappa_map[:, fk : NsidePf - fk, fk : NsidePf - fk],
2245
+ -5000,
2246
+ np.uint16,
2247
+ my_header,
2248
+ "KAPPA",
2249
+ ("-0.2mB", "-5000*log10(kappa)"),
2250
+ )
2251
+ )
2252
+
2253
+ if "T" in outmaps:
2254
+ hdulist.append(
2255
+ Block.compress_map(
2256
+ self.Tsum_map[:, fk : NsidePf - fk, fk : NsidePf - fk],
2257
+ 200000,
2258
+ np.int16,
2259
+ my_header,
2260
+ "INWTSUM",
2261
+ ("5uB", "200000*log10(Tsum)"),
2262
+ )
2263
+ )
2264
+
2265
+ if "N" in outmaps:
2266
+ hdulist.append(
2267
+ Block.compress_map(
2268
+ self.Neff_map[:, fk : NsidePf - fk, fk : NsidePf - fk],
2269
+ 50000,
2270
+ np.uint16,
2271
+ my_header,
2272
+ "EFFCOVER",
2273
+ ("20uB", "50000*log10(Neff)"),
2274
+ )
2275
+ )
2276
+
2277
+ # splitpsf information
2278
+ if self.cfg.psfsplit:
2279
+ text = ""
2280
+ iter = 0
2281
+ iterfile = self.cfg.inlayercache + "_iter.txt"
2282
+ oldcfgfile = self.cfg.inlayercache + "_oldcfg.json"
2283
+ if exists(iterfile):
2284
+ with open(iterfile, "r") as f:
2285
+ iter = int(f.read().split()[0])
2286
+ if exists(oldcfgfile):
2287
+ with open(oldcfgfile, "r") as fcfg:
2288
+ text = fcfg.read()
2289
+ prevconfig_hdu = fits.TableHDU.from_columns(
2290
+ [fits.Column(name="text", array=text.split(), format="A512", ascii=True)]
2291
+ )
2292
+ prevconfig_hdu.header["EXTNAME"] = "OLDCFG"
2293
+ prevconfig_hdu.header["IMSBITER"] = (iter, "Number of iterations of PSFSPLIT")
2294
+ prevconfig_hdu.header["COMMENT"] = "Configuration files from previous iterations."
2295
+ hdulist.append(prevconfig_hdu)
2296
+
2297
+ hdu_list = fits.HDUList(hdulist)
2298
+ hdu_list.writeto(self.outstem + ".fits", overwrite=True)
2299
+
2300
+ def clear_all(self) -> None:
2301
+ """Free up all memory spaces."""
2302
+
2303
+ if self.cfg.tempfile is not None:
2304
+ self.cache_dir.rmdir()
2305
+
2306
+ del self.obsdata, self.obslist, self.outwcs
2307
+ del self.outpsfgrp, self.outpsfovl
2308
+ self.sysmata.clear()
2309
+ del self.sysmata
2310
+ self.sysmatb.clear()
2311
+ del self.sysmatb
2312
+ del self.out_map, self.T_weightmap
2313
+
2314
+ outmaps = self.cfg.outmaps # shortcut
2315
+ if "U" in outmaps:
2316
+ del self.UC_map
2317
+ if "S" in outmaps:
2318
+ del self.Sigma_map
2319
+ if "K" in outmaps:
2320
+ del self.kappa_map
2321
+ if "T" in outmaps:
2322
+ del self.Tsum_map
2323
+ if "N" in outmaps:
2324
+ del self.Neff_map
2325
+
2326
+ for j_st in range(self.cfg.n1P + 2):
2327
+ for i_st in range(self.cfg.n1P + 2):
2328
+ inst = self.instamps[j_st][i_st]
2329
+ if inst is not None:
2330
+ inst.clear()
2331
+ self.instamps[j_st][i_st] = None