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/analysis.py ADDED
@@ -0,0 +1,1480 @@
1
+ """
2
+ Tools to analyze coadded images.
3
+
4
+ Classes
5
+ -------
6
+ OutImage
7
+ Wrapper for coadded images (blocks).
8
+ NoiseAnal
9
+ Analysis of noise frames.
10
+ StarsAnal
11
+ Analysis of point sources.
12
+ _BlkGrp
13
+ Abstract base class for groups of blocks (mosiacs or suites).
14
+ Mosaic
15
+ Wrapper for coadded mosaics (2D arrays of blocks).
16
+ Suite
17
+ Wrapper for coadded suites (hashed arrays of blocks).
18
+
19
+ """
20
+
21
+ import re
22
+ from enum import Enum
23
+ from os.path import exists
24
+ from pathlib import Path
25
+
26
+ import galsim
27
+ import healpy
28
+ import numpy as np
29
+ from astropy import constants as const
30
+ from astropy import units as u
31
+ from astropy import wcs
32
+ from astropy.io import fits
33
+ from scipy import ndimage
34
+
35
+ from .coadd import Block, OutStamp
36
+ from .compress.compressutils import ReadFile
37
+ from .config import Config, Timer
38
+ from .config import Settings as Stn
39
+ from .diagnostics.outimage_utils.helper import HDU_to_bels
40
+
41
+
42
+ class OutImage:
43
+ """
44
+ Wrapper for coadded images (blocks).
45
+
46
+ Parameters
47
+ ----------
48
+ fpath : str or str-like
49
+ Path to the output FITS file.
50
+ cfg : Config, optional
51
+ Configuration used for this output image.
52
+ If provided, no consistency check is performed.
53
+ If None, it will be extracted from FITS file.
54
+ hdu_names : list of str, optional
55
+ List of HDU names of this FITS file.
56
+ If provided, no consistency check is performed.
57
+ If None, it will be derived from `cfg`.
58
+
59
+ Methods
60
+ -------
61
+ get_hdu_names
62
+ Parse outmaps to get a list of HDU names.
63
+ __init__
64
+ Constructor.
65
+ get_last_line
66
+ Get last line of a text file.
67
+ get_time_consump
68
+ Parse terminal output to get time consumption.
69
+ _load_or_save_hdu_list
70
+ Load data from or save data to FITS file.
71
+ get_coadded_layer
72
+ Extract a coadded layer from the primary HDU.
73
+ get_T_weightmap
74
+ Extract T_weightmap from an additional HDU.
75
+ get_mean_coverage
76
+ Compute mean coverage based on T_weightmap.
77
+ get_output_map
78
+ Extract an output map from the additional HDUs.
79
+ _update_hdu_data
80
+ Update data using data provided by a neighbor.
81
+
82
+ """
83
+
84
+ @staticmethod
85
+ def get_hdu_names(outmaps: str) -> list[str]:
86
+ """
87
+ Parse outmaps to get a list of HDU names.
88
+
89
+ Parameters
90
+ ----------
91
+ outmaps : str
92
+ outmaps attribute of a Config instance.
93
+
94
+ Returns
95
+ -------
96
+ list of str
97
+ A list of HDU names.
98
+
99
+ """
100
+
101
+ hdu_names = ["PRIMARY", "CONFIG", "INDATA", "INWEIGHT", "INWTFLAT"]
102
+ if "U" in outmaps:
103
+ hdu_names.append("FIDELITY")
104
+ if "S" in outmaps:
105
+ hdu_names.append("SIGMA")
106
+ if "K" in outmaps:
107
+ hdu_names.append("KAPPA")
108
+ if "T" in outmaps:
109
+ hdu_names.append("INWTSUM")
110
+ if "N" in outmaps:
111
+ hdu_names.append("EFFCOVER")
112
+ return hdu_names
113
+
114
+ def __init__(self, fpath: str, cfg: Config = None, hdu_names: list[str] = None) -> None:
115
+ fpath = str(fpath)
116
+ self.fpath = fpath
117
+
118
+ self.cfg = cfg
119
+ if cfg is None:
120
+ with ReadFile(fpath) as hdu_list:
121
+ self.cfg = Config("".join(hdu_list["CONFIG"].data["text"].tolist()))
122
+ self.cfg() # update configuration parameters
123
+
124
+ self.hdu_names = hdu_names
125
+ if hdu_names is None:
126
+ self.hdu_names = OutImage.get_hdu_names(self.cfg.outmaps)
127
+
128
+ with fits.open(self.fpath) as _hdul:
129
+ _header = _hdul["CONFIG"].header
130
+
131
+ if ("BLOCKX" in _header) and ("BLOCKY" in _header):
132
+ self.ibx = int(_header["BLOCKX"])
133
+ self.iby = int(_header["BLOCKY"])
134
+ else:
135
+ # Get file indices.
136
+ fstem = Path(fpath).stem
137
+ # This part is for legacy file names that had a '_map'.
138
+ # Summer 2025 run and later don't have this.
139
+ if fstem[-4:] == "_map":
140
+ fstem = fstem[:-4]
141
+ self.ibx, self.iby = map(int, fstem.split("_")[-2:])
142
+
143
+ @staticmethod
144
+ def get_last_line(fname: str) -> str:
145
+ """
146
+ Get last line of a text file.
147
+
148
+ Parameters
149
+ ----------
150
+ fname : str
151
+ Path to the text file.
152
+
153
+ Returns
154
+ -------
155
+ str
156
+ Last line of the text file.
157
+
158
+ """
159
+
160
+ with open(fname, "r") as f:
161
+ for line in f:
162
+ last_line = line
163
+ return last_line
164
+
165
+ def get_time_consump(self) -> float:
166
+ """
167
+ Parse terminal output to get time consumption.
168
+
169
+ Returns
170
+ -------
171
+ float
172
+ Time consumption in seconds (if found), otherwise nan.
173
+
174
+ """
175
+
176
+ fname = self.fpath.replace(".fits", ".out")
177
+ try:
178
+ last_line = OutImage.get_last_line(fname)
179
+ m = re.match("finished at t = ([0-9.]+) s", last_line)
180
+ return float(m.group(1))
181
+ except FileNotFoundError:
182
+ return np.nan
183
+
184
+ def _load_or_save_hdu_list(
185
+ self, load_mode: bool = True, save_file: bool = False, auto_to_all: bool = False
186
+ ) -> None:
187
+ """
188
+ Load data from or save data to FITS file.
189
+
190
+ Parameters
191
+ ----------
192
+ load_mode : bool, optional
193
+ If True, load data from FITS file (if not already loaded);
194
+ if False, remove current data from memory (if data exist).
195
+ save_file : bool, optional
196
+ Only used when `load_mode` == False. If (`save_file` ==) True,
197
+ save current data to FITS file (overwriting the existing file).
198
+ auto_to_all : bool, optional
199
+ Only used when load_mode == False and save_file == True.
200
+ If (auto_to_all ==) True, change 'PADSIDES' from 'auto' to 'all'
201
+ in the 'CONFIG' HDU of FITS file.
202
+
203
+ Returns
204
+ -------
205
+ None
206
+
207
+ """
208
+
209
+ if load_mode:
210
+ if not hasattr(self, "hdu_list"):
211
+ self.hdu_list = ReadFile(self.fpath)
212
+
213
+ else:
214
+ if save_file:
215
+ assert hasattr(self, "hdu_list"), "no hdu_list to save"
216
+
217
+ if auto_to_all:
218
+ my_cfg = Config("".join(self.hdu_list["CONFIG"].data["text"].tolist()))
219
+ my_cfg.pad_sides = "all"
220
+ config_hdu = fits.TableHDU.from_columns(
221
+ [
222
+ fits.Column(
223
+ name="text",
224
+ array=my_cfg.to_file(None).splitlines(),
225
+ format="A512",
226
+ ascii=True,
227
+ )
228
+ ]
229
+ )
230
+ config_hdu.header["EXTNAME"] = "CONFIG"
231
+ self.hdu_list["CONFIG"] = config_hdu
232
+
233
+ self.hdu_list.writeto(self.fpath, overwrite=True)
234
+
235
+ if hasattr(self, "hdu_list"):
236
+ self.hdu_list.close()
237
+ del self.hdu_list
238
+
239
+ def get_coadded_layer(self, layer: str, j_out: int = 0) -> np.array:
240
+ """
241
+ Extract a coadded layer from the primary HDU.
242
+
243
+ Parameters
244
+ ----------
245
+ layer : str
246
+ Name of the layer to be extracted.
247
+ j_out : int or None, optional
248
+ Index of the output PSF. If None, return results based on all output PSFs.
249
+
250
+ Returns
251
+ -------
252
+ data : np.array
253
+ Requested coadded layer.
254
+ The shape is either (NsideP, NsideP) or (n_out, NsideP, NsideP) (all output PSFs)
255
+
256
+ """
257
+
258
+ assert layer in ["SCI"] + self.cfg.extrainput[1:], f"Error: layer '{layer}' not found"
259
+ idx = self.cfg.extrainput.index(layer if layer != "SCI" else None)
260
+
261
+ data_loaded = hasattr(self, "hdu_list")
262
+ if not data_loaded:
263
+ self.hdu_list = ReadFile(self.fpath)
264
+
265
+ if j_out is not None:
266
+ data = (self.hdu_list["PRIMARY"].data[j_out, idx]).astype(np.float32)
267
+ else:
268
+ data = (self.hdu_list["PRIMARY"].data[:, idx]).astype(np.float32)
269
+
270
+ if not data_loaded:
271
+ self.hdu_list.close()
272
+ del self.hdu_list
273
+ return data
274
+
275
+ def get_T_weightmap(self, flat: bool = False, j_out: int = 0) -> np.array:
276
+ """
277
+ Extract T_weightmap from an additional HDU.
278
+
279
+ Parameters
280
+ ----------
281
+ flat : bool, optional
282
+ Whether to read the flat version of T_weightmap.
283
+ j_out : int or None, optional
284
+ Only used when `flat` is False. Index of the output PSF.
285
+ If None, return results based on all output PSFs.
286
+
287
+ Returns
288
+ -------
289
+ data : np.array
290
+ Requested T_weightmap.
291
+ Shape is either (n_inimage, n1P, n1P) (1)
292
+ or (n_out, n_inimage, n1P, n1P) (all output PSFs)
293
+ or (n_out*n1P, n_inimage*n1P) (flat version)
294
+
295
+ """
296
+
297
+ data_loaded = hasattr(self, "hdu_list")
298
+ if not data_loaded:
299
+ self.hdu_list = ReadFile(self.fpath)
300
+
301
+ if not flat: # read T_hdu
302
+ if j_out is not None:
303
+ data = (self.hdu_list["INWEIGHT"].data[j_out, ...]).astype(np.float32)
304
+ else:
305
+ data = (self.hdu_list["INWEIGHT"].data[:, ...]).astype(np.float32)
306
+
307
+ else: # read T_hdu2
308
+ data = (self.hdu_list["INWTFLAT"].data).astype(np.float32)
309
+
310
+ if not data_loaded:
311
+ self.hdu_list.close()
312
+ del self.hdu_list
313
+ return data
314
+
315
+ def get_mean_coverage(self, padding: bool = False) -> float:
316
+ """
317
+ Compute mean coverage based on T_weightmap.
318
+
319
+ We assume that mean coverage is the same for all output PSFs.
320
+
321
+ Parameters
322
+ ----------
323
+ padding : bool, optional
324
+ Whether to include padding postage stamps. The default is False.
325
+
326
+ Returns
327
+ -------
328
+ mean_coverage : float
329
+ Mean coverage based on T_weightmap.
330
+
331
+ """
332
+
333
+ T_weightmap = self.get_T_weightmap(j_out=0) # shape: (n_inimage, n1P, n1P)
334
+ if not padding:
335
+ pad = self.cfg.postage_pad # shortcut
336
+ if pad > 0:
337
+ T_weightmap = T_weightmap[:, pad:-pad, pad:-pad]
338
+
339
+ mean_coverage = np.mean(np.sum(T_weightmap.astype(bool), axis=0))
340
+ del T_weightmap
341
+ return mean_coverage
342
+
343
+ def get_output_map(self, outmap: str, j_out: int = 0) -> np.array:
344
+ """
345
+ Extract an output map from the additional HDUs.
346
+
347
+ Parameters
348
+ ----------
349
+ outmap : str
350
+ Name of the output map to be extracted.
351
+ j_out : int or None, optional
352
+ Index of the output PSF.
353
+ If None, return results based on all output PSFs.
354
+
355
+ Returns
356
+ -------
357
+ data : np.array
358
+ Requested output map. shape is either (NsideP, NsideP)
359
+ or (n_out, NsideP, NsideP) (all output PSFs).
360
+
361
+ """
362
+
363
+ assert outmap in self.hdu_names, f"Error: map '{outmap}' not found"
364
+ assert outmap in [
365
+ "FIDELITY",
366
+ "SIGMA",
367
+ "KAPPA",
368
+ "INWTSUM",
369
+ "EFFCOVER",
370
+ ], f"Error: map '{outmap}' not supported by get_output_map"
371
+
372
+ data_loaded = hasattr(self, "hdu_list")
373
+ if not data_loaded:
374
+ self.hdu_list = ReadFile(self.fpath)
375
+
376
+ coef = 1.0 / HDU_to_bels(self.hdu_list[outmap])
377
+ slice_ = np.s_[j_out] if j_out is not None else np.s_[:]
378
+ data = np.power(10.0, self.hdu_list[outmap].data[slice_] / coef).astype(np.float32)
379
+
380
+ dtype = self.hdu_list[outmap].data.dtype
381
+ if dtype == np.dtype("uint16"):
382
+ a_min, a_max = 0, 65535
383
+ elif dtype == np.dtype(">i2"):
384
+ a_min, a_max = -32768, 32767
385
+ a_zero = a_min if coef > 0 else a_max
386
+ data[data == np.power(10.0, a_zero / coef)] = 0.0
387
+
388
+ if not data_loaded:
389
+ self.hdu_list.close()
390
+ del self.hdu_list
391
+ return data
392
+
393
+ def _update_hdu_data(self, neighbor: "OutImage", direction: str, add_mode: bool = True) -> None:
394
+ """
395
+ Update data using data provided by a neighbor.
396
+
397
+ This method is developed for postprocessing, i.e.,
398
+ sharing padding postage stamps between adjacent blocks.
399
+ This method neither loads nor saves hdu_list.
400
+
401
+ Parameters
402
+ ----------
403
+ neighbor : OutImage
404
+ Neighboring output image (block) who shares data with "me."
405
+ direction : str
406
+ Which side to update. Must be 'left', 'right', 'bottom', or 'top'.
407
+ add_mode : bool, optional
408
+ If True, update "my" data by adding neighbor's to "mine;"
409
+ if False, replace "my" data with neighbor's.
410
+
411
+ Returns
412
+ -------
413
+ None
414
+
415
+ """
416
+
417
+ assert direction in [
418
+ "left",
419
+ "right",
420
+ "bottom",
421
+ "top",
422
+ ], f"Error: direction '{direction}' not supported by _update_hdu_data"
423
+
424
+ # not necessarily the same as self.cfg
425
+ my_cfg = Config("".join(self.hdu_list["CONFIG"].data["text"].tolist()))
426
+ assert my_cfg.pad_sides == "auto", "Error: _update_hdu_data only supports pad_sides == 'auto'"
427
+ del my_cfg
428
+
429
+ # update PRIMARY
430
+ NsideP = self.cfg.NsideP # shortcut
431
+ width = self.cfg.postage_pad * self.cfg.n2 # width of padding region
432
+ fk = self.cfg.fade_kernel # shortcut
433
+
434
+ if direction == "left":
435
+ my_slice = np.s_[:, :, :, 0 : width + fk]
436
+ ur_slice = np.s_[:, :, :, NsideP - width * 2 : NsideP - width + fk]
437
+ elif direction == "right":
438
+ my_slice = np.s_[:, :, :, NsideP - width - fk : NsideP]
439
+ ur_slice = np.s_[:, :, :, width - fk : width * 2]
440
+ elif direction == "bottom":
441
+ my_slice = np.s_[:, :, 0 : width + fk, :]
442
+ ur_slice = np.s_[:, :, NsideP - width * 2 : NsideP - width + fk, :]
443
+ elif direction == "top":
444
+ my_slice = np.s_[:, :, NsideP - width - fk : NsideP, :]
445
+ ur_slice = np.s_[:, :, width - fk : width * 2, :]
446
+
447
+ self.hdu_list["PRIMARY"].data[my_slice] = (
448
+ self.hdu_list["PRIMARY"].data[my_slice] * add_mode + neighbor.hdu_list["PRIMARY"].data[ur_slice]
449
+ )
450
+ del my_slice, ur_slice
451
+
452
+ # update INWEIGHT and INWTFLAT
453
+ n1P = self.cfg.n1P # shortcuts
454
+ pad = self.cfg.postage_pad
455
+
456
+ my_idscas = list(
457
+ zip(self.hdu_list["INDATA"].data["obsid"], self.hdu_list["INDATA"].data["sca"], strict=False)
458
+ )
459
+ ur_idscas = list(
460
+ zip(
461
+ neighbor.hdu_list["INDATA"].data["obsid"],
462
+ neighbor.hdu_list["INDATA"].data["sca"],
463
+ strict=False,
464
+ )
465
+ )
466
+
467
+ for idsca in set(my_idscas) & set(ur_idscas):
468
+ my_idx = my_idscas.index(idsca)
469
+ ur_idx = ur_idscas.index(idsca)
470
+
471
+ if direction == "left":
472
+ my_slice = np.s_[:, my_idx, :, 0:pad]
473
+ ur_slice = np.s_[:, ur_idx, :, n1P - pad * 2 : n1P - pad]
474
+ elif direction == "right":
475
+ my_slice = np.s_[:, my_idx, :, n1P - pad : n1P]
476
+ ur_slice = np.s_[:, ur_idx, :, pad : pad * 2]
477
+ elif direction == "bottom":
478
+ my_slice = np.s_[:, my_idx, 0:pad, :]
479
+ ur_slice = np.s_[:, ur_idx, n1P - pad * 2 : n1P - pad, :]
480
+ elif direction == "top":
481
+ my_slice = np.s_[:, my_idx, n1P - pad : n1P, :]
482
+ ur_slice = np.s_[:, ur_idx, pad : pad * 2, :]
483
+
484
+ self.hdu_list["INWEIGHT"].data[my_slice] = neighbor.hdu_list["INWEIGHT"].data[ur_slice]
485
+ del my_slice, ur_slice
486
+
487
+ del my_idscas, ur_idscas
488
+
489
+ n_out, n_inimage = self.hdu_list["INWEIGHT"].data.shape[:2]
490
+ self.hdu_list["INWTFLAT"].data[:, :] = np.transpose(
491
+ self.hdu_list["INWEIGHT"].data, axes=(0, 2, 1, 3)
492
+ ).reshape((n_out * n1P, n_inimage * n1P))
493
+
494
+ # update output maps
495
+ fk = self.cfg.fade_kernel
496
+
497
+ for outmap in self.hdu_names[5:]:
498
+ my_maps = self.get_output_map(outmap, None)
499
+ ur_maps = neighbor.get_output_map(outmap, None)
500
+
501
+ if direction == "left":
502
+ if add_mode:
503
+ OutStamp.trapezoid(my_maps, fk, False, (0, 0, width - fk, 0), "L")
504
+ OutStamp.trapezoid(ur_maps, fk, False, (0, 0, 0, width - fk), "R")
505
+ my_slice = np.s_[:, :, 0 : width + fk]
506
+ ur_slice = np.s_[:, :, NsideP - width * 2 : NsideP - width + fk]
507
+
508
+ elif direction == "right":
509
+ if add_mode:
510
+ OutStamp.trapezoid(my_maps, fk, False, (0, 0, 0, width - fk), "R")
511
+ OutStamp.trapezoid(ur_maps, fk, False, (0, 0, width - fk, 0), "L")
512
+ my_slice = np.s_[:, :, NsideP - width - fk : NsideP]
513
+ ur_slice = np.s_[:, :, width - fk : width * 2]
514
+
515
+ elif direction == "bottom":
516
+ if add_mode:
517
+ OutStamp.trapezoid(my_maps, fk, False, (width - fk, 0, 0, 0), "B")
518
+ OutStamp.trapezoid(ur_maps, fk, False, (0, width - fk, 0, 0), "T")
519
+ my_slice = np.s_[:, 0 : width + fk, :]
520
+ ur_slice = np.s_[:, NsideP - width * 2 : NsideP - width + fk, :]
521
+
522
+ elif direction == "top":
523
+ if add_mode:
524
+ OutStamp.trapezoid(my_maps, fk, False, (0, width - fk, 0, 0), "T")
525
+ OutStamp.trapezoid(ur_maps, fk, False, (width - fk, 0, 0, 0), "B")
526
+ my_slice = np.s_[:, NsideP - width - fk : NsideP, :]
527
+ ur_slice = np.s_[:, width - fk : width * 2, :]
528
+
529
+ coef = int(self.hdu_list[outmap].header.comments["UNIT"].partition("*")[0])
530
+ dtype = self.hdu_list[outmap].data.dtype
531
+ if dtype == np.dtype(">i2"):
532
+ dtype = np.dtype("int16")
533
+ self.hdu_list[outmap].data[my_slice] = Block.compress_map(
534
+ my_maps[my_slice] * add_mode + ur_maps[ur_slice], coef, dtype
535
+ )
536
+ del my_maps, ur_maps, my_slice, ur_slice
537
+
538
+
539
+ class NoiseAnal:
540
+ """
541
+ Analysis of noise frames.
542
+
543
+ Largely based on diagnostics/noise/noisespecs.py.
544
+
545
+ Parameters
546
+ ----------
547
+ outim : OutImage
548
+ Output image to analyze.
549
+ layer : str
550
+ Layer name of noise frame to analyze.
551
+
552
+ Methods
553
+ -------
554
+ __init__
555
+ Constructor.
556
+ get_norm
557
+ Get norm for 2D noise power spectrum (classmethod).
558
+ azimuthal_average
559
+ Compute radial profile of image (staticmethod)).
560
+ _get_wavenumbers
561
+ Calculate wavenumbers for the input image (staticmethod).
562
+ __call__
563
+ Analyze specified noise frame of given output image.
564
+ clear
565
+ Free up memory space.
566
+
567
+ """
568
+
569
+ # from noise/noisespecs.py
570
+ AREA = {"Y106": 7006, "J129": 7111, "H158": 7340, "F184": 4840, "K213": 4654, "W146": 22085} # cm^2
571
+
572
+ # Set useful constants
573
+ tfr = 3.08 # sec
574
+ gain = 1.458 # electrons/DN
575
+ ABstd = 3.631e-20 # erg/cm^2
576
+ h = const.h.to("erg/Hz").value # 6.62607015e-27
577
+ m_ab = 23.9 # sample mag for PS
578
+ s_in = 0.11 # arcsec
579
+
580
+ # following make_plot_ln.py
581
+ PS1D_COLORS = ["orange", "darksalmon", "palevioletred", "mediumvioletred", "darkviolet"]
582
+ PS1D_STYLES = ["solid", "dotted", "dashed", "solid", "dashdot"]
583
+
584
+ def __init__(self, outim: OutImage, layer: str) -> None:
585
+ self.outim = outim
586
+ self.layer = layer
587
+
588
+ self.cfg = outim.cfg
589
+ assert layer in ["SCI"] + self.cfg.extrainput[1:], f"Error: layer '{layer}' not found"
590
+
591
+ @classmethod
592
+ def get_norm(cls, layer: str, L: int, filtername: str, s_out: float) -> float:
593
+ """
594
+ Get norm for 2D noise power spectrum.
595
+
596
+ Parameters
597
+ ----------
598
+ layer : str
599
+ Layer name of noise frame to analyze.
600
+ L : int
601
+ Side length of noise frame in px.
602
+ filtername : str
603
+ Name of filter used for this output image.
604
+ s_out : float
605
+ Output pixel scale in arcsec.
606
+
607
+ Returns
608
+ -------
609
+ float
610
+ Norm for 2D noise power spectrum.
611
+
612
+ Notes
613
+ -----
614
+ For simulated noise frames, dividing by s_in**2
615
+ converts from units of S_in^2 to arcsec^2.
616
+
617
+ """
618
+
619
+ if layer.startswith("white"):
620
+ return (L / s_out) ** 2 # (L * (cls.s_in/s_out)) ** 2
621
+ elif layer.startswith("1f"):
622
+ return (L / s_out) ** 2 # (L * (cls.s_in/s_out)) ** 2
623
+ elif layer.startswith("lab"):
624
+ return (
625
+ cls.tfr
626
+ / cls.gain
627
+ * cls.ABstd
628
+ / cls.h
629
+ * cls.AREA[filtername]
630
+ * 10 ** (-0.4 * cls.m_ab)
631
+ * s_out**2
632
+ )
633
+
634
+ @staticmethod
635
+ def azimuthal_average(
636
+ image: np.array, nradbins: int, rbin: np.array = None, ridx: np.array = None
637
+ ) -> np.array:
638
+ """
639
+ Compute radial profile of image.
640
+
641
+ Parameters
642
+ ----------
643
+ image : np.array
644
+ Input image, shape (L, L).
645
+ nradbins : int
646
+ Number of radial bins in profile.
647
+ rbin: np.array, optional
648
+ "labels" parameter for ndimage utilities.
649
+ If provided, has shape (L, L).
650
+ The default is None. If not provided, derive from `image`.shape.
651
+ ridx: np.array, optional
652
+ "index" parameter for ndimage utilities.
653
+ If provided, has shape (`nradbins`,).
654
+ The default is None. If not provided, derive from rbin.
655
+
656
+ Returns
657
+ -------
658
+ radial_mean : np.array
659
+ Mean intensity within each annulus. Main result. Shape is (`nradbins`,)
660
+ radial_err : np.array
661
+ Standard error on the mean: sigma / sqrt(N). Shape is (`nradbins`,)
662
+
663
+ """
664
+
665
+ if rbin is None:
666
+ ny, nx = image.shape
667
+ yy, xx = np.mgrid[:ny, :nx]
668
+ r = np.hypot(xx - nx / 2, yy - ny / 2)
669
+ rbin = (nradbins * r / r.max()).astype(int)
670
+ if ridx is None:
671
+ ridx = np.arange(1, rbin.max() + 1)
672
+
673
+ radial_mean = ndimage.mean(image, labels=rbin, index=ridx)
674
+ radial_stddev = ndimage.standard_deviation(image, labels=rbin, index=ridx)
675
+ npix = ndimage.sum(np.ones_like(image), labels=rbin, index=ridx)
676
+ radial_err = radial_stddev / np.sqrt(npix)
677
+ del npix
678
+ return radial_mean, radial_err
679
+
680
+ @staticmethod
681
+ def _get_wavenumbers(
682
+ window_length: int, nradbins: int, rbin: np.array = None, ridx: np.array = None
683
+ ) -> np.array:
684
+ """
685
+ Calculate wavenumbers for the input image.
686
+
687
+ Parameters
688
+ ----------
689
+ window_length : int
690
+ the length of one axis of the image.
691
+ nradbins : int
692
+ number of radial bins the image should be averaged into
693
+ rbin: np.array, optional
694
+ "labels" parameter for ndimage utilities.
695
+ If provided, shape is (L,L).
696
+ The default is None. If not provided, derive from image.shape.
697
+ ridx: np.array, optional
698
+ "index" parameter for ndimage utilities.
699
+ If provided, shape is (`nradbins`,).
700
+ The default is None. If not provided, derive from `rbin`.
701
+
702
+ Returns
703
+ -------
704
+ kmean : np.array
705
+ the wavenumbers for the image, shape (`nradbins`,)
706
+
707
+ """
708
+
709
+ k = np.fft.fftshift(np.fft.fftfreq(window_length))
710
+ kx, ky = np.meshgrid(k, k)
711
+ k = np.sqrt(np.square(kx) + np.square(ky))
712
+
713
+ if rbin is None or ridx is None:
714
+ kmean, kerr = NoiseAnal.azimuthal_average(k, nradbins)
715
+ else:
716
+ kmean = ndimage.mean(k, labels=rbin, index=ridx)
717
+ return kmean
718
+
719
+ def __call__(
720
+ self, padding: bool = False, bin_: bool = True, rbin: np.array = None, ridx: np.array = None
721
+ ) -> None:
722
+ """
723
+ Analyze specified noise frame of given output image.
724
+
725
+ Parameters
726
+ ----------
727
+ padding : bool, optional
728
+ Whether to include padding postage stamps. (to be implemented)
729
+ bin_ : bool, optional
730
+ Whether to bin the 2D spectrum into L/8 x L/8 image.
731
+ Currently this is ignored, as only bin_ == True is supported.
732
+ rbin: np.array, optional, shape : (L, L)
733
+ "labels" parameter for ndimage utilities.
734
+ The default is None. If not provided, derive from image.shape.
735
+ ridx: np.array, optional, shape : (nradbins,)
736
+ "index" parameter for ndimage utilities.
737
+ The default is None. If not provided, derive from rbin.
738
+
739
+ Returns
740
+ -------
741
+ None
742
+
743
+ Notes
744
+ -----
745
+ If the image side length is not a multiple of 8, the extra pixels (`L` // 8)
746
+ are clipped.
747
+
748
+ """
749
+
750
+ L = self.cfg.NsideP # side length in px
751
+ indata = self.outim.get_coadded_layer(self.layer)
752
+ if not padding:
753
+ L = self.cfg.Nside
754
+ # padding region around the edge
755
+ bdpad = self.cfg.n2 * self.cfg.postage_pad
756
+ indata = indata[bdpad:-bdpad, bdpad:-bdpad]
757
+
758
+ s_out = self.cfg.dtheta * u.degree.to("arcsec") # in arcsec
759
+ Lcut = L // 8 * 8 # "extra" pixels will be trimmed to get a multiple of 8
760
+ norm = NoiseAnal.get_norm(self.layer, Lcut, Stn.RomanFilters[self.cfg.use_filter], s_out)
761
+
762
+ # Measure the 2D power spectrum of image.
763
+ ps = np.empty((Lcut, Lcut), dtype=np.float64)
764
+ rps = np.square(np.abs(np.fft.fftshift(np.fft.rfft2(indata[:Lcut, :Lcut]), 0))) / norm
765
+ ps[:, Lcut // 2 :] = rps[:, :-1]
766
+ ps[1:, : Lcut // 2] = rps[Lcut - 1 : 0 : -1, Lcut // 2 : 0 : -1]
767
+ ps[0, : Lcut // 2] = rps[0, Lcut // 2 : 0 : -1]
768
+ self.ps2d = np.average(np.reshape(ps, (Lcut // 8, 8, Lcut // 8, 8)), axis=(1, 3))
769
+ del rps, ps
770
+
771
+ # Calculate the azimuthally-averaged 1D power spectrum of the image.
772
+ nradbins = (
773
+ Lcut // 16
774
+ ) # Number of radial bins is side length div. into 8 from binning and then (floor) div. by 2.
775
+ ps_1d, ps_image_err = NoiseAnal.azimuthal_average(self.ps2d, nradbins, rbin, ridx)
776
+ # wavenumbers = NoiseAnal._get_wavenumbers(Lcut, nradbins)
777
+
778
+ self.ps1d = np.zeros((Lcut // 16, 2))
779
+ # self.ps1d[:, 0] = wavenumbers # powerspectrum.k
780
+ self.ps1d[:, 0] = ps_1d # powerspectrum.ps_image
781
+ self.ps1d[:, 1] = ps_image_err # powerspectrum.ps_image_err
782
+
783
+ def clear(self) -> None:
784
+ """Free up memory space."""
785
+
786
+ if hasattr(self, "ps2d"):
787
+ del self.ps2d, self.ps1d
788
+
789
+
790
+ # diagnostics/starcube_nonoise_coldescr.txt
791
+
792
+ ColDescr = Enum(
793
+ "ColDescr",
794
+ [
795
+ "RA", # 0: right ascension
796
+ "DEC", # 1: declination
797
+ # 'BLOCK_IX', # 2: block ix
798
+ # 'BLOCK_IY', # 3: block iy
799
+ "X_POS", # 4: x position in block image (float)
800
+ "Y_POS", # 5: y position in block image (float)
801
+ # 'X_INT', # 6: int part of x
802
+ # 'Y_INT', # 7: int part of y
803
+ # 'X_FRAC', # 8: frac part of x
804
+ # 'Y_FRAC', # 9: frac part of y
805
+ "AMPLITUDE", # 10: star fit -> amplitude
806
+ "OFFSET_X", # 11: star fit -> centroid offset (in output pixels), x
807
+ "OFFSET_Y", # 12: star fit -> centroid offset (in output pixels), y
808
+ "WIDTH", # 13: star fit -> sigma (in output pixels)
809
+ "SHAPE_G1", # 14: star fit -> g1 shape
810
+ "SHAPE_G2", # 15: star fit -> g2 shape
811
+ "M42_REAL", # 16: star 4th moment Re M42 (Zhang et al. 2023 MNRAS 525, 2441 convention)
812
+ "M42_IMAG", # 17: star 4th moment Im M42
813
+ "FORCED_PLUS", # 18: star moment with forced sigma=0.40 arcsec scale length [+ component] (in units of forced sigma**2) # noqa: E501
814
+ "FORCED_CROSS", # 19: star moment with forced sigma=0.40 arcsec scale length [x component]
815
+ "FIDELITY", # 20: fidelity (mean in 0.5 arcsec box)
816
+ "COVERAGE", # 21: coverage at star center
817
+ "MEAN_UC", # new: mean PSF leakage U/C (in linear space)
818
+ "MEAN_SIGMA", # new: mean noise amplification Sigma
819
+ "STD_TSUM", # new: standard deviation of total weight
820
+ "MEAN_NEFF", # new: mean effective coverage
821
+ ],
822
+ start=0,
823
+ )
824
+
825
+
826
+ class StarsAnal:
827
+ """
828
+ Analysis of point sources.
829
+
830
+ Largely based on diagnostics/starcube_nonoise.py.
831
+
832
+ Parameters
833
+ ----------
834
+ outim : OutImage
835
+ Output image to analyze.
836
+ layer : str, optional
837
+ Layer name of injected stars to analyze.
838
+
839
+ Methods
840
+ -------
841
+ __init__
842
+ Constructor.
843
+ __call__
844
+ Analyze given point source frame of given output image.
845
+ clear
846
+ Free up memory space.
847
+
848
+ """
849
+
850
+ bd = 40 # padding size
851
+ bd2 = 8
852
+ ncol = len(ColDescr)
853
+
854
+ def __init__(self, outim: OutImage, layer: str = "gsstar14") -> None:
855
+ self.outim = outim
856
+ self.layer = layer
857
+ assert layer == "gsstar14", "Error: currently only 'gsstar14' is supported"
858
+
859
+ self.cfg = outim.cfg
860
+ assert layer in ["SCI"] + self.cfg.extrainput[1:], f"Error: layer '{layer}' not found"
861
+
862
+ def __call__(
863
+ self,
864
+ n: int = None,
865
+ search_radius: float = None,
866
+ forced_scale: float = None,
867
+ bdpad: int = None,
868
+ res: int = None,
869
+ ) -> None:
870
+ """
871
+ Analyze given point source frame of given output image.
872
+
873
+ Parameters
874
+ ----------
875
+ n : int or None, optional
876
+ Size of output images.
877
+ If not provided, derive from self.cfg. Same for other parameters.
878
+ search_radius : float or None, optional
879
+ Search radius for injected point sources.
880
+ forced_scale : float or None, optional
881
+ Forced scale length for star moments.
882
+ bdpad : int or None, optional
883
+ Padding region around the edge.
884
+ res : int or None, optional
885
+ Resolution of HEALPix grid.
886
+
887
+ Returns
888
+ -------
889
+ None
890
+
891
+ """
892
+
893
+ if None in [n, search_radius, forced_scale, bdpad, res]:
894
+ n = self.cfg.NsideP # size of output images
895
+ blocksize = self.cfg.n1 * self.cfg.n2 * self.cfg.dtheta * Stn.degree # radians
896
+ search_radius = 1.5 * blocksize / np.sqrt(2.0) # search radius
897
+ forced_scale = 0.40 * u.arcsec.to("degree") / self.cfg.dtheta # in output pixels
898
+ bdpad = self.cfg.n2 * self.cfg.postage_pad # padding region around the edge
899
+ res = int(re.match(r"^gsstar(\d+)$", self.layer).group(1))
900
+ # print(n, search_radius, forced_scale, bdpad, res)
901
+
902
+ data_loaded = hasattr(self.outim, "hdu_list")
903
+ if not data_loaded:
904
+ self.outim._load_or_save_hdu_list(True)
905
+
906
+ f = self.outim.hdu_list # alias
907
+ use_slice = (["SCI"] + self.cfg.extrainput[1:]).index(self.layer)
908
+
909
+ mywcs = wcs.WCS(f[0].header)
910
+ map_ = f[0].data[0, use_slice, :, :]
911
+ wt = np.sum(np.where(f["INWEIGHT"].data[0, :, :, :] > 0.01, 1, 0), axis=0)
912
+ fmap = (
913
+ f["FIDELITY"].data[0, :, :].astype(np.float32) * HDU_to_bels(f["FIDELITY"]) / (-0.1)
914
+ ) # convert to dB, inverse scale
915
+ fmap = np.floor(fmap).astype(np.int16) # and round to integer
916
+ del f
917
+
918
+ outmaps = self.cfg.outmaps # shortcut
919
+ if "U" in outmaps:
920
+ UC_map = self.outim.get_output_map("FIDELITY")
921
+ if "S" in outmaps:
922
+ Sigma_map = self.outim.get_output_map("SIGMA")
923
+ if "T" in outmaps:
924
+ Tsum_map = self.outim.get_output_map("INWTSUM")
925
+ if "N" in outmaps:
926
+ Neff_map = self.outim.get_output_map("EFFCOVER")
927
+
928
+ if not data_loaded:
929
+ self.outim._load_or_save_hdu_list(False)
930
+
931
+ ra_cent, dec_cent = mywcs.all_pix2world(
932
+ [(n - 1) / 2], [(n - 1) / 2], [0.0], [0.0], 0, ra_dec_order=True
933
+ )
934
+ ra_cent = ra_cent[0]
935
+ dec_cent = dec_cent[0]
936
+ vec = healpy.ang2vec(ra_cent, dec_cent, lonlat=True)
937
+ qp = healpy.query_disc(2**res, vec, search_radius, nest=False)
938
+ ra_hpix, dec_hpix = healpy.pix2ang(2**res, qp, nest=False, lonlat=True)
939
+ npix = len(ra_hpix)
940
+ x, y, z1, z2 = mywcs.all_world2pix(ra_hpix, dec_hpix, np.zeros((npix,)), np.zeros((npix,)), 0)
941
+ xi = np.rint(x).astype(np.int16)
942
+ yi = np.rint(y).astype(np.int16)
943
+ grp = np.where(
944
+ np.logical_and(
945
+ np.logical_and(xi >= bdpad, xi < n - bdpad), np.logical_and(yi >= bdpad, yi < n - bdpad)
946
+ )
947
+ )
948
+ ra_hpix = ra_hpix[grp]
949
+ dec_hpix = dec_hpix[grp]
950
+ x = x[grp]
951
+ y = y[grp]
952
+ npix = len(x)
953
+ del vec, qp, z1, z2, grp
954
+
955
+ self.sub_cat = np.zeros((npix, StarsAnal.ncol))
956
+ xi = np.rint(x).astype(np.int16)
957
+ yi = np.rint(y).astype(np.int16)
958
+ # position information
959
+ self.sub_cat[:, ColDescr.RA.value] = ra_hpix
960
+ self.sub_cat[:, ColDescr.DEC.value] = dec_hpix
961
+ # self.sub_cat[:, ColDescr.BLOCK_IX.value] = self.outim.ibx
962
+ # self.sub_cat[:, ColDescr.BLOCK_IY.value] = self.outim.iby
963
+ self.sub_cat[:, ColDescr.X_POS.value] = x
964
+ self.sub_cat[:, ColDescr.Y_POS.value] = y
965
+ # self.sub_cat[:, ColDescr.X_INT .value] = xi
966
+ # self.sub_cat[:, ColDescr.Y_INT .value] = yi
967
+ dx = x - xi # self.sub_cat[:, ColDescr.X_FRAC .value] = dx = x-xi
968
+ dy = y - yi # self.sub_cat[:, ColDescr.Y_FRAC .value] = dy = y-yi
969
+ del ra_hpix, dec_hpix
970
+
971
+ bd = StarsAnal.bd # shortcut
972
+ # print(self.outim.ibx, self.outim.iby, self.outim.fpath, npix)
973
+ print(npix, end=" ")
974
+ for k in range(npix):
975
+ newimage = map_[yi[k] + 1 - bd : yi[k] + bd, xi[k] + 1 - bd : xi[k] + bd]
976
+
977
+ # PSF shape
978
+ moms = galsim.Image(newimage).FindAdaptiveMom(strict=False)
979
+ if moms.error_message != "":
980
+ continue
981
+
982
+ self.sub_cat[k, ColDescr.AMPLITUDE.value] = moms.moments_amp
983
+ self.sub_cat[k, ColDescr.OFFSET_X.value] = moms.moments_centroid.x - bd - dx[k]
984
+ self.sub_cat[k, ColDescr.OFFSET_Y.value] = moms.moments_centroid.y - bd - dy[k]
985
+ self.sub_cat[k, ColDescr.WIDTH.value] = moms.moments_sigma
986
+ self.sub_cat[k, ColDescr.SHAPE_G1.value] = moms.observed_shape.g1
987
+ self.sub_cat[k, ColDescr.SHAPE_G2.value] = moms.observed_shape.g2
988
+
989
+ # higher moments
990
+ x_, y_ = np.meshgrid(
991
+ np.arange(1, bd * 2) - moms.moments_centroid.x, np.arange(1, bd * 2) - moms.moments_centroid.y
992
+ )
993
+ e1 = moms.observed_shape.e1
994
+ e2 = moms.observed_shape.e2
995
+ Mxx = moms.moments_sigma**2 * (1 + e1) / np.sqrt(1 - e1**2 - e2**2)
996
+ Myy = moms.moments_sigma**2 * (1 - e1) / np.sqrt(1 - e1**2 - e2**2)
997
+ Mxy = moms.moments_sigma**2 * e2 / np.sqrt(1 - e1**2 - e2**2)
998
+ D = Mxx * Myy - Mxy**2
999
+ zeta = D * (Mxx + Myy + 2 * np.sqrt(D))
1000
+ u_ = ((Myy + np.sqrt(D)) * x_ - Mxy * y_) / zeta**0.5
1001
+ v_ = ((Mxx + np.sqrt(D)) * y_ - Mxy * x_) / zeta**0.5
1002
+ wti = newimage * np.exp(-0.5 * (u_**2 + v_**2))
1003
+ self.sub_cat[k, ColDescr.M42_REAL.value] = np.sum(wti * (u_**4 - v_**4)) / np.sum(wti)
1004
+ self.sub_cat[k, ColDescr.M42_IMAG.value] = (
1005
+ 2 * np.sum(wti * (u_**3 * v_ + u_ * v_**3)) / np.sum(wti)
1006
+ )
1007
+
1008
+ # moments with forced scale length
1009
+ wti2 = newimage * np.exp(-0.5 * (x_**2 + y_**2) / forced_scale**2)
1010
+ self.sub_cat[k, ColDescr.FORCED_PLUS.value] = (
1011
+ np.sum(wti2 * (x_**2 - y_**2)) / np.sum(wti2) / forced_scale**2
1012
+ )
1013
+ self.sub_cat[k, ColDescr.FORCED_CROSS.value] = (
1014
+ np.sum(wti2 * (2 * x_ * y_)) / np.sum(wti2) / forced_scale**2
1015
+ )
1016
+
1017
+ # fidelity and coverage
1018
+ central = np.s_[
1019
+ yi[k] + 1 - StarsAnal.bd2 : yi[k] + StarsAnal.bd2,
1020
+ xi[k] + 1 - StarsAnal.bd2 : xi[k] + StarsAnal.bd2,
1021
+ ] # central region of the star
1022
+ self.sub_cat[k, ColDescr.FIDELITY.value] = np.mean(fmap[central])
1023
+ self.sub_cat[k, ColDescr.COVERAGE.value] = wt[yi[k] // self.cfg.n2, xi[k] // self.cfg.n2]
1024
+
1025
+ # new columns based on output maps
1026
+ self.sub_cat[k, ColDescr.MEAN_UC.value] = np.mean(UC_map[central]) if "U" in outmaps else -1
1027
+ self.sub_cat[k, ColDescr.MEAN_SIGMA.value] = np.mean(Sigma_map[central]) if "S" in outmaps else -1
1028
+ self.sub_cat[k, ColDescr.STD_TSUM.value] = np.std(Tsum_map[central]) if "T" in outmaps else -1
1029
+ if self.cfg.linear_algebra == "Empirical":
1030
+ self.sub_cat[k, ColDescr.STD_TSUM.value] = 0
1031
+ self.sub_cat[k, ColDescr.MEAN_NEFF.value] = np.mean(Neff_map[central]) if "N" in outmaps else -1
1032
+
1033
+ del newimage, x_, y_, u_, v_, wti, wti2
1034
+
1035
+ del map_, wt, fmap
1036
+ del x, y, xi, yi, dx, dy
1037
+
1038
+ if "U" in outmaps:
1039
+ del UC_map
1040
+ if "S" in outmaps:
1041
+ del Sigma_map
1042
+ if "T" in outmaps:
1043
+ del Tsum_map
1044
+ if "N" in outmaps:
1045
+ del Neff_map
1046
+
1047
+ def clear(self) -> None:
1048
+ """
1049
+ Free up memory space.
1050
+
1051
+ Returns
1052
+ -------
1053
+ None.
1054
+
1055
+ """
1056
+
1057
+ if hasattr(self, "sub_cat"):
1058
+ del self.sub_cat
1059
+
1060
+
1061
+ class _BlkGrp:
1062
+ """
1063
+ Abstract base class for groups of blocks (mosiacs or suites).
1064
+
1065
+ Methods
1066
+ -------
1067
+ __call__
1068
+ Run all the analyses below.
1069
+ get_consump_map
1070
+ Get map of time consumption.
1071
+ get_coverage_map
1072
+ Get map of mean coverages.
1073
+ get_noise_power_spectra
1074
+ Analyze noise power spectra of this mosaic.
1075
+ get_star_catalog
1076
+ Analyze injected point sources of this mosaic.
1077
+ clear
1078
+ Free up memory space.
1079
+
1080
+ """
1081
+
1082
+ def __call__(self, overwrite: bool = False) -> None:
1083
+ """
1084
+ Run all the analyses below.
1085
+
1086
+ Parameters
1087
+ ----------
1088
+ overwrite : bool, optional
1089
+ Whether to overwrite existing results.
1090
+
1091
+ Returns
1092
+ -------
1093
+ None
1094
+
1095
+ """
1096
+
1097
+ self.get_consump_map(overwrite=overwrite) # Get map of time consumption.
1098
+ self.get_coverage_map(overwrite=overwrite) # Get map of mean coverages.
1099
+ self.get_noise_power_spectra(overwrite=overwrite) # Analyze noise power spectra of this mosaic.
1100
+ self.get_star_catalog(overwrite=overwrite) # Analyze injected point sources of this mosaic.
1101
+
1102
+ def get_consump_map(self, overwrite: bool = False) -> None:
1103
+ """
1104
+ Get map of time consumption.
1105
+
1106
+ Parameters
1107
+ ----------
1108
+ overwrite : bool, optional
1109
+ Whether to overwrite existing results.
1110
+
1111
+ Returns
1112
+ -------
1113
+ None
1114
+
1115
+ """
1116
+
1117
+ fname = self.cfg.outstem + "_Consump.npy"
1118
+ if not overwrite and exists(fname):
1119
+ with open(fname, "rb") as f:
1120
+ self.consump_map = np.load(f)
1121
+ return
1122
+
1123
+ if self.ndim == 2: # Mosaic
1124
+ nblock = self.cfg.nblock # shortcut
1125
+ self.consump_map = np.zeros((nblock, nblock))
1126
+ for iby in range(nblock):
1127
+ for ibx in range(nblock):
1128
+ self.consump_map[iby][ibx] = self.outimages[iby][ibx].get_time_consump()
1129
+
1130
+ elif self.ndim == 1: # Suite
1131
+ nrun = self.nrun
1132
+ self.consump_map = np.zeros((nrun,))
1133
+ for ib in range(nrun):
1134
+ self.consump_map[ib] = self.outimages[ib].get_time_consump()
1135
+
1136
+ with open(fname, "wb") as f:
1137
+ np.save(f, self.consump_map)
1138
+
1139
+ def get_coverage_map(self, overwrite: bool = False) -> None:
1140
+ """
1141
+ Get map of mean coverages.
1142
+
1143
+ Parameters
1144
+ ----------
1145
+ overwrite : bool, optional
1146
+ Whether to overwrite existing results.
1147
+
1148
+ Returns
1149
+ -------
1150
+ None
1151
+
1152
+ """
1153
+
1154
+ fname = self.cfg.outstem + "_Coverage.npy"
1155
+ if not overwrite and exists(fname):
1156
+ with open(fname, "rb") as f:
1157
+ self.coverage_map = np.load(f)
1158
+ return
1159
+
1160
+ if self.ndim == 2: # Mosaic
1161
+ nblock = self.cfg.nblock # shortcut
1162
+ self.coverage_map = np.zeros((nblock, nblock))
1163
+ for iby in range(nblock):
1164
+ for ibx in range(nblock):
1165
+ self.coverage_map[iby][ibx] = self.outimages[iby][ibx].get_mean_coverage()
1166
+
1167
+ elif self.ndim == 1: # Suite
1168
+ nrun = self.nrun
1169
+ self.coverage_map = np.zeros((nrun,))
1170
+ for ib in range(nrun):
1171
+ self.coverage_map[ib] = self.outimages[ib].get_mean_coverage()
1172
+
1173
+ with open(fname, "wb") as f:
1174
+ np.save(f, self.coverage_map)
1175
+
1176
+ def get_noise_power_spectra(self, bins: int = 5, overwrite: bool = False) -> None:
1177
+ """
1178
+ Analyze noise power spectra of this mosaic.
1179
+
1180
+ The output noise power spectra are written to ``self.cfg.outstem + "_NoisePS.npz"``. These
1181
+ are in the format:
1182
+
1183
+ * `ps2d_all` = 2D power spectrum, [which_noise_layer, ybin, xbin]
1184
+ * `ps1d_all` = 1D power spectrum, [which_noise_layer, coverage_bin, wavenumber_bin, value_or_err]
1185
+ * `wavenumber` = 1D array of wavenumbers
1186
+
1187
+ Parameters
1188
+ ----------
1189
+ bins : int, optional
1190
+ Number of coverage bins for 1D power spectra.
1191
+ overwrite : bool, optional
1192
+ Whether to overwrite existing results.
1193
+
1194
+ Returns
1195
+ -------
1196
+ None
1197
+
1198
+ """
1199
+
1200
+ fname = self.cfg.outstem + "_NoisePS.npz"
1201
+ if not overwrite and exists(fname):
1202
+ with np.load(fname) as f:
1203
+ self.ps2d_all = f["ps2d_all"]
1204
+ self.ps1d_all = f["ps1d_all"]
1205
+ self.wavenumbers = f["wavenumbers"]
1206
+ return
1207
+
1208
+ timer = Timer()
1209
+
1210
+ # identify noise layers
1211
+ noiseinput = [layer for layer in self.cfg.extrainput[1:] if "noise" in layer]
1212
+ n_innoise = len(noiseinput)
1213
+ print(noiseinput)
1214
+
1215
+ # mean coverage bins
1216
+ if not hasattr(self, "coverage_map"):
1217
+ self.get_coverage_map()
1218
+
1219
+ mc_max = self.coverage_map.max() + 1e-12
1220
+ mc_min = self.coverage_map.min() - 1e-12
1221
+ coverage_idx = ((self.coverage_map - mc_min) / (mc_max - mc_min) * bins).astype(np.uint8)
1222
+ unique, counts = np.unique(coverage_idx, return_counts=True)
1223
+
1224
+ # create storage
1225
+ L = self.cfg.Nside if not self.padding else self.cfg.NsideP
1226
+
1227
+ self.ps2d_all = np.zeros((n_innoise, L // 8, L // 8))
1228
+ self.ps1d_all = np.zeros((n_innoise, bins, L // 16, 2))
1229
+
1230
+ # derive rbin and ridx for NoiseAnal.azimuthal_average
1231
+ nradbins = (
1232
+ L // 16
1233
+ ) # Number of radial bins is side length div. into 8 from binning and then (floor) div. by 2.
1234
+ yy, xx = np.mgrid[: L // 8, : L // 8]
1235
+ r = np.hypot(xx - L // 8 / 2, yy - L // 8 / 2)
1236
+ rbin = (nradbins * r / r.max()).astype(int)
1237
+ ridx = np.arange(1, rbin.max() + 1)
1238
+ del yy, xx, r
1239
+
1240
+ self.wavenumbers = NoiseAnal._get_wavenumbers(L, nradbins)
1241
+ # dividing by s_out converts from units of cyc/s_out to cyc/arcsec
1242
+ self.wavenumbers /= self.cfg.dtheta * u.degree.to("arcsec")
1243
+
1244
+ # loop over noise layers and output images
1245
+ if self.ndim == 2: # Mosaic
1246
+ for iby in range(self.cfg.nblock):
1247
+ print(f" > row {iby:2d} t= {timer():9.2f} s")
1248
+ for inl, layer in enumerate(noiseinput):
1249
+ for ibx in range(self.cfg.nblock):
1250
+ noise = NoiseAnal(self.outimages[iby][ibx], layer)
1251
+ noise(padding=self.padding, rbin=rbin, ridx=ridx)
1252
+ self.ps2d_all[inl, :, :] += noise.ps2d
1253
+ self.ps1d_all[inl, coverage_idx[iby][ibx], :, :] += noise.ps1d[:, :]
1254
+ noise.clear()
1255
+ del noise
1256
+
1257
+ elif self.ndim == 1: # Suite
1258
+ nrun = self.nrun
1259
+ for inl, layer in enumerate(noiseinput):
1260
+ for ib in range(nrun):
1261
+ noise = NoiseAnal(self.outimages[ib], layer)
1262
+ noise(padding=self.padding, rbin=rbin, ridx=ridx)
1263
+ self.ps2d_all[inl, :, :] += noise.ps2d
1264
+ self.ps1d_all[inl, coverage_idx[ib], :, :] += noise.ps1d[:, :]
1265
+ noise.clear()
1266
+ del noise
1267
+
1268
+ del rbin, ridx
1269
+
1270
+ # postprocessing
1271
+ if self.ndim == 2: # Mosaic
1272
+ self.ps2d_all /= self.cfg.nblock**2
1273
+ elif self.ndim == 1: # Suite
1274
+ self.ps2d_all /= self.nrun
1275
+
1276
+ for idx, count in zip(unique, counts, strict=False):
1277
+ self.ps1d_all[:, idx, :, :] /= count
1278
+ del coverage_idx, counts
1279
+
1280
+ np.savez(fname, ps2d_all=self.ps2d_all, ps1d_all=self.ps1d_all, wavenumbers=self.wavenumbers)
1281
+ print(f"finished at t = {timer():.2f} s")
1282
+
1283
+ def get_star_catalog(self, layer: str = "gsstar14", overwrite: bool = False) -> None:
1284
+ """
1285
+ Analyze injected point sources of this mosaic.
1286
+
1287
+ Parameters
1288
+ ----------
1289
+ layer : str, optional
1290
+ Layer name of injected stars to analyze.
1291
+ overwrite : bool, optional
1292
+ Whether to overwrite existing results.
1293
+
1294
+ Returns
1295
+ -------
1296
+ None
1297
+
1298
+ """
1299
+
1300
+ fname = self.cfg.outstem + "_StarCat.npy"
1301
+ if not overwrite and exists(fname):
1302
+ with open(fname, "rb") as f:
1303
+ self.star_cat = np.load(f)
1304
+ return
1305
+
1306
+ timer = Timer()
1307
+ self.star_cat = np.zeros((0, StarsAnal.ncol))
1308
+
1309
+ n = self.cfg.NsideP # size of output images
1310
+ blocksize = self.cfg.n1 * self.cfg.n2 * self.cfg.dtheta * Stn.degree # radians
1311
+ search_radius = 1.5 * blocksize / np.sqrt(2.0) # search radius
1312
+ forced_scale = 0.40 * u.arcsec.to("degree") / self.cfg.dtheta # in output pixels
1313
+ bdpad = self.cfg.n2 * self.cfg.postage_pad # padding region around the edge
1314
+ res = int(re.match(r"^gsstar(\d+)$", layer).group(1))
1315
+ # print(n, search_radius, forced_scale, bdpad, res)
1316
+
1317
+ # loop over output images
1318
+ if self.ndim == 2: # Mosaic
1319
+ for iby in range(self.cfg.nblock):
1320
+ print(f" > row {iby:2d} t= {timer():9.2f} s")
1321
+
1322
+ print("star counts:", end=" ")
1323
+ for ibx in range(self.cfg.nblock):
1324
+ stars = StarsAnal(self.outimages[iby][ibx], layer)
1325
+ stars(n, search_radius, forced_scale, bdpad, res)
1326
+ self.star_cat = np.concatenate((self.star_cat, stars.sub_cat), axis=0)
1327
+ stars.clear()
1328
+ del stars
1329
+ print()
1330
+
1331
+ elif self.ndim == 1: # Suite
1332
+ nrun = self.nrun
1333
+ print("star counts:", end=" ")
1334
+ for ib in range(nrun):
1335
+ stars = StarsAnal(self.outimages[ib], layer)
1336
+ stars(n, search_radius, forced_scale, bdpad, res)
1337
+ self.star_cat = np.concatenate((self.star_cat, stars.sub_cat), axis=0)
1338
+ stars.clear()
1339
+ del stars
1340
+ print()
1341
+
1342
+ with open(fname, "wb") as f:
1343
+ np.save(f, self.star_cat)
1344
+
1345
+ print(f"finished at t = {timer():.2f} s")
1346
+
1347
+ def clear(self) -> None:
1348
+ """Free up memory space."""
1349
+
1350
+ if self.ndim == 2: # Mosaic
1351
+ for ibx in range(self.cfg.nblock):
1352
+ for iby in range(self.cfg.nblock):
1353
+ self.outimages[iby][ibx] = None
1354
+
1355
+ elif self.ndim == 1: # Suite
1356
+ for ib in range(self.nrun):
1357
+ self.outimages[ib] = None
1358
+
1359
+ if hasattr(self, "consump_map"):
1360
+ del self.consump_map
1361
+ if hasattr(self, "coverage_map"):
1362
+ del self.coverage_map
1363
+ if hasattr(self, "ps2d_all"):
1364
+ del self.ps2d_all, self.ps1d_all
1365
+ if hasattr(self, "star_cat"):
1366
+ del self.star_cat
1367
+
1368
+
1369
+ class Mosaic(_BlkGrp):
1370
+ """
1371
+ Wrapper for coadded mosaics (2D arrays of blocks).
1372
+
1373
+ Parameters
1374
+ ----------
1375
+ cfg : Config
1376
+ Configuration used for this output mosaic.
1377
+
1378
+ Methods
1379
+ -------
1380
+ __init__
1381
+ Constructor.
1382
+ share_padding_stamps
1383
+ Share padding postage stamps between adjacent blocks.
1384
+
1385
+ """
1386
+
1387
+ ndim = 2
1388
+ padding = False # for get_noise_power_spectra
1389
+
1390
+ def __init__(self, cfg: Config) -> None:
1391
+ """Constructor."""
1392
+
1393
+ cfg()
1394
+ self.cfg = cfg
1395
+ self.hdu_names = OutImage.get_hdu_names(cfg.outmaps)
1396
+
1397
+ self.outimages = [[None for ibx in range(cfg.nblock)] for iby in range(cfg.nblock)]
1398
+ for ibx in range(cfg.nblock):
1399
+ for iby in range(cfg.nblock):
1400
+ fpath = cfg.outstem + f"_{ibx:02d}_{iby:02d}.fits"
1401
+ self.outimages[iby][ibx] = OutImage(fpath, cfg, self.hdu_names)
1402
+
1403
+ def share_padding_stamps(self) -> None:
1404
+ """
1405
+ Share padding postage stamps between adjacent blocks.
1406
+
1407
+ Returns
1408
+ -------
1409
+ None
1410
+
1411
+ """
1412
+
1413
+ assert self.cfg.pad_sides == "auto", "Error: share_padding_stamps only supports pad_sides == 'auto'"
1414
+ nblock = self.cfg.nblock # shortcut
1415
+ timer = Timer()
1416
+
1417
+ print(" > horizontal sharing")
1418
+ for iby in range(nblock):
1419
+ print(f" > row {iby:2d} t= {timer():9.2f} s")
1420
+ self.outimages[iby][0]._load_or_save_hdu_list(True)
1421
+ for ibx in range(nblock - 1):
1422
+ self.outimages[iby][ibx + 1]._load_or_save_hdu_list(True)
1423
+ self.outimages[iby][ibx]._update_hdu_data(self.outimages[iby][ibx + 1], "right", True)
1424
+ self.outimages[iby][ibx + 1]._update_hdu_data(self.outimages[iby][ibx], "left", False)
1425
+ self.outimages[iby][ibx]._load_or_save_hdu_list(False, save_file=True)
1426
+ self.outimages[iby][nblock - 1]._load_or_save_hdu_list(False, save_file=True)
1427
+ print(flush=True)
1428
+
1429
+ print(" > vertical sharing")
1430
+ for ibx in range(nblock):
1431
+ print(f" > column {ibx:2d} t= {timer():9.2f} s")
1432
+ self.outimages[0][ibx]._load_or_save_hdu_list(True)
1433
+ for iby in range(nblock - 1):
1434
+ self.outimages[iby + 1][ibx]._load_or_save_hdu_list(True)
1435
+ self.outimages[iby][ibx]._update_hdu_data(self.outimages[iby + 1][ibx], "top", True)
1436
+ self.outimages[iby + 1][ibx]._update_hdu_data(self.outimages[iby][ibx], "bottom", False)
1437
+ self.outimages[iby][ibx]._load_or_save_hdu_list(False, save_file=True, auto_to_all=True)
1438
+ self.outimages[nblock - 1][ibx]._load_or_save_hdu_list(False, save_file=True, auto_to_all=True)
1439
+ print(flush=True)
1440
+
1441
+ print(f"finished at t = {timer():.2f} s")
1442
+
1443
+
1444
+ class Suite(_BlkGrp):
1445
+ """
1446
+ Wrapper for coadded suites (hashed arrays of blocks).
1447
+
1448
+ Parameters
1449
+ ----------
1450
+ cfg : Config
1451
+ Configuration used for this output mosaic.
1452
+ prime : int, optional
1453
+ Prime number for hashing (Paper IV).
1454
+ nrun : int, optional
1455
+ Number of coadded blocks (Paper IV).
1456
+
1457
+ Methods
1458
+ -------
1459
+ __init__
1460
+ Constructor.
1461
+
1462
+ """
1463
+
1464
+ ndim = 1
1465
+ padding = True # for get_noise_power_spectra
1466
+
1467
+ def __init__(self, cfg: Config, prime: int = 691, nrun: int = 16) -> None:
1468
+ """Constructor."""
1469
+
1470
+ cfg()
1471
+ self.cfg = cfg
1472
+ self.hdu_names = OutImage.get_hdu_names(cfg.outmaps)
1473
+
1474
+ self.prime = prime
1475
+ self.nrun = nrun
1476
+ self.outimages = [None for ib in range(nrun)]
1477
+ for ib in range(nrun):
1478
+ ibx, iby = divmod(ib * prime % cfg.nblock**2, cfg.nblock)
1479
+ fpath = cfg.outstem + f"_{ibx:02d}_{iby:02d}.fits"
1480
+ self.outimages[ib] = OutImage(fpath, cfg, self.hdu_names)