hyper-py-photometry 0.1.0__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.
- hyper_py/__init__.py +1 -0
- hyper_py/bkg_multigauss.py +524 -0
- hyper_py/bkg_single.py +477 -0
- hyper_py/config.py +43 -0
- hyper_py/create_background_slices.py +160 -0
- hyper_py/data_output.py +132 -0
- hyper_py/detection.py +142 -0
- hyper_py/extract_cubes.py +42 -0
- hyper_py/fitting.py +562 -0
- hyper_py/gaussfit.py +519 -0
- hyper_py/groups.py +66 -0
- hyper_py/hyper.py +150 -0
- hyper_py/logger.py +73 -0
- hyper_py/map_io.py +73 -0
- hyper_py/paths_io.py +122 -0
- hyper_py/photometry.py +114 -0
- hyper_py/run_hyper.py +45 -0
- hyper_py/single_map.py +716 -0
- hyper_py/survey.py +70 -0
- hyper_py/visualization.py +150 -0
- hyper_py_photometry-0.1.0.dist-info/METADATA +514 -0
- hyper_py_photometry-0.1.0.dist-info/RECORD +26 -0
- hyper_py_photometry-0.1.0.dist-info/WHEEL +5 -0
- hyper_py_photometry-0.1.0.dist-info/entry_points.txt +4 -0
- hyper_py_photometry-0.1.0.dist-info/licenses/LICENSE +13 -0
- hyper_py_photometry-0.1.0.dist-info/top_level.txt +1 -0
hyper_py/single_map.py
ADDED
|
@@ -0,0 +1,716 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
from astropy.coordinates import SkyCoord
|
|
6
|
+
from astropy.io import ascii, fits
|
|
7
|
+
from astropy.stats import SigmaClip, sigma_clipped_stats
|
|
8
|
+
from astropy.wcs import WCS
|
|
9
|
+
|
|
10
|
+
from collections.abc import Iterable
|
|
11
|
+
|
|
12
|
+
from hyper_py.paths_io import get_hyper_single_map_paths
|
|
13
|
+
from hyper_py.survey import get_beam_info
|
|
14
|
+
from hyper_py.map_io import read_and_prepare_map
|
|
15
|
+
from hyper_py.detection import detect_sources
|
|
16
|
+
from hyper_py.data_output import write_tables
|
|
17
|
+
from hyper_py.groups import group_sources
|
|
18
|
+
from hyper_py.photometry import aperture_photometry_on_sources
|
|
19
|
+
from hyper_py.gaussfit import fit_isolated_gaussian
|
|
20
|
+
from hyper_py.fitting import fit_group_with_background
|
|
21
|
+
from hyper_py.visualization import plot_fit_summary
|
|
22
|
+
from hyper_py.logger import setup_logger
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def main(map_name=None, cfg=None, dir_root=None, logger=None, logger_file_only=None):
|
|
26
|
+
|
|
27
|
+
paths_dict = get_hyper_single_map_paths(cfg, map_name)
|
|
28
|
+
|
|
29
|
+
# - input/output paths - #
|
|
30
|
+
datacube = cfg.get("control", "datacube", False)
|
|
31
|
+
|
|
32
|
+
dir_root = cfg.get("paths", "output")["dir_root"]
|
|
33
|
+
|
|
34
|
+
if datacube:
|
|
35
|
+
input_map_path = Path(dir_root, cfg.get("control")["dir_datacube_slices"], map_name)
|
|
36
|
+
else:
|
|
37
|
+
input_map_path = paths_dict["input_map_path"]
|
|
38
|
+
|
|
39
|
+
output_dir_path = paths_dict["output_dir_path"]
|
|
40
|
+
|
|
41
|
+
base_name_with_suffix = paths_dict["base_name_with_suffix"]
|
|
42
|
+
centroids_file = paths_dict["centroids_file"]
|
|
43
|
+
ellipses_file = paths_dict["ellipses_file"]
|
|
44
|
+
suffix = paths_dict["suffix"]
|
|
45
|
+
|
|
46
|
+
# - control - #
|
|
47
|
+
detection_only = cfg.get("control", "detection_only", False)
|
|
48
|
+
fixed_radius = cfg.get("photometry", "fixed_radius", False)
|
|
49
|
+
|
|
50
|
+
# - params - #
|
|
51
|
+
survey_code = cfg.get("survey", "survey_code")
|
|
52
|
+
|
|
53
|
+
# - visualization params - #
|
|
54
|
+
try:
|
|
55
|
+
visualize_deblended = cfg.get("visualization", "visualize_deblended", False)
|
|
56
|
+
visualize_output_dir_deblended = os.path.join(dir_root, cfg.get("visualization", "output_dir_deblended", "images/deblended"))
|
|
57
|
+
except:
|
|
58
|
+
visualize_deblended = False
|
|
59
|
+
|
|
60
|
+
# - Fits save params - #
|
|
61
|
+
try:
|
|
62
|
+
fits_deblended = cfg.get("fits_output", "fits_deblended", False)
|
|
63
|
+
fits_output_dir_deblended = os.path.join(dir_root, cfg.get("fits_output", "fits_output_dir_deblended", "fits/deblended"))
|
|
64
|
+
except:
|
|
65
|
+
fits_deblended = False
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# - Setup log file specifically for this map - #
|
|
69
|
+
log_new_map_file = f"Hyper_log_{suffix}.log"
|
|
70
|
+
log_new_map_dir = cfg.get("paths")["output"]["dir_log_out"]
|
|
71
|
+
log_path_each_map = os.path.join(dir_root, log_new_map_dir, log_new_map_file)
|
|
72
|
+
|
|
73
|
+
# Give this map a unique logger name
|
|
74
|
+
if logger is None:
|
|
75
|
+
logger, logger_file_only = setup_logger(
|
|
76
|
+
log_path=log_path_each_map,
|
|
77
|
+
logger_name=f"HyperLogger_{suffix}", # unique name per map
|
|
78
|
+
overwrite=True,
|
|
79
|
+
process_name=os.path.basename(input_map_path) # Use the map name as the process name
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# initialize vectors and Table
|
|
83
|
+
radius_val_1 = []
|
|
84
|
+
radius_val_2 = []
|
|
85
|
+
PA_val = []
|
|
86
|
+
sky_val = []
|
|
87
|
+
poly_order_val = []
|
|
88
|
+
nmse_val = []
|
|
89
|
+
redchi_val = []
|
|
90
|
+
bic_val = []
|
|
91
|
+
updated_xcen = []
|
|
92
|
+
updated_ycen = []
|
|
93
|
+
flux_peak = []
|
|
94
|
+
flux = []
|
|
95
|
+
flux_err = []
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
fit_statuts_val = []
|
|
99
|
+
deblend_val = []
|
|
100
|
+
cluster_val = []
|
|
101
|
+
|
|
102
|
+
source_id_save = []
|
|
103
|
+
|
|
104
|
+
bg_model = None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# --- see if peaks position and aperture radius are fixed or not --- #
|
|
108
|
+
def ensure_list(x, n):
|
|
109
|
+
return x if isinstance(x, Iterable) and not isinstance(x, str) else [x] * n
|
|
110
|
+
|
|
111
|
+
# --- Load fixed table if specified --- #
|
|
112
|
+
use_fixed_table = cfg.get("detection", "use_fixed_source_table", False)
|
|
113
|
+
fixed_radius = cfg.get("photometry", "fixed_radius", False)
|
|
114
|
+
fixed_peaks = cfg.get("detection", "fixed_peaks", False)
|
|
115
|
+
|
|
116
|
+
if use_fixed_table:
|
|
117
|
+
table_path = os.path.join(dir_root, cfg.get("detection", "fixed_source_table_path"))
|
|
118
|
+
fixed_sources = ascii.read(table_path, format="ipac")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# - read header and map - #
|
|
122
|
+
beam_arcsec, beam_area_arcsec2, beam_area_sr = get_beam_info(survey_code, input_map_path)
|
|
123
|
+
|
|
124
|
+
map_struct = read_and_prepare_map(
|
|
125
|
+
filepath=input_map_path,
|
|
126
|
+
beam=beam_arcsec,
|
|
127
|
+
beam_area_arcsec2=beam_area_arcsec2,
|
|
128
|
+
beam_area_sr = beam_area_sr,
|
|
129
|
+
convert_mjy=cfg.get("units", "convert_mJy")
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
real_map = map_struct["map"]
|
|
133
|
+
|
|
134
|
+
# # --- zero-mean for the input map --- #
|
|
135
|
+
# map_zero_mean = real_map - np.nanmean(real_map)
|
|
136
|
+
# real_map = map_zero_mean
|
|
137
|
+
|
|
138
|
+
header = map_struct["header"]
|
|
139
|
+
wcs = WCS(header)
|
|
140
|
+
pix_dim = map_struct["pix_dim"]
|
|
141
|
+
beam_dim = map_struct["beam_dim"]
|
|
142
|
+
beam_area = map_struct["beam_area_arcsec2"]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# --- map rms used to define real sources in the map - accounting for non-zero background --- #
|
|
146
|
+
use_maual_rms = cfg.get("detection", "use_manual_rms", False)
|
|
147
|
+
if use_maual_rms == True:
|
|
148
|
+
real_rms = cfg.get("detection", "rms_value", False)
|
|
149
|
+
else:
|
|
150
|
+
sigma_clip = SigmaClip(sigma=3.0, maxiters=10)
|
|
151
|
+
map_zero_mean_detect = real_map - np.nanmean(real_map)
|
|
152
|
+
clipped = sigma_clip(map_zero_mean_detect)
|
|
153
|
+
real_rms = np.sqrt(np.nanmean(clipped**2))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# --- run sources identification --- #
|
|
157
|
+
if fixed_peaks:
|
|
158
|
+
if use_fixed_table:
|
|
159
|
+
logger.info("[INFO] Using manually provided peak coordinates from IPAC table.")
|
|
160
|
+
xcen = np.array(fixed_sources["RA"]) # these are assumed to be in world coordinates
|
|
161
|
+
ycen = np.array(fixed_sources["DEC"])
|
|
162
|
+
|
|
163
|
+
# Convert WCS coordinates to pixel positions
|
|
164
|
+
xpix, ypix = wcs.wcs_world2pix(xcen, ycen, 0)
|
|
165
|
+
xcen = xpix
|
|
166
|
+
ycen = ypix
|
|
167
|
+
|
|
168
|
+
else:
|
|
169
|
+
logger.info("[INFO] Using manually provided peak coordinates from config file.")
|
|
170
|
+
xcen_fix = cfg.get("detection", "xcen_fix")
|
|
171
|
+
ycen_fix = cfg.get("detection", "ycen_fix")
|
|
172
|
+
|
|
173
|
+
# Convert WCS coordinates to pixel positions
|
|
174
|
+
xpix, ypix = wcs.wcs_world2pix(xcen_fix, ycen_fix, 0)
|
|
175
|
+
xcen = xpix
|
|
176
|
+
ycen = ypix
|
|
177
|
+
|
|
178
|
+
sources = xcen
|
|
179
|
+
all_sources_xcen = xcen
|
|
180
|
+
all_sources_ycen = ycen
|
|
181
|
+
|
|
182
|
+
else:
|
|
183
|
+
sources = detect_sources(
|
|
184
|
+
map_struct_list=map_struct,
|
|
185
|
+
dist_limit_arcsec=cfg.get("detection", "dist_limit_arcsec", 0),
|
|
186
|
+
real_map=map_zero_mean_detect,
|
|
187
|
+
rms_real=real_rms,
|
|
188
|
+
snr_threshold=cfg.get("detection", "sigma_thres"),
|
|
189
|
+
roundlim=cfg.get("detection", "roundlim", [-1.0, 1.0]),
|
|
190
|
+
sharplim=cfg.get("detection", "sharplim", [-1.0, 2.0]),
|
|
191
|
+
config=cfg
|
|
192
|
+
)
|
|
193
|
+
xcen = sources["xcentroid"]
|
|
194
|
+
ycen = sources["ycentroid"]
|
|
195
|
+
|
|
196
|
+
all_sources_xcen = xcen
|
|
197
|
+
all_sources_ycen = ycen
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# -- if fixed_radius = True generate a xcen vector of aperture radii -- #
|
|
201
|
+
if fixed_radius:
|
|
202
|
+
if use_fixed_table:
|
|
203
|
+
fwhm_1_list = np.array(fixed_sources["FWHM_1"])
|
|
204
|
+
fwhm_2_list = np.array(fixed_sources["FWHM_2"])
|
|
205
|
+
PA_list = np.array(fixed_sources["PA"])
|
|
206
|
+
else:
|
|
207
|
+
N = len(xcen)
|
|
208
|
+
fwhm_1_list = ensure_list(cfg.get("photometry", "fwhm_1", 3.0), N)
|
|
209
|
+
fwhm_2_list = ensure_list(cfg.get("photometry", "fwhm_2", 2.0), N)
|
|
210
|
+
PA_list = ensure_list(cfg.get("photometry", "PA_val", 0.0), N)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
# --- organize sources in isolated or groups ---#
|
|
214
|
+
start_group, common_group, deblend = group_sources(
|
|
215
|
+
xcen=xcen,
|
|
216
|
+
ycen=ycen,
|
|
217
|
+
pix_dim=pix_dim,
|
|
218
|
+
beam_dim=beam_dim,
|
|
219
|
+
aper_sup=cfg.get("photometry", "aper_sup"),
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
tot_sources = len(sources)
|
|
223
|
+
isolated = np.where(start_group == 0)[0]
|
|
224
|
+
blended = np.where(start_group == 1)[0]
|
|
225
|
+
|
|
226
|
+
logger.info(f"{tot_sources} sources above threshold.")
|
|
227
|
+
logger.info(f"{len(isolated)} sources are isolated")
|
|
228
|
+
logger.info(f"{len(blended)} sources are blended")
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
if detection_only:
|
|
232
|
+
logger.info("[INFO] Detection-only mode enabled. Skipping photometry and fitting.")
|
|
233
|
+
|
|
234
|
+
# Convert to sky coordinates
|
|
235
|
+
x_pixels = np.array(xcen, dtype=np.float64)
|
|
236
|
+
y_pixels = np.array(ycen, dtype=np.float64)
|
|
237
|
+
ra, dec = wcs.wcs_pix2world(x_pixels, y_pixels, 0)
|
|
238
|
+
ra_save = np.where(ra < 0., ra + 360., ra)
|
|
239
|
+
|
|
240
|
+
skycoords = SkyCoord(ra=ra, dec=dec, unit='deg', frame='icrs')
|
|
241
|
+
glon = skycoords.galactic.l.deg
|
|
242
|
+
glat = skycoords.galactic.b.deg
|
|
243
|
+
|
|
244
|
+
# Prepare zeroed output table
|
|
245
|
+
N = len(xcen)
|
|
246
|
+
data_dict = {
|
|
247
|
+
"MAP_ID": [str(suffix)],
|
|
248
|
+
"HYPER_ID": [0],
|
|
249
|
+
"FLUX_PEAK": [0.0],
|
|
250
|
+
"FLUX": [0.0],
|
|
251
|
+
"FLUX_ERR": [0.0],
|
|
252
|
+
"RESIDUALS": [0.0],
|
|
253
|
+
"POLYN": [0],
|
|
254
|
+
"NMSE": [0.0],
|
|
255
|
+
"CHI2_RED": [0.0],
|
|
256
|
+
"FWHM_1": [0.0],
|
|
257
|
+
"FWHM_2": [0.0],
|
|
258
|
+
"PA": [0.0],
|
|
259
|
+
"STATUS": [0],
|
|
260
|
+
"GLON": [0.0],
|
|
261
|
+
"GLAT": [0.0],
|
|
262
|
+
"RA": [0.0],
|
|
263
|
+
"DEC": [0.0],
|
|
264
|
+
"DEBLEND": [0],
|
|
265
|
+
"CLUSTER": [0],
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
sigma_thres = cfg.get("detection", "sigma_thres")
|
|
269
|
+
write_tables(data_dict, output_dir_path, cfg, sigma_thres, real_rms, base_filename=base_name_with_suffix)
|
|
270
|
+
|
|
271
|
+
######################## Write only the centroid region file ########################
|
|
272
|
+
radecsys = (header.get("RADESYS") or header.get("RADECSYS") or wcs.wcs.radesys or "FK5").strip().upper()
|
|
273
|
+
if "ICRS" in radecsys:
|
|
274
|
+
ds9_coordsys = "icrs"
|
|
275
|
+
elif "FK5" in radecsys:
|
|
276
|
+
ds9_coordsys = "fk5"
|
|
277
|
+
elif "GAL" in radecsys:
|
|
278
|
+
ds9_coordsys = "galactic"
|
|
279
|
+
else:
|
|
280
|
+
ds9_coordsys = "fk5"
|
|
281
|
+
|
|
282
|
+
with open(centroids_file, "w") as f:
|
|
283
|
+
f.write("# Region file format: DS9 version 4.1\n")
|
|
284
|
+
f.write("global color=cyan dashlist=8 3 width=1 font='helvetica 10 normal' select=1 "
|
|
285
|
+
"highlite=1 edit=1 move=1 delete=1 include=1 fixed=0\n")
|
|
286
|
+
f.write(f"{ds9_coordsys}\n")
|
|
287
|
+
for xw, yw in zip(ra_save, dec):
|
|
288
|
+
f.write(f"point({xw:.8f},{yw:.8f}) # point=cross\n")
|
|
289
|
+
|
|
290
|
+
logger.info(f"Detection-only mode complete. Saved table and centroid region file for {N} sources.\n")
|
|
291
|
+
return map_name, bg_model, header, header # ✅ Done!
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
######################## ISOLATED sources photometry ########################
|
|
298
|
+
for idx_iso, i in enumerate(isolated):
|
|
299
|
+
|
|
300
|
+
logger_file_only.info(f"Photometry on isolated source {idx_iso + 1} of {len(isolated)}")
|
|
301
|
+
|
|
302
|
+
fit_status, fit_result, model_fn, bg_order, cutout, (yslice, xslice), bg_mean, bg_model, cutout_header, final_nmse, final_redchi, final_bic = fit_isolated_gaussian(
|
|
303
|
+
image=real_map,
|
|
304
|
+
xcen=xcen[i],
|
|
305
|
+
ycen=ycen[i],
|
|
306
|
+
all_sources_xcen = all_sources_xcen,
|
|
307
|
+
all_sources_ycen = all_sources_ycen,
|
|
308
|
+
source_id=idx_iso,
|
|
309
|
+
map_struct=map_struct,
|
|
310
|
+
suffix=suffix,
|
|
311
|
+
config=cfg,
|
|
312
|
+
logger=logger,
|
|
313
|
+
logger_file_only=logger_file_only
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
if fit_result is None:
|
|
317
|
+
logger_file_only.error(f"Fit failed for isolated source {i}")
|
|
318
|
+
continue
|
|
319
|
+
|
|
320
|
+
# --- Extract fitted Gaussian parameters ---
|
|
321
|
+
sig_x = fit_result.params["g_sigmax"].value
|
|
322
|
+
sig_y = fit_result.params["g_sigmay"].value
|
|
323
|
+
fwhm_x = 2.3548 * sig_x
|
|
324
|
+
fwhm_y = 2.3548 * sig_y
|
|
325
|
+
theta = np.rad2deg(fit_result.params["g_theta"].value) #+90. # rotated for photometry #
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
# --- radius fixed if decided in the config file ---
|
|
329
|
+
if fixed_radius:
|
|
330
|
+
if len(fwhm_1_list) == 1:
|
|
331
|
+
fwhm_x = fwhm_1_list[0] / pix_dim
|
|
332
|
+
fwhm_y = fwhm_2_list[0] / pix_dim
|
|
333
|
+
theta = PA_list[0]
|
|
334
|
+
else:
|
|
335
|
+
fwhm_x = fwhm_1_list[i] / pix_dim
|
|
336
|
+
fwhm_y = fwhm_2_list[i] / pix_dim
|
|
337
|
+
theta = PA_list[i]
|
|
338
|
+
|
|
339
|
+
# --- Evaluate full model on the cutout grid ---
|
|
340
|
+
yy, xx = np.indices(cutout.shape)
|
|
341
|
+
|
|
342
|
+
# --- Zero out the Gaussian component ---
|
|
343
|
+
params_bg_only = fit_result.params.copy()
|
|
344
|
+
for name in params_bg_only:
|
|
345
|
+
if name.startswith("g_"):
|
|
346
|
+
params_bg_only[name].set(value=0.0)
|
|
347
|
+
|
|
348
|
+
model_bg_only = model_fn(params_bg_only, xx, yy)
|
|
349
|
+
|
|
350
|
+
# --- Final cleaned map for aperture photometry ---
|
|
351
|
+
source_only_map = cutout - model_bg_only
|
|
352
|
+
|
|
353
|
+
# --- Photometry: use centroid within cutout ---
|
|
354
|
+
phot_single = aperture_photometry_on_sources(
|
|
355
|
+
image=source_only_map,
|
|
356
|
+
xcen=[fit_result.params["g_centerx"].value], # relative coordinates inside cutout
|
|
357
|
+
ycen=[fit_result.params["g_centery"].value],
|
|
358
|
+
config=cfg,
|
|
359
|
+
radius_val_1=[fwhm_x],
|
|
360
|
+
radius_val_2=[fwhm_y],
|
|
361
|
+
PA_val=[theta]
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
# --- populate the Table --- #
|
|
365
|
+
|
|
366
|
+
# - flux peak in mJy/beam -#
|
|
367
|
+
xc_rel = int(round(fit_result.params["g_centerx"].value))
|
|
368
|
+
yc_rel = int(round(fit_result.params["g_centery"].value))
|
|
369
|
+
flux_peak_mjy_pix = source_only_map[yc_rel, xc_rel] # in mJy/pixel
|
|
370
|
+
beam_area_pix = beam_area / (pix_dim**2) # beam area in pixel²
|
|
371
|
+
flux_peak_mjy_beam = flux_peak_mjy_pix / beam_area_pix # → mJy/beam
|
|
372
|
+
flux_peak.append(flux_peak_mjy_beam)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
flux.append(phot_single["flux"][0])
|
|
376
|
+
flux_err.append(phot_single["error"][0])
|
|
377
|
+
|
|
378
|
+
radius_val_1.append(fwhm_x * pix_dim) # save value in arcsec
|
|
379
|
+
radius_val_2.append(fwhm_y * pix_dim) # save value in arcsec
|
|
380
|
+
PA_val.append(theta)
|
|
381
|
+
updated_xcen.append(fit_result.params["g_centerx"].value + xslice.start)
|
|
382
|
+
updated_ycen.append(fit_result.params["g_centery"].value + yslice.start)
|
|
383
|
+
sky_val.append(bg_mean)
|
|
384
|
+
poly_order_val.append(bg_order)
|
|
385
|
+
nmse_val.append(final_nmse)
|
|
386
|
+
redchi_val.append(final_redchi)
|
|
387
|
+
bic_val.append(final_bic)
|
|
388
|
+
|
|
389
|
+
fit_statuts_val.append(fit_status)
|
|
390
|
+
deblend_val.append(0) # not deblended
|
|
391
|
+
cluster_val.append(1) # only one source
|
|
392
|
+
|
|
393
|
+
source_id_save.append(i+1) #source_id to save in params files
|
|
394
|
+
|
|
395
|
+
tot_fitted_isolated = len(updated_xcen)
|
|
396
|
+
logger.info(f"✓ Fitted {tot_fitted_isolated} isolated sources with Gaussian + background")
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
######################## BLENDED sources photometry ########################
|
|
402
|
+
seen_groups = set()
|
|
403
|
+
group_counter = 0
|
|
404
|
+
total_groups = len(set([tuple(sorted(common_group[i][common_group[i] >= 0])) for i in blended]))
|
|
405
|
+
|
|
406
|
+
count_blended_sources = 0
|
|
407
|
+
for i in blended:
|
|
408
|
+
group_indices = common_group[i]
|
|
409
|
+
group_indices = group_indices[group_indices >= 0]
|
|
410
|
+
group_key = tuple(sorted(group_indices))
|
|
411
|
+
|
|
412
|
+
if group_key in seen_groups:
|
|
413
|
+
continue # already processed this group
|
|
414
|
+
|
|
415
|
+
seen_groups.add(group_key)
|
|
416
|
+
group_counter += 1
|
|
417
|
+
|
|
418
|
+
#- counts indexes for plots -#
|
|
419
|
+
count_source_blended_indexes = (tot_fitted_isolated + count_blended_sources +1, tot_fitted_isolated + count_blended_sources +len(group_indices))
|
|
420
|
+
|
|
421
|
+
logger_file_only.info(f"Photometry on source group {group_counter} of {total_groups} ({len(group_indices)} sources)")
|
|
422
|
+
|
|
423
|
+
group_x = xcen[group_indices]
|
|
424
|
+
group_y = ycen[group_indices]
|
|
425
|
+
|
|
426
|
+
fit_status, fit_result, model_fn, bg_order, cutout, cutout_masked_full, cutout_slice, cutout_header, bg_mean, bg_model, box_size, final_nmse, final_redchi, final_bic = fit_group_with_background(
|
|
427
|
+
image=real_map,
|
|
428
|
+
xcen=group_x,
|
|
429
|
+
ycen=group_y,
|
|
430
|
+
all_sources_xcen = all_sources_xcen,
|
|
431
|
+
all_sources_ycen = all_sources_ycen,
|
|
432
|
+
group_indices = group_indices,
|
|
433
|
+
map_struct=map_struct,
|
|
434
|
+
config=cfg,
|
|
435
|
+
suffix=suffix,
|
|
436
|
+
logger=logger,
|
|
437
|
+
logger_file_only=logger_file_only,
|
|
438
|
+
group_id=group_indices,
|
|
439
|
+
count_source_blended_indexes = count_source_blended_indexes
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
# --- radius fixed if decided in the config file ---
|
|
444
|
+
if fixed_radius == True:
|
|
445
|
+
if len(fwhm_1_list) == 1:
|
|
446
|
+
fwhm_x_group = np.full(len(group_indices), fwhm_1_list[0] / pix_dim)
|
|
447
|
+
fwhm_y_group = np.full(len(group_indices), fwhm_2_list[0] / pix_dim)
|
|
448
|
+
theta_group = np.full(len(group_indices), PA_list[0])
|
|
449
|
+
else:
|
|
450
|
+
fwhm_x_group = fwhm_1_list[group_indices] / pix_dim
|
|
451
|
+
fwhm_y_group = fwhm_2_list[group_indices] / pix_dim
|
|
452
|
+
theta_group = PA_list[group_indices]
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
if fit_result is None:
|
|
456
|
+
logger.error(f"Group fit failed for sources {group_key}")
|
|
457
|
+
continue
|
|
458
|
+
|
|
459
|
+
# Unpack slices
|
|
460
|
+
yslice, xslice = cutout_slice
|
|
461
|
+
x0_global = xslice.start
|
|
462
|
+
y0_global = yslice.start
|
|
463
|
+
|
|
464
|
+
# Pre-evaluate full model on the same grid
|
|
465
|
+
xx, yy = np.meshgrid(np.arange(cutout.shape[1]), np.arange(cutout.shape[0]))
|
|
466
|
+
|
|
467
|
+
for j, idx in enumerate(group_indices):
|
|
468
|
+
# --- Create residual map where only source j is preserved ---
|
|
469
|
+
params_sub = fit_result.params.copy()
|
|
470
|
+
|
|
471
|
+
# Zero the current source j only
|
|
472
|
+
for name in params_sub:
|
|
473
|
+
if name.startswith(f"g{j}_"):
|
|
474
|
+
params_sub[name].set(value=0.0)
|
|
475
|
+
|
|
476
|
+
model_without_j = model_fn(params_sub, xx, yy)
|
|
477
|
+
source_only_map = cutout - model_without_j # subtract background + companions
|
|
478
|
+
|
|
479
|
+
# --- Extract Gaussian parameters for aperture ---
|
|
480
|
+
sig_x = fit_result.params[f"g{j}_sx"].value
|
|
481
|
+
sig_y = fit_result.params[f"g{j}_sy"].value
|
|
482
|
+
fwhm_x = 2.3548 * sig_x # FWHM in pixels
|
|
483
|
+
fwhm_y = 2.3548 * sig_y
|
|
484
|
+
theta = np.rad2deg(fit_result.params[f"g{j}_theta"].value)
|
|
485
|
+
|
|
486
|
+
# --- radius fixed if decided in the config file ---
|
|
487
|
+
if fixed_radius == True:
|
|
488
|
+
fwhm_x = fwhm_x_group[j]
|
|
489
|
+
fwhm_y = fwhm_y_group[j]
|
|
490
|
+
theta = theta_group[j]
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
# --- Perform aperture photometry on residual image relative to cutout --- #
|
|
495
|
+
phot_res = aperture_photometry_on_sources(
|
|
496
|
+
image=source_only_map,
|
|
497
|
+
xcen=[fit_result.params[f"g{j}_x0"].value],
|
|
498
|
+
ycen=[fit_result.params[f"g{j}_y0"].value],
|
|
499
|
+
config=cfg,
|
|
500
|
+
radius_val_1=[fwhm_x],
|
|
501
|
+
radius_val_2=[fwhm_y],
|
|
502
|
+
PA_val=[theta]
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
# -- Save the cutout, model, and residual maps for deblended sources as fits files -- #
|
|
507
|
+
if fits_deblended:
|
|
508
|
+
def save_fits(array, output_dir, label_name, extension_name, header=None):
|
|
509
|
+
|
|
510
|
+
# Ensure the output directory exists
|
|
511
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
512
|
+
|
|
513
|
+
# Create the FITS filename based on the label and extension type
|
|
514
|
+
filename = f"{output_dir}/{label_name}_{extension_name}.fits"
|
|
515
|
+
|
|
516
|
+
# Create a PrimaryHDU object and write the array into the FITS file
|
|
517
|
+
hdu = fits.PrimaryHDU(data=array, header=header)
|
|
518
|
+
|
|
519
|
+
convert_mjy=cfg.get("units", "convert_mJy")
|
|
520
|
+
if convert_mjy:
|
|
521
|
+
hdu.header['BUNIT'] = 'mJy/pixel'
|
|
522
|
+
else: hdu.header['BUNIT'] = 'Jy/pixel'
|
|
523
|
+
|
|
524
|
+
hdul = fits.HDUList([hdu])
|
|
525
|
+
# Write the FITS file
|
|
526
|
+
hdul.writeto(filename, overwrite=True)
|
|
527
|
+
|
|
528
|
+
save_fits(cutout, fits_output_dir_deblended, f"HYPER_MAP_{suffix}_ID_{tot_fitted_isolated + count_blended_sources +1 +j}_single_source", "cutout", header=cutout_header)
|
|
529
|
+
save_fits(cutout_masked_full, fits_output_dir_deblended, f"HYPER_MAP_{suffix}_ID_{tot_fitted_isolated + count_blended_sources +1 +j}_single_source", "cutout_masked_full", header=cutout_header)
|
|
530
|
+
save_fits(model_without_j, fits_output_dir_deblended, f"HYPER_MAP_{suffix}_ID_{tot_fitted_isolated + count_blended_sources +1 +j}_single_source", "model", header=cutout_header)
|
|
531
|
+
save_fits(source_only_map, fits_output_dir_deblended, f"HYPER_MAP_{suffix}_ID_{tot_fitted_isolated + count_blended_sources +1 +j}_single_source", "residual", header=cutout_header)
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
# --- visualize plots of the cutout, model, and residual maps for deblended sources an png files --- #
|
|
535
|
+
if visualize_deblended:
|
|
536
|
+
plot_fit_summary(
|
|
537
|
+
cutout=cutout, # original cutout background subtracted
|
|
538
|
+
cutout_masked_full=cutout_masked_full, # original cutout masked
|
|
539
|
+
model=model_without_j, # model of only source j
|
|
540
|
+
residual=source_only_map, # what you're analyzing
|
|
541
|
+
output_dir=visualize_output_dir_deblended, # or configurable
|
|
542
|
+
label_name=f"HYPER_MAP_{suffix}_ID_{tot_fitted_isolated + count_blended_sources +1 +j}_single_source", # unique label per source
|
|
543
|
+
box_size=box_size,
|
|
544
|
+
poly_order=bg_order,
|
|
545
|
+
nmse=final_nmse
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
# Save results in Table
|
|
549
|
+
xc_rel = int(round(fit_result.params[f"g{j}_x0"].value))
|
|
550
|
+
yc_rel = int(round(fit_result.params[f"g{j}_y0"].value))
|
|
551
|
+
flux_peak_mjy_pix = source_only_map[yc_rel, xc_rel] # in mJy/pixel
|
|
552
|
+
beam_area_pix = beam_area / (pix_dim**2) # beam area in pixel²
|
|
553
|
+
flux_peak_mjy_beam = flux_peak_mjy_pix / beam_area_pix # → mJy/beam
|
|
554
|
+
flux_peak.append(flux_peak_mjy_beam)
|
|
555
|
+
|
|
556
|
+
flux.append(phot_res["flux"][0])
|
|
557
|
+
flux_err.append(phot_res["error"][0])
|
|
558
|
+
|
|
559
|
+
radius_val_1.append(fwhm_x * pix_dim) # save value in arcsec
|
|
560
|
+
radius_val_2.append(fwhm_y * pix_dim) # save value in arcsec
|
|
561
|
+
PA_val.append(theta)
|
|
562
|
+
updated_xcen.append(fit_result.params[f"g{j}_x0"].value + x0_global)
|
|
563
|
+
updated_ycen.append(fit_result.params[f"g{j}_y0"].value + y0_global)
|
|
564
|
+
|
|
565
|
+
sky_val.append(bg_mean)
|
|
566
|
+
poly_order_val.append(bg_order)
|
|
567
|
+
nmse_val.append(final_nmse)
|
|
568
|
+
redchi_val.append(final_redchi)
|
|
569
|
+
bic_val.append(final_bic)
|
|
570
|
+
|
|
571
|
+
fit_statuts_val.append(fit_status)
|
|
572
|
+
deblend_val.append(1) # multi-Gaussian fit
|
|
573
|
+
cluster_val.append(len(group_indices)) # number of sources in the group
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
count_blended_sources = count_blended_sources + len(group_indices)
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
cluster_array = np.array(cluster_val)
|
|
580
|
+
tot_blended_sources = np.sum(cluster_array >= 2)
|
|
581
|
+
logger.info(f"✓ Fitted {tot_blended_sources} blended sources with Multiple Gaussians + background")
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
######################## Parameters for Table and region file ########################
|
|
586
|
+
|
|
587
|
+
# Assuming you have your WCS object (usually from your FITS header)
|
|
588
|
+
header = map_struct["header"]
|
|
589
|
+
# Convert pixel coordinates to sky coordinates (RA, Dec)
|
|
590
|
+
x_pixels = np.array(updated_xcen, dtype=np.float64)
|
|
591
|
+
y_pixels = np.array(updated_ycen, dtype=np.float64)
|
|
592
|
+
|
|
593
|
+
# Initialize WCS from header
|
|
594
|
+
wcs = WCS(header)
|
|
595
|
+
ra, dec = wcs.wcs_pix2world(x_pixels, y_pixels, 0)
|
|
596
|
+
ra_save = np.where(ra < 0., ra + 360., ra)
|
|
597
|
+
|
|
598
|
+
skycoords = SkyCoord(ra=ra, dec=dec, unit='deg', frame='icrs')
|
|
599
|
+
glon = skycoords.galactic.l.deg
|
|
600
|
+
glat = skycoords.galactic.b.deg
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
######################## Write Table after photometry ########################
|
|
604
|
+
|
|
605
|
+
if len(updated_xcen) == 0:
|
|
606
|
+
data_dict = {
|
|
607
|
+
"MAP_ID": [str(suffix)],
|
|
608
|
+
"HYPER_ID": [0],
|
|
609
|
+
"FLUX_PEAK": [0.0],
|
|
610
|
+
"FLUX": [0.0],
|
|
611
|
+
"FLUX_ERR": [0.0],
|
|
612
|
+
"RESIDUALS": [0.0],
|
|
613
|
+
"POLYN": [0],
|
|
614
|
+
"NMSE": [0.0],
|
|
615
|
+
"CHI2_RED": [0.0],
|
|
616
|
+
"FWHM_1": [0.0],
|
|
617
|
+
"FWHM_2": [0.0],
|
|
618
|
+
"PA": [0.0],
|
|
619
|
+
"STATUS": [0],
|
|
620
|
+
"GLON": [0.0],
|
|
621
|
+
"GLAT": [0.0],
|
|
622
|
+
"RA": [0.0],
|
|
623
|
+
"DEC": [0.0],
|
|
624
|
+
"DEBLEND": [0],
|
|
625
|
+
"CLUSTER": [0],
|
|
626
|
+
}
|
|
627
|
+
else:
|
|
628
|
+
data_dict = {
|
|
629
|
+
"MAP_ID": [str(suffix)] * len(updated_xcen),
|
|
630
|
+
"HYPER_ID": list(range(1, len(updated_xcen) + 1)),
|
|
631
|
+
"FLUX_PEAK": list(flux_peak),
|
|
632
|
+
"FLUX": list(flux),
|
|
633
|
+
"FLUX_ERR": list(flux_err),
|
|
634
|
+
"RESIDUALS": list(sky_val),
|
|
635
|
+
"POLYN": list(poly_order_val),
|
|
636
|
+
"NMSE": list(nmse_val),
|
|
637
|
+
"CHI2_RED": list(redchi_val),
|
|
638
|
+
"BIC": list(bic_val),
|
|
639
|
+
"FWHM_1": list(radius_val_1),
|
|
640
|
+
"FWHM_2": list(radius_val_2),
|
|
641
|
+
"PA": list(PA_val),
|
|
642
|
+
"STATUS": list(fit_statuts_val),
|
|
643
|
+
"GLON": list(glon),
|
|
644
|
+
"GLAT": list(glat),
|
|
645
|
+
"RA": list(ra_save),
|
|
646
|
+
"DEC": list(dec),
|
|
647
|
+
"DEBLEND": list(deblend_val),
|
|
648
|
+
"CLUSTER": list(cluster_val),
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
# -- Print the output directory and file path --#
|
|
654
|
+
sigma_thres=cfg.get("detection", "sigma_thres")
|
|
655
|
+
write_tables(data_dict, output_dir_path, cfg, sigma_thres, real_rms, base_filename=base_name_with_suffix)
|
|
656
|
+
logger_file_only.info(f"✅ eCSV and IPAC tables written to: {output_dir_path}")
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
######################## Write Region file ########################
|
|
662
|
+
# Convert PA to DS9 convention
|
|
663
|
+
# Initialize WCS from header
|
|
664
|
+
theta_DS9 = [(pa + 90) % 180 for pa in PA_val]
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
# --- Extract coordinate system ---
|
|
668
|
+
# Extract WCS and coordinate system
|
|
669
|
+
wcs = WCS(header)
|
|
670
|
+
ra = ra_save
|
|
671
|
+
|
|
672
|
+
# Map RADESYS to DS9 coordinate system name
|
|
673
|
+
radecsys = (header.get("RADESYS") or header.get("RADECSYS") or wcs.wcs.radesys or "FK5").strip().upper()
|
|
674
|
+
if "ICRS" in radecsys:
|
|
675
|
+
ds9_coordsys = "icrs"
|
|
676
|
+
elif "FK5" in radecsys:
|
|
677
|
+
ds9_coordsys = "fk5"
|
|
678
|
+
elif "GAL" in radecsys:
|
|
679
|
+
ds9_coordsys = "galactic"
|
|
680
|
+
else:
|
|
681
|
+
ds9_coordsys = "fk5" # Fallback
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
# --- Write centroids only file ---
|
|
685
|
+
with open(centroids_file, "w") as f:
|
|
686
|
+
f.write("# Region file format: DS9 version 4.1\n")
|
|
687
|
+
f.write("global color=cyan dashlist=8 3 width=1 font='helvetica 10 normal' select=1 "
|
|
688
|
+
"highlite=1 edit=1 move=1 delete=1 include=1 fixed=0\n")
|
|
689
|
+
f.write(f"{ds9_coordsys}\n")
|
|
690
|
+
for xw, yw in zip(ra, dec):
|
|
691
|
+
f.write(f"point({xw:.8f},{yw:.8f}) # point=cross\n")
|
|
692
|
+
|
|
693
|
+
# --- Write ellipses file, with ellipses and centroids ---
|
|
694
|
+
with open(ellipses_file, "w") as f:
|
|
695
|
+
f.write("# Region file format: DS9 version 4.1\n")
|
|
696
|
+
f.write("global color=magenta dashlist=8 3 width=1 font='helvetica 10 normal' select=1 "
|
|
697
|
+
"highlite=1 edit=1 move=1 delete=1 include=1 fixed=0\n")
|
|
698
|
+
f.write(f"{ds9_coordsys}\n")
|
|
699
|
+
for i, (xw, yw, a, b, angle) in enumerate(zip(ra, dec, radius_val_1, radius_val_2, theta_DS9), 1):
|
|
700
|
+
f.write(f"ellipse({xw:.8f},{yw:.8f},{a:.4f}\",{b:.4f}\",{angle:.3f})\n")
|
|
701
|
+
f.write(f"point({xw:.8f},{yw:.8f}) # point=cross text={{ID {i}}}\n")
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
if bg_model is not None:
|
|
705
|
+
return map_name, bg_model, cutout_header, header
|
|
706
|
+
else:
|
|
707
|
+
valid_real_map_nobg = ~np.isnan(real_map)
|
|
708
|
+
mean_valid_real_map_nobg, median_valid_real_map_nobg, std_valid_real_map_nobg = sigma_clipped_stats(real_map[valid_real_map_nobg], sigma=3.0, maxiters=5)
|
|
709
|
+
real_map_nobg = np.full_like(real_map, median_valid_real_map_nobg)
|
|
710
|
+
real_map_nobg[np.isnan(real_map)] = np.nan
|
|
711
|
+
bg_model = real_map_nobg
|
|
712
|
+
return map_name, bg_model, header, header
|
|
713
|
+
|
|
714
|
+
#################################### MAIN CALL ####################################
|
|
715
|
+
if __name__ == "__main__":
|
|
716
|
+
main()
|