stdpipe 0.4.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.
- stdpipe/__init__.py +9 -0
- stdpipe/artefacts.py +370 -0
- stdpipe/astrometry.py +2001 -0
- stdpipe/astrometry_quad.py +1643 -0
- stdpipe/astrometry_wcs.py +1141 -0
- stdpipe/catalogs.py +942 -0
- stdpipe/cutouts.py +570 -0
- stdpipe/data/legacysurvey_bricks.fits.gz +0 -0
- stdpipe/data/ps1skycells.txt +199708 -0
- stdpipe/db.py +130 -0
- stdpipe/fringe_removal.py +1086 -0
- stdpipe/lcs.py +440 -0
- stdpipe/photometry.py +2340 -0
- stdpipe/photometry_background.py +998 -0
- stdpipe/photometry_iraf.py +913 -0
- stdpipe/photometry_measure.py +2268 -0
- stdpipe/photometry_model.py +931 -0
- stdpipe/photometry_psf.py +1124 -0
- stdpipe/photometry_quality.py +185 -0
- stdpipe/pipeline.py +1303 -0
- stdpipe/plots.py +1582 -0
- stdpipe/psf.py +1542 -0
- stdpipe/realbogus.py +971 -0
- stdpipe/realbogus_features.py +1657 -0
- stdpipe/reproject.py +943 -0
- stdpipe/resolve.py +267 -0
- stdpipe/sfft.py +1077 -0
- stdpipe/simulation.py +2131 -0
- stdpipe/smoothing.py +488 -0
- stdpipe/subtraction.py +1324 -0
- stdpipe/templates.py +929 -0
- stdpipe/utils.py +365 -0
- stdpipe-0.4.0.dist-info/METADATA +171 -0
- stdpipe-0.4.0.dist-info/RECORD +37 -0
- stdpipe-0.4.0.dist-info/WHEEL +5 -0
- stdpipe-0.4.0.dist-info/licenses/LICENSE.md +21 -0
- stdpipe-0.4.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,913 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Aperture photometry using PyRAF DAOPHOT routines.
|
|
3
|
+
|
|
4
|
+
This module provides photometry functionality compatible with stdpipe.photometry
|
|
5
|
+
but using IRAF/DAOPHOT tasks for aperture photometry measurements.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import tempfile
|
|
10
|
+
import shutil
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
from astropy.io import fits
|
|
14
|
+
from astropy.table import Table
|
|
15
|
+
import photutils.background
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# Check if pyraf is available without importing it yet
|
|
19
|
+
# (importing pyraf initializes IRAF which can cause issues with pytest stdin/stdout capturing)
|
|
20
|
+
def _check_pyraf_available():
|
|
21
|
+
"""Check if pyraf is available and can actually import iraf."""
|
|
22
|
+
try:
|
|
23
|
+
# First check if pyraf package exists
|
|
24
|
+
import importlib.util
|
|
25
|
+
|
|
26
|
+
spec = importlib.util.find_spec("pyraf")
|
|
27
|
+
if spec is None:
|
|
28
|
+
return False
|
|
29
|
+
|
|
30
|
+
# Now try to actually import iraf to verify it works
|
|
31
|
+
# This is necessary because pyraf might be installed but broken
|
|
32
|
+
from pyraf import iraf
|
|
33
|
+
|
|
34
|
+
return True
|
|
35
|
+
except (ImportError, ValueError, AttributeError):
|
|
36
|
+
return False
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
PYRAF_AVAILABLE = _check_pyraf_available()
|
|
40
|
+
|
|
41
|
+
# Global variable to hold iraf module once imported
|
|
42
|
+
_iraf = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _get_iraf():
|
|
46
|
+
"""Lazy import of pyraf.iraf module."""
|
|
47
|
+
global _iraf
|
|
48
|
+
if _iraf is None:
|
|
49
|
+
try:
|
|
50
|
+
from pyraf import iraf
|
|
51
|
+
|
|
52
|
+
_iraf = iraf
|
|
53
|
+
except ImportError as e:
|
|
54
|
+
raise ImportError(
|
|
55
|
+
"PyRAF is not available. Please install PyRAF to use this module."
|
|
56
|
+
) from e
|
|
57
|
+
return _iraf
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _select_psf_stars(obj, fwhm, n_stars=20, isolation_radius=None, max_stars=50, verbose=False):
|
|
61
|
+
"""
|
|
62
|
+
Select good PSF stars from object table.
|
|
63
|
+
|
|
64
|
+
Selects stars suitable for building a PSF model based on:
|
|
65
|
+
- Brightness (high flux or low magnitude)
|
|
66
|
+
- High S/N ratio (if flux_err available)
|
|
67
|
+
- Isolation (no nearby neighbors)
|
|
68
|
+
- Not saturated (if flags available)
|
|
69
|
+
- Roundness (if shape parameters available)
|
|
70
|
+
|
|
71
|
+
Parameters
|
|
72
|
+
----------
|
|
73
|
+
obj : astropy.table.Table
|
|
74
|
+
Object table with detected sources.
|
|
75
|
+
fwhm : float
|
|
76
|
+
FWHM in pixels for isolation checking.
|
|
77
|
+
n_stars : int
|
|
78
|
+
Target number of PSF stars to select.
|
|
79
|
+
isolation_radius : float or None
|
|
80
|
+
Isolation radius in pixels. Default is 10*fwhm.
|
|
81
|
+
max_stars : int
|
|
82
|
+
Maximum number of candidates to consider.
|
|
83
|
+
verbose : bool or callable
|
|
84
|
+
Verbose output.
|
|
85
|
+
|
|
86
|
+
Returns
|
|
87
|
+
-------
|
|
88
|
+
numpy.ndarray
|
|
89
|
+
Array of indices of selected PSF stars.
|
|
90
|
+
"""
|
|
91
|
+
log = (verbose if callable(verbose) else print) if verbose else lambda *args, **kwargs: None
|
|
92
|
+
|
|
93
|
+
if len(obj) == 0:
|
|
94
|
+
log("No objects available for PSF star selection")
|
|
95
|
+
return np.array([], dtype=int)
|
|
96
|
+
|
|
97
|
+
if isolation_radius is None:
|
|
98
|
+
isolation_radius = 10 * fwhm
|
|
99
|
+
|
|
100
|
+
# Start with all objects
|
|
101
|
+
candidates = np.ones(len(obj), dtype=bool)
|
|
102
|
+
|
|
103
|
+
# Filter out saturated objects if flags are available
|
|
104
|
+
if 'flags' in obj.colnames:
|
|
105
|
+
# Assume flag 0x04 indicates saturation (common convention)
|
|
106
|
+
saturated = (obj['flags'] & 0x04) != 0
|
|
107
|
+
candidates &= ~saturated
|
|
108
|
+
log(f"Removed {np.sum(saturated)} saturated objects")
|
|
109
|
+
|
|
110
|
+
# Prefer objects with good S/N if flux_err is available
|
|
111
|
+
if 'flux' in obj.colnames and 'flux_err' in obj.colnames:
|
|
112
|
+
sn = obj['flux'] / obj['flux_err']
|
|
113
|
+
# Require S/N > 20 for PSF stars
|
|
114
|
+
good_sn = sn > 20
|
|
115
|
+
candidates &= good_sn
|
|
116
|
+
log(f"Filtered to {np.sum(candidates)} objects with S/N > 20")
|
|
117
|
+
|
|
118
|
+
# Require positive flux
|
|
119
|
+
if 'flux' in obj.colnames:
|
|
120
|
+
candidates &= obj['flux'] > 0
|
|
121
|
+
|
|
122
|
+
if np.sum(candidates) == 0:
|
|
123
|
+
log("Warning: No suitable PSF star candidates found")
|
|
124
|
+
return np.array([], dtype=int)
|
|
125
|
+
|
|
126
|
+
# Sort by brightness (flux or magnitude)
|
|
127
|
+
if 'flux' in obj.colnames:
|
|
128
|
+
# Use flux - higher is brighter
|
|
129
|
+
brightness = obj['flux'].copy()
|
|
130
|
+
brightness[~candidates] = -np.inf
|
|
131
|
+
sorted_idx = np.argsort(brightness)[::-1] # Descending order
|
|
132
|
+
elif 'mag' in obj.colnames:
|
|
133
|
+
# Use magnitude - lower is brighter
|
|
134
|
+
brightness = obj['mag'].copy()
|
|
135
|
+
brightness[~candidates] = np.inf
|
|
136
|
+
sorted_idx = np.argsort(brightness) # Ascending order
|
|
137
|
+
else:
|
|
138
|
+
log("Warning: No flux or mag column available, cannot sort by brightness")
|
|
139
|
+
sorted_idx = np.arange(len(obj))
|
|
140
|
+
|
|
141
|
+
# Consider only the brightest candidates
|
|
142
|
+
sorted_idx = sorted_idx[:max_stars]
|
|
143
|
+
|
|
144
|
+
# Select isolated stars
|
|
145
|
+
selected = []
|
|
146
|
+
x_all = obj['x']
|
|
147
|
+
y_all = obj['y']
|
|
148
|
+
|
|
149
|
+
for idx in sorted_idx:
|
|
150
|
+
if not candidates[idx]:
|
|
151
|
+
continue
|
|
152
|
+
|
|
153
|
+
# Check isolation - no other bright stars nearby
|
|
154
|
+
x, y = x_all[idx], y_all[idx]
|
|
155
|
+
distances = np.sqrt((x_all - x) ** 2 + (y_all - y) ** 2)
|
|
156
|
+
|
|
157
|
+
# Count neighbors within isolation radius (excluding self)
|
|
158
|
+
neighbors = np.sum((distances < isolation_radius) & (distances > 0) & candidates)
|
|
159
|
+
|
|
160
|
+
if neighbors == 0:
|
|
161
|
+
selected.append(idx)
|
|
162
|
+
if len(selected) >= n_stars:
|
|
163
|
+
break
|
|
164
|
+
|
|
165
|
+
selected = np.array(selected, dtype=int)
|
|
166
|
+
log(f"Selected {len(selected)} isolated PSF stars from {np.sum(candidates)} candidates")
|
|
167
|
+
|
|
168
|
+
return selected
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def measure_objects(
|
|
172
|
+
obj,
|
|
173
|
+
image,
|
|
174
|
+
aper=3,
|
|
175
|
+
bkgann=None,
|
|
176
|
+
fwhm=None,
|
|
177
|
+
mask=None,
|
|
178
|
+
bg=None,
|
|
179
|
+
err=None,
|
|
180
|
+
gain=None,
|
|
181
|
+
bg_size=64,
|
|
182
|
+
sn=None,
|
|
183
|
+
centroid_iter=0,
|
|
184
|
+
keep_negative=True,
|
|
185
|
+
get_bg=False,
|
|
186
|
+
_workdir=None,
|
|
187
|
+
_tmpdir=None,
|
|
188
|
+
verbose=False,
|
|
189
|
+
):
|
|
190
|
+
"""Aperture photometry at the positions of already detected objects using PyRAF DAOPHOT.
|
|
191
|
+
|
|
192
|
+
This function provides similar behavior to :func:`stdpipe.photometry.measure_objects`
|
|
193
|
+
but uses IRAF DAOPHOT tasks for photometry measurements.
|
|
194
|
+
|
|
195
|
+
It will estimate and subtract the background unless external background estimation (`bg`)
|
|
196
|
+
is provided, and use user-provided noise map (`err`) if requested.
|
|
197
|
+
|
|
198
|
+
If the `mask` is provided, it will set 0x200 bit in object `flags` if at least one of
|
|
199
|
+
aperture pixels is masked.
|
|
200
|
+
|
|
201
|
+
The results may optionally be filtered to drop the detections with low signal to noise
|
|
202
|
+
ratio if `sn` parameter is set and positive. It will also filter out the events with
|
|
203
|
+
negative flux.
|
|
204
|
+
|
|
205
|
+
**Note:** Requires PyRAF and IRAF to be installed and properly configured.
|
|
206
|
+
|
|
207
|
+
Parameters
|
|
208
|
+
----------
|
|
209
|
+
obj : astropy.table.Table
|
|
210
|
+
Initial object detections to be measured. Must have 'x' and 'y' columns.
|
|
211
|
+
image : numpy.ndarray
|
|
212
|
+
Input image as a 2D NumPy array.
|
|
213
|
+
aper : float
|
|
214
|
+
Circular aperture radius in pixels for flux measurement.
|
|
215
|
+
bkgann : tuple of float or None
|
|
216
|
+
Background annulus as (inner_radius, outer_radius) for local background
|
|
217
|
+
estimation. If not set, global background model is used instead.
|
|
218
|
+
fwhm : float or None
|
|
219
|
+
If provided, `aper` and `bkgann` are measured in units of FWHM.
|
|
220
|
+
mask : numpy.ndarray or None
|
|
221
|
+
Boolean image mask (True values will be masked).
|
|
222
|
+
bg : numpy.ndarray or None
|
|
223
|
+
Background image to subtract instead of automatically computed one.
|
|
224
|
+
err : numpy.ndarray or None
|
|
225
|
+
Image noise map to use instead of automatically computed one.
|
|
226
|
+
gain : float or None
|
|
227
|
+
Image gain in e/ADU, used for photometry error estimation.
|
|
228
|
+
bg_size : int
|
|
229
|
+
Background grid size in pixels for global background estimation.
|
|
230
|
+
sn : float or None
|
|
231
|
+
Minimal S/N ratio. If set, measurements with magnitude errors
|
|
232
|
+
exceeding 1/sn will be discarded.
|
|
233
|
+
centroid_iter : int
|
|
234
|
+
Number of centroiding iterations before photometry (not implemented
|
|
235
|
+
for IRAF backend).
|
|
236
|
+
keep_negative : bool
|
|
237
|
+
If False, measurements with negative fluxes will be discarded.
|
|
238
|
+
get_bg : bool
|
|
239
|
+
If True, also return estimated background and background noise images.
|
|
240
|
+
_workdir : str or None
|
|
241
|
+
Directory for temporary files. If None, a temporary directory is created.
|
|
242
|
+
_tmpdir : str or None
|
|
243
|
+
Parent directory for temporary directory creation if _workdir is None.
|
|
244
|
+
verbose : bool or callable
|
|
245
|
+
Whether to show verbose messages. May be boolean or a print-like function.
|
|
246
|
+
|
|
247
|
+
Returns
|
|
248
|
+
-------
|
|
249
|
+
astropy.table.Table
|
|
250
|
+
Copy of original table with `flux`, `fluxerr`, `mag` and `magerr` columns
|
|
251
|
+
replaced with measured values. If ``get_bg=True``, returns a tuple of
|
|
252
|
+
(table, background, background_error).
|
|
253
|
+
|
|
254
|
+
Raises
|
|
255
|
+
------
|
|
256
|
+
ImportError
|
|
257
|
+
If PyRAF is not available.
|
|
258
|
+
RuntimeError
|
|
259
|
+
If DAOPHOT task fails.
|
|
260
|
+
"""
|
|
261
|
+
|
|
262
|
+
if not PYRAF_AVAILABLE:
|
|
263
|
+
raise ImportError("PyRAF is not available. Please install PyRAF to use this module.")
|
|
264
|
+
|
|
265
|
+
# Simple wrapper around print for logging in verbose mode only
|
|
266
|
+
log = (verbose if callable(verbose) else print) if verbose else lambda *args, **kwargs: None
|
|
267
|
+
|
|
268
|
+
if not len(obj):
|
|
269
|
+
log('No objects to measure')
|
|
270
|
+
return obj
|
|
271
|
+
|
|
272
|
+
# Check required columns
|
|
273
|
+
if 'x' not in obj.colnames or 'y' not in obj.colnames:
|
|
274
|
+
raise ValueError("Object table must have 'x' and 'y' columns")
|
|
275
|
+
|
|
276
|
+
# Operate on the copy of the list
|
|
277
|
+
obj = obj.copy()
|
|
278
|
+
|
|
279
|
+
# Sanitize the image and make its copy to safely operate on it
|
|
280
|
+
image1 = image.astype(np.double)
|
|
281
|
+
mask0 = ~np.isfinite(image1) # Minimal mask
|
|
282
|
+
|
|
283
|
+
# Ensure that the mask is defined
|
|
284
|
+
if mask is None:
|
|
285
|
+
mask = mask0
|
|
286
|
+
else:
|
|
287
|
+
mask = mask.astype(bool)
|
|
288
|
+
|
|
289
|
+
# Background estimation
|
|
290
|
+
if bg is None or err is None or get_bg:
|
|
291
|
+
log('Estimating global background with %dx%d mesh' % (bg_size, bg_size))
|
|
292
|
+
bg_est = photutils.background.Background2D(
|
|
293
|
+
image1, bg_size, mask=mask | mask0, exclude_percentile=90
|
|
294
|
+
)
|
|
295
|
+
bg_est_bg = bg_est.background
|
|
296
|
+
bg_est_rms = bg_est.background_rms
|
|
297
|
+
else:
|
|
298
|
+
bg_est = None
|
|
299
|
+
|
|
300
|
+
if bg is None:
|
|
301
|
+
log(
|
|
302
|
+
'Subtracting global background: median %.1f rms %.2f'
|
|
303
|
+
% (np.median(bg_est_bg), np.std(bg_est_bg))
|
|
304
|
+
)
|
|
305
|
+
image1 = image1 - bg_est_bg
|
|
306
|
+
bg_used = bg_est_bg
|
|
307
|
+
else:
|
|
308
|
+
log(
|
|
309
|
+
'Subtracting user-provided background: median %.1f rms %.2f'
|
|
310
|
+
% (np.median(bg), np.std(bg))
|
|
311
|
+
)
|
|
312
|
+
image1 = image1 - bg
|
|
313
|
+
bg_used = bg
|
|
314
|
+
|
|
315
|
+
image1[mask0] = 0
|
|
316
|
+
|
|
317
|
+
if err is None:
|
|
318
|
+
if bg_est is not None:
|
|
319
|
+
err = bg_est_rms
|
|
320
|
+
log(
|
|
321
|
+
'Using global background noise map: median %.1f rms %.2f'
|
|
322
|
+
% (np.median(err), np.std(err))
|
|
323
|
+
)
|
|
324
|
+
else:
|
|
325
|
+
# Estimate simple RMS if no background estimation was done
|
|
326
|
+
err = np.ones_like(image1) * np.nanstd(image1)
|
|
327
|
+
log('Using simple RMS estimate: %.2f' % np.nanstd(image1))
|
|
328
|
+
else:
|
|
329
|
+
log('Using user-provided noise map: median %.1f rms %.2f' % (np.median(err), np.std(err)))
|
|
330
|
+
|
|
331
|
+
# Scale apertures by FWHM if requested
|
|
332
|
+
if fwhm is not None and fwhm > 0:
|
|
333
|
+
log('Scaling aperture radii with FWHM %.1f pix' % fwhm)
|
|
334
|
+
aper_scaled = aper * fwhm
|
|
335
|
+
if bkgann is not None:
|
|
336
|
+
bkgann_scaled = [_ * fwhm for _ in bkgann]
|
|
337
|
+
else:
|
|
338
|
+
bkgann_scaled = None
|
|
339
|
+
else:
|
|
340
|
+
aper_scaled = aper
|
|
341
|
+
bkgann_scaled = bkgann
|
|
342
|
+
|
|
343
|
+
log('Using aperture radius %.1f pixels' % aper_scaled)
|
|
344
|
+
|
|
345
|
+
if centroid_iter > 0:
|
|
346
|
+
log('Warning: centroid_iter is not implemented for IRAF backend, ignoring')
|
|
347
|
+
|
|
348
|
+
# Create or use working directory
|
|
349
|
+
workdir = _workdir if _workdir is not None else tempfile.mkdtemp(prefix='iraf', dir=_tmpdir)
|
|
350
|
+
remove_workdir = _workdir is None # Only remove if we created it
|
|
351
|
+
|
|
352
|
+
try:
|
|
353
|
+
# Write image to temporary FITS file
|
|
354
|
+
image_file = os.path.join(workdir, 'image.fits')
|
|
355
|
+
fits.writeto(image_file, image1.astype(np.float32), overwrite=True)
|
|
356
|
+
|
|
357
|
+
# Write coordinate file
|
|
358
|
+
coord_file = os.path.join(workdir, 'coords.txt')
|
|
359
|
+
# IRAF uses 1-indexed coordinates
|
|
360
|
+
with open(coord_file, 'w') as f:
|
|
361
|
+
for i, row in enumerate(obj):
|
|
362
|
+
f.write('%.3f %.3f\n' % (row['x'] + 1, row['y'] + 1))
|
|
363
|
+
|
|
364
|
+
# Output file for photometry results
|
|
365
|
+
phot_file = os.path.join(workdir, 'phot.mag')
|
|
366
|
+
|
|
367
|
+
# Get iraf module (lazy import)
|
|
368
|
+
iraf = _get_iraf()
|
|
369
|
+
|
|
370
|
+
# Set up DAOPHOT parameters
|
|
371
|
+
iraf.noao(_doprint=0)
|
|
372
|
+
iraf.digiphot(_doprint=0)
|
|
373
|
+
iraf.daophot(_doprint=0)
|
|
374
|
+
|
|
375
|
+
# Configure photometry parameters
|
|
376
|
+
iraf.datapars.fwhmpsf = fwhm if fwhm is not None else 3.0
|
|
377
|
+
iraf.datapars.sigma = np.median(err) if err is not None else 1.0
|
|
378
|
+
iraf.datapars.datamin = 'INDEF'
|
|
379
|
+
iraf.datapars.datamax = 'INDEF'
|
|
380
|
+
iraf.datapars.gain = gain if gain is not None else 1.0
|
|
381
|
+
iraf.datapars.readnoise = 0.0 # Already included in error map
|
|
382
|
+
|
|
383
|
+
# Set aperture parameters
|
|
384
|
+
iraf.photpars.apertures = aper_scaled
|
|
385
|
+
iraf.photpars.zmag = 25.0 # Arbitrary zero point
|
|
386
|
+
|
|
387
|
+
# Set sky fitting parameters
|
|
388
|
+
if bkgann_scaled is not None:
|
|
389
|
+
log(
|
|
390
|
+
'Using local background annulus between %.1f and %.1f pixels'
|
|
391
|
+
% (bkgann_scaled[0], bkgann_scaled[1])
|
|
392
|
+
)
|
|
393
|
+
iraf.fitskypars.annulus = bkgann_scaled[0]
|
|
394
|
+
iraf.fitskypars.dannulus = bkgann_scaled[1] - bkgann_scaled[0]
|
|
395
|
+
iraf.fitskypars.salgorithm = 'mode'
|
|
396
|
+
else:
|
|
397
|
+
# Use minimal annulus if no local background requested
|
|
398
|
+
# This effectively uses already subtracted background
|
|
399
|
+
iraf.fitskypars.annulus = aper_scaled + 1
|
|
400
|
+
iraf.fitskypars.dannulus = 1
|
|
401
|
+
iraf.fitskypars.salgorithm = 'constant'
|
|
402
|
+
iraf.fitskypars.skyvalue = 0.0
|
|
403
|
+
|
|
404
|
+
# Run DAOPHOT phot task
|
|
405
|
+
log('Running IRAF DAOPHOT phot task')
|
|
406
|
+
iraf.phot(
|
|
407
|
+
image=image_file,
|
|
408
|
+
coords=coord_file,
|
|
409
|
+
output=phot_file,
|
|
410
|
+
interactive='no',
|
|
411
|
+
verify='no',
|
|
412
|
+
verbose='no',
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
# Parse DAOPHOT output
|
|
416
|
+
log('Parsing DAOPHOT output')
|
|
417
|
+
flux_list = []
|
|
418
|
+
fluxerr_list = []
|
|
419
|
+
mag_list = []
|
|
420
|
+
magerr_list = []
|
|
421
|
+
sky_list = []
|
|
422
|
+
|
|
423
|
+
# Read the output file using IRAF txdump
|
|
424
|
+
txdump_file = os.path.join(workdir, 'txdump.txt')
|
|
425
|
+
iraf.txdump(
|
|
426
|
+
textfiles=phot_file, fields='FLUX,MERR,MAG,MSKY', expr='yes', Stdout=txdump_file
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
# Parse txdump output
|
|
430
|
+
with open(txdump_file, 'r') as f:
|
|
431
|
+
for line in f:
|
|
432
|
+
parts = line.strip().split()
|
|
433
|
+
if len(parts) >= 4:
|
|
434
|
+
flux = float(parts[0]) if parts[0] != 'INDEF' else np.nan
|
|
435
|
+
magerr = float(parts[1]) if parts[1] != 'INDEF' else np.nan
|
|
436
|
+
mag = float(parts[2]) if parts[2] != 'INDEF' else np.nan
|
|
437
|
+
sky = float(parts[3]) if parts[3] != 'INDEF' else np.nan
|
|
438
|
+
|
|
439
|
+
flux_list.append(flux)
|
|
440
|
+
mag_list.append(mag)
|
|
441
|
+
magerr_list.append(magerr)
|
|
442
|
+
sky_list.append(sky)
|
|
443
|
+
|
|
444
|
+
# Calculate flux error from magnitude error
|
|
445
|
+
if np.isfinite(flux) and np.isfinite(magerr) and flux > 0:
|
|
446
|
+
fluxerr = flux * magerr * np.log(10) / 2.5
|
|
447
|
+
else:
|
|
448
|
+
fluxerr = np.nan
|
|
449
|
+
fluxerr_list.append(fluxerr)
|
|
450
|
+
|
|
451
|
+
# Verify we got results for all objects
|
|
452
|
+
if len(flux_list) != len(obj):
|
|
453
|
+
log('Warning: DAOPHOT returned %d results for %d objects' % (len(flux_list), len(obj)))
|
|
454
|
+
# Pad with NaNs if needed
|
|
455
|
+
while len(flux_list) < len(obj):
|
|
456
|
+
flux_list.append(np.nan)
|
|
457
|
+
fluxerr_list.append(np.nan)
|
|
458
|
+
mag_list.append(np.nan)
|
|
459
|
+
magerr_list.append(np.nan)
|
|
460
|
+
sky_list.append(np.nan)
|
|
461
|
+
|
|
462
|
+
# Store results in the object table
|
|
463
|
+
obj['flux'] = np.array(flux_list)
|
|
464
|
+
obj['fluxerr'] = np.array(fluxerr_list)
|
|
465
|
+
obj['mag'] = np.array(mag_list)
|
|
466
|
+
obj['magerr'] = np.array(magerr_list)
|
|
467
|
+
|
|
468
|
+
if 'flags' not in obj.keys():
|
|
469
|
+
obj['flags'] = 0
|
|
470
|
+
|
|
471
|
+
# Flag objects with masked pixels in aperture
|
|
472
|
+
# FIXME: This is a simplified check - just check if object is near masked pixels
|
|
473
|
+
for i, row in enumerate(obj):
|
|
474
|
+
x, y = int(row['x']), int(row['y'])
|
|
475
|
+
# Check pixels in aperture
|
|
476
|
+
y_grid, x_grid = np.ogrid[
|
|
477
|
+
max(0, y - int(aper_scaled) - 1) : min(image.shape[0], y + int(aper_scaled) + 2),
|
|
478
|
+
max(0, x - int(aper_scaled) - 1) : min(image.shape[1], x + int(aper_scaled) + 2),
|
|
479
|
+
]
|
|
480
|
+
r = np.sqrt((x_grid - x) ** 2 + (y_grid - y) ** 2)
|
|
481
|
+
if np.any(mask[y_grid, x_grid][r <= aper_scaled]):
|
|
482
|
+
obj['flags'][i] |= 0x200
|
|
483
|
+
|
|
484
|
+
# Flag objects with zero flux returned - these are truncated?..
|
|
485
|
+
obj['flags'][obj['flux'] == 0] &= 0x010 # SExtractor flag for truncated aperture
|
|
486
|
+
|
|
487
|
+
# Store local background if available
|
|
488
|
+
if bkgann_scaled is not None:
|
|
489
|
+
obj['bg_local'] = np.array(sky_list)
|
|
490
|
+
|
|
491
|
+
# Final filtering of properly measured objects
|
|
492
|
+
if sn is not None and sn > 0:
|
|
493
|
+
log('Filtering out measurements with S/N < %.1f' % sn)
|
|
494
|
+
idx = np.isfinite(obj['magerr'])
|
|
495
|
+
idx[idx] &= obj['magerr'][idx] < 1 / sn
|
|
496
|
+
obj = obj[idx]
|
|
497
|
+
|
|
498
|
+
if not keep_negative:
|
|
499
|
+
log('Filtering out measurements with negative fluxes')
|
|
500
|
+
idx = obj['flux'] > 0
|
|
501
|
+
obj = obj[idx]
|
|
502
|
+
|
|
503
|
+
finally:
|
|
504
|
+
# Clean up temporary directory only if we created it
|
|
505
|
+
if remove_workdir and os.path.exists(workdir):
|
|
506
|
+
shutil.rmtree(workdir)
|
|
507
|
+
|
|
508
|
+
if get_bg:
|
|
509
|
+
return obj, bg_used, err
|
|
510
|
+
else:
|
|
511
|
+
return obj
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def measure_objects_psf(
|
|
515
|
+
obj,
|
|
516
|
+
image,
|
|
517
|
+
fwhm=None,
|
|
518
|
+
psf_stars=None,
|
|
519
|
+
n_psf_stars=20,
|
|
520
|
+
mask=None,
|
|
521
|
+
bg=None,
|
|
522
|
+
err=None,
|
|
523
|
+
gain=None,
|
|
524
|
+
bg_size=64,
|
|
525
|
+
sn=None,
|
|
526
|
+
psfrad=None,
|
|
527
|
+
fitrad=None,
|
|
528
|
+
psf_function='auto',
|
|
529
|
+
psf_varorder=0,
|
|
530
|
+
keep_negative=True,
|
|
531
|
+
get_bg=False,
|
|
532
|
+
_workdir=None,
|
|
533
|
+
_tmpdir=None,
|
|
534
|
+
verbose=False,
|
|
535
|
+
):
|
|
536
|
+
"""PSF photometry using IRAF DAOPHOT (psf + allstar tasks).
|
|
537
|
+
|
|
538
|
+
This function performs PSF photometry using the full DAOPHOT workflow:
|
|
539
|
+
1. Select PSF stars (or use provided psf_stars)
|
|
540
|
+
2. Build PSF model using DAOPHOT psf task
|
|
541
|
+
3. Fit all sources using DAOPHOT allstar task
|
|
542
|
+
4. Return results in stdpipe format with quality metrics
|
|
543
|
+
|
|
544
|
+
The PSF model is built automatically from bright, isolated stars in the image.
|
|
545
|
+
If psf_stars is provided, those specific stars will be used instead of automatic
|
|
546
|
+
selection.
|
|
547
|
+
|
|
548
|
+
**Note:** Requires PyRAF and IRAF to be installed and properly configured.
|
|
549
|
+
|
|
550
|
+
Parameters
|
|
551
|
+
----------
|
|
552
|
+
obj : astropy.table.Table
|
|
553
|
+
Initial object detections. Must have 'x' and 'y' columns. Should also
|
|
554
|
+
have 'flux' or 'mag' for PSF star selection.
|
|
555
|
+
image : numpy.ndarray
|
|
556
|
+
Input image as a 2D NumPy array.
|
|
557
|
+
fwhm : float
|
|
558
|
+
FWHM in pixels (required for PSF building and fitting).
|
|
559
|
+
psf_stars : array-like or None
|
|
560
|
+
Indices of objects to use as PSF stars. If None, stars are selected
|
|
561
|
+
automatically.
|
|
562
|
+
n_psf_stars : int
|
|
563
|
+
Number of PSF stars to select automatically.
|
|
564
|
+
mask : numpy.ndarray or None
|
|
565
|
+
Boolean image mask (True values will be masked).
|
|
566
|
+
bg : numpy.ndarray or None
|
|
567
|
+
Background image to subtract instead of automatically computed one.
|
|
568
|
+
err : numpy.ndarray or None
|
|
569
|
+
Image noise map.
|
|
570
|
+
gain : float or None
|
|
571
|
+
Image gain in e/ADU.
|
|
572
|
+
bg_size : int
|
|
573
|
+
Background grid size in pixels.
|
|
574
|
+
sn : float or None
|
|
575
|
+
Minimal S/N ratio for output filtering.
|
|
576
|
+
psfrad : float or None
|
|
577
|
+
PSF radius in pixels (default: 3*fwhm). Sets how far the PSF extends.
|
|
578
|
+
fitrad : float or None
|
|
579
|
+
Fitting radius in pixels (default: fwhm). Sets how many pixels to use
|
|
580
|
+
for fitting each star.
|
|
581
|
+
psf_function : str
|
|
582
|
+
PSF function type: 'auto', 'gauss', 'moffat15', 'moffat25', 'lorentz',
|
|
583
|
+
'penny1', or 'penny2'.
|
|
584
|
+
psf_varorder : int
|
|
585
|
+
PSF variability order: 0=constant, 1=linear, 2=quadratic.
|
|
586
|
+
keep_negative : bool
|
|
587
|
+
If False, discard measurements with negative fluxes.
|
|
588
|
+
get_bg : bool
|
|
589
|
+
If True, also return background and error images.
|
|
590
|
+
_workdir : str or None
|
|
591
|
+
Working directory for temporary files.
|
|
592
|
+
_tmpdir : str or None
|
|
593
|
+
Parent directory for temporary directory creation.
|
|
594
|
+
verbose : bool or callable
|
|
595
|
+
Verbose output.
|
|
596
|
+
|
|
597
|
+
Returns
|
|
598
|
+
-------
|
|
599
|
+
astropy.table.Table
|
|
600
|
+
Table with PSF photometry results including flux, mag, x_psf, y_psf,
|
|
601
|
+
qfit_psf, cfit_psf, flags_psf columns. If ``get_bg=True``, returns a
|
|
602
|
+
tuple of (table, background, background_error).
|
|
603
|
+
|
|
604
|
+
Raises
|
|
605
|
+
------
|
|
606
|
+
ImportError
|
|
607
|
+
If PyRAF is not available.
|
|
608
|
+
ValueError
|
|
609
|
+
If required parameters are missing.
|
|
610
|
+
RuntimeError
|
|
611
|
+
If DAOPHOT tasks fail.
|
|
612
|
+
"""
|
|
613
|
+
|
|
614
|
+
if not PYRAF_AVAILABLE:
|
|
615
|
+
raise ImportError("PyRAF is not available. Please install PyRAF to use this module.")
|
|
616
|
+
|
|
617
|
+
# Simple wrapper around print for logging
|
|
618
|
+
log = (verbose if callable(verbose) else print) if verbose else lambda *args, **kwargs: None
|
|
619
|
+
|
|
620
|
+
if not len(obj):
|
|
621
|
+
log('No objects to measure')
|
|
622
|
+
return obj
|
|
623
|
+
|
|
624
|
+
# Check required parameters
|
|
625
|
+
if fwhm is None or fwhm <= 0:
|
|
626
|
+
raise ValueError("fwhm parameter is required and must be positive for PSF photometry")
|
|
627
|
+
|
|
628
|
+
# Check required columns
|
|
629
|
+
if 'x' not in obj.colnames or 'y' not in obj.colnames:
|
|
630
|
+
raise ValueError("Object table must have 'x' and 'y' columns")
|
|
631
|
+
|
|
632
|
+
# Operate on a copy
|
|
633
|
+
obj = obj.copy()
|
|
634
|
+
|
|
635
|
+
# Sanitize image
|
|
636
|
+
image1 = image.astype(np.double)
|
|
637
|
+
mask0 = ~np.isfinite(image1)
|
|
638
|
+
|
|
639
|
+
# Ensure mask is defined
|
|
640
|
+
if mask is None:
|
|
641
|
+
mask = mask0
|
|
642
|
+
else:
|
|
643
|
+
mask = mask.astype(bool)
|
|
644
|
+
|
|
645
|
+
# Background estimation
|
|
646
|
+
if bg is None or err is None or get_bg:
|
|
647
|
+
log('Estimating global background with %dx%d mesh' % (bg_size, bg_size))
|
|
648
|
+
bg_est = photutils.background.Background2D(
|
|
649
|
+
image1, bg_size, mask=mask | mask0, exclude_percentile=90
|
|
650
|
+
)
|
|
651
|
+
bg_est_bg = bg_est.background
|
|
652
|
+
bg_est_rms = bg_est.background_rms
|
|
653
|
+
else:
|
|
654
|
+
bg_est = None
|
|
655
|
+
|
|
656
|
+
if bg is None:
|
|
657
|
+
log(
|
|
658
|
+
'Subtracting global background: median %.1f rms %.2f'
|
|
659
|
+
% (np.median(bg_est_bg), np.std(bg_est_bg))
|
|
660
|
+
)
|
|
661
|
+
image1 = image1 - bg_est_bg
|
|
662
|
+
bg_used = bg_est_bg
|
|
663
|
+
else:
|
|
664
|
+
log(
|
|
665
|
+
'Subtracting user-provided background: median %.1f rms %.2f'
|
|
666
|
+
% (np.median(bg), np.std(bg))
|
|
667
|
+
)
|
|
668
|
+
image1 = image1 - bg
|
|
669
|
+
bg_used = bg
|
|
670
|
+
|
|
671
|
+
image1[mask0] = 0
|
|
672
|
+
|
|
673
|
+
if err is None:
|
|
674
|
+
if bg_est is not None:
|
|
675
|
+
err = bg_est_rms
|
|
676
|
+
log(
|
|
677
|
+
'Using global background noise map: median %.1f rms %.2f'
|
|
678
|
+
% (np.median(err), np.std(err))
|
|
679
|
+
)
|
|
680
|
+
else:
|
|
681
|
+
err = np.ones_like(image1) * np.nanstd(image1)
|
|
682
|
+
log('Using simple RMS estimate: %.2f' % np.nanstd(image1))
|
|
683
|
+
else:
|
|
684
|
+
log('Using user-provided noise map: median %.1f rms %.2f' % (np.median(err), np.std(err)))
|
|
685
|
+
|
|
686
|
+
# Set default PSF and fitting radii
|
|
687
|
+
if psfrad is None:
|
|
688
|
+
psfrad = 3 * fwhm
|
|
689
|
+
if fitrad is None:
|
|
690
|
+
fitrad = fwhm
|
|
691
|
+
|
|
692
|
+
log(f'Using PSF radius {psfrad:.1f} pixels, fitting radius {fitrad:.1f} pixels')
|
|
693
|
+
|
|
694
|
+
# Select PSF stars if not provided
|
|
695
|
+
if psf_stars is None:
|
|
696
|
+
# Adjust requested number if we have fewer objects
|
|
697
|
+
n_stars_to_select = min(n_psf_stars, max(2, len(obj) // 2))
|
|
698
|
+
log(f'Automatically selecting up to {n_stars_to_select} PSF stars from {len(obj)} objects')
|
|
699
|
+
psf_star_idx = _select_psf_stars(obj, fwhm, n_stars=n_stars_to_select, verbose=verbose)
|
|
700
|
+
if len(psf_star_idx) == 0:
|
|
701
|
+
raise RuntimeError("No suitable PSF stars found. Cannot build PSF model.")
|
|
702
|
+
log(f'Selected {len(psf_star_idx)} PSF stars')
|
|
703
|
+
else:
|
|
704
|
+
psf_star_idx = np.asarray(psf_stars, dtype=int)
|
|
705
|
+
log(f'Using {len(psf_star_idx)} user-provided PSF stars')
|
|
706
|
+
|
|
707
|
+
# Create working directory
|
|
708
|
+
workdir = _workdir if _workdir is not None else tempfile.mkdtemp(prefix='iraf_psf', dir=_tmpdir)
|
|
709
|
+
remove_workdir = _workdir is None
|
|
710
|
+
|
|
711
|
+
try:
|
|
712
|
+
# Write background-subtracted image
|
|
713
|
+
image_file = os.path.join(workdir, 'image.fits')
|
|
714
|
+
fits.writeto(image_file, image1.astype(np.float32), overwrite=True)
|
|
715
|
+
|
|
716
|
+
# Write coordinate file for all objects (1-indexed for IRAF)
|
|
717
|
+
coord_file = os.path.join(workdir, 'coords.txt')
|
|
718
|
+
with open(coord_file, 'w') as f:
|
|
719
|
+
for i, row in enumerate(obj):
|
|
720
|
+
f.write('%.3f %.3f\n' % (row['x'] + 1, row['y'] + 1))
|
|
721
|
+
|
|
722
|
+
# Get iraf module
|
|
723
|
+
iraf = _get_iraf()
|
|
724
|
+
|
|
725
|
+
# Set up DAOPHOT
|
|
726
|
+
iraf.noao(_doprint=0)
|
|
727
|
+
iraf.digiphot(_doprint=0)
|
|
728
|
+
iraf.daophot(_doprint=0)
|
|
729
|
+
|
|
730
|
+
# Configure data parameters
|
|
731
|
+
iraf.datapars.fwhmpsf = fwhm
|
|
732
|
+
iraf.datapars.sigma = np.median(err) if err is not None else 1.0
|
|
733
|
+
iraf.datapars.datamin = 'INDEF'
|
|
734
|
+
iraf.datapars.datamax = 'INDEF'
|
|
735
|
+
iraf.datapars.epadu = gain if gain is not None else 1.0
|
|
736
|
+
iraf.datapars.readnoise = 0.0
|
|
737
|
+
|
|
738
|
+
# Configure photometry parameters (needed for initial phot)
|
|
739
|
+
iraf.photpars.apertures = fitrad
|
|
740
|
+
iraf.photpars.zmag = 25.0
|
|
741
|
+
|
|
742
|
+
# Configure sky parameters (use constant zero - already subtracted)
|
|
743
|
+
iraf.fitskypars.annulus = psfrad + 1.0
|
|
744
|
+
iraf.fitskypars.dannulus = 1.0
|
|
745
|
+
iraf.fitskypars.salgorithm = 'constant'
|
|
746
|
+
iraf.fitskypars.skyvalue = 0.0
|
|
747
|
+
|
|
748
|
+
# Step 1: Run phot on all stars to get initial magnitudes
|
|
749
|
+
log('Running DAOPHOT phot task on all objects')
|
|
750
|
+
phot_file = os.path.join(workdir, 'all_stars.mag')
|
|
751
|
+
iraf.phot(
|
|
752
|
+
image=image_file,
|
|
753
|
+
coords=coord_file,
|
|
754
|
+
output=phot_file,
|
|
755
|
+
interactive='no',
|
|
756
|
+
verify='no',
|
|
757
|
+
verbose='no',
|
|
758
|
+
)
|
|
759
|
+
|
|
760
|
+
# Step 2: Create PSF star list file using pselect
|
|
761
|
+
log(f'Creating PSF star list with {len(psf_star_idx)} stars')
|
|
762
|
+
pst_file = os.path.join(workdir, 'psf_stars.pst')
|
|
763
|
+
|
|
764
|
+
# Build expression to select PSF stars by ID (1-indexed in IRAF)
|
|
765
|
+
# IDs in DAOPHOT are 1-indexed, so we add 1 to our 0-indexed array
|
|
766
|
+
psf_star_ids = [str(idx + 1) for idx in psf_star_idx]
|
|
767
|
+
expr = ' || '.join([f'ID=={id}' for id in psf_star_ids])
|
|
768
|
+
|
|
769
|
+
log(f'Selecting PSF stars with IDs: {", ".join(psf_star_ids)}')
|
|
770
|
+
|
|
771
|
+
# Use pselect to extract PSF stars from phot output
|
|
772
|
+
iraf.pselect(infiles=phot_file, outfiles=pst_file, expr=expr)
|
|
773
|
+
|
|
774
|
+
# Step 3: Build PSF model using psf task
|
|
775
|
+
log(f'Building PSF model with function={psf_function}, varorder={psf_varorder}')
|
|
776
|
+
psf_image = os.path.join(workdir, 'psf') # IRAF will add .fits extension
|
|
777
|
+
pst_out_file = os.path.join(workdir, 'psf_stars_used.pst')
|
|
778
|
+
grp_file = os.path.join(workdir, 'groups.grp')
|
|
779
|
+
|
|
780
|
+
# Configure PSF parameters
|
|
781
|
+
iraf.daopars.psfrad = psfrad
|
|
782
|
+
iraf.daopars.fitrad = fitrad
|
|
783
|
+
iraf.daopars.function = psf_function
|
|
784
|
+
iraf.daopars.varorder = psf_varorder
|
|
785
|
+
|
|
786
|
+
iraf.psf(
|
|
787
|
+
image=image_file,
|
|
788
|
+
photfile=phot_file,
|
|
789
|
+
pstfile=pst_file,
|
|
790
|
+
psfimage=psf_image,
|
|
791
|
+
opstfile=pst_out_file,
|
|
792
|
+
groupfile=grp_file,
|
|
793
|
+
interactive='no',
|
|
794
|
+
verify='no',
|
|
795
|
+
verbose='no',
|
|
796
|
+
)
|
|
797
|
+
|
|
798
|
+
log('PSF model built successfully')
|
|
799
|
+
|
|
800
|
+
# Step 4: Run allstar to fit PSF to all objects
|
|
801
|
+
log('Running DAOPHOT allstar task')
|
|
802
|
+
allstar_file = os.path.join(workdir, 'output.als')
|
|
803
|
+
reject_file = os.path.join(workdir, 'rejected.als')
|
|
804
|
+
subimage_file = os.path.join(workdir, 'subtracted.fits')
|
|
805
|
+
|
|
806
|
+
# Configure allstar parameters
|
|
807
|
+
iraf.daopars.recenter = 'yes'
|
|
808
|
+
iraf.daopars.fitsky = 'no' # Sky already subtracted
|
|
809
|
+
|
|
810
|
+
iraf.allstar(
|
|
811
|
+
image=image_file,
|
|
812
|
+
photfile=phot_file,
|
|
813
|
+
psfimage=psf_image,
|
|
814
|
+
allstarfile=allstar_file,
|
|
815
|
+
rejfile=reject_file,
|
|
816
|
+
subimage=subimage_file,
|
|
817
|
+
verify='no',
|
|
818
|
+
verbose='no',
|
|
819
|
+
)
|
|
820
|
+
|
|
821
|
+
log('PSF fitting completed, parsing results')
|
|
822
|
+
|
|
823
|
+
# Step 5: Parse allstar output
|
|
824
|
+
# .als format: ID X Y MAG MERR MSKY NITER SHARPNESS CHI PIER
|
|
825
|
+
txdump_als_file = os.path.join(workdir, 'txdump_als.txt')
|
|
826
|
+
iraf.txdump(
|
|
827
|
+
textfiles=allstar_file,
|
|
828
|
+
fields='ID,XCEN,YCEN,MAG,MERR,SHARPNESS,CHI,PIER',
|
|
829
|
+
expr='yes',
|
|
830
|
+
Stdout=txdump_als_file,
|
|
831
|
+
)
|
|
832
|
+
|
|
833
|
+
# Store results in object table
|
|
834
|
+
obj['flux'] = np.nan
|
|
835
|
+
obj['fluxerr'] = np.nan
|
|
836
|
+
obj['mag'] = np.nan
|
|
837
|
+
obj['magerr'] = np.nan
|
|
838
|
+
obj['x_psf'] = np.nan
|
|
839
|
+
obj['y_psf'] = np.nan
|
|
840
|
+
obj['qfit_psf'] = np.nan
|
|
841
|
+
obj['cfit_psf'] = np.nan
|
|
842
|
+
obj['flags_psf'] = 0
|
|
843
|
+
|
|
844
|
+
# Parse txdump output
|
|
845
|
+
with open(txdump_als_file, 'r') as f:
|
|
846
|
+
txdump_lines = f.readlines()
|
|
847
|
+
|
|
848
|
+
log(f'Allstar returned {len(txdump_lines)} fitted objects')
|
|
849
|
+
|
|
850
|
+
for line in txdump_lines:
|
|
851
|
+
parts = line.strip().split()
|
|
852
|
+
if len(parts) >= 8:
|
|
853
|
+
obj_id = int(parts[0])
|
|
854
|
+
x_fit = (
|
|
855
|
+
float(parts[1]) - 1 if parts[1] != 'INDEF' else np.nan
|
|
856
|
+
) # Convert to 0-indexed
|
|
857
|
+
y_fit = float(parts[2]) - 1 if parts[2] != 'INDEF' else np.nan
|
|
858
|
+
mag = float(parts[3]) if parts[3] != 'INDEF' else np.nan
|
|
859
|
+
magerr = float(parts[4]) if parts[4] != 'INDEF' else np.nan
|
|
860
|
+
sharpness = float(parts[5]) if parts[5] != 'INDEF' else np.nan
|
|
861
|
+
chi = float(parts[6]) if parts[6] != 'INDEF' else np.nan
|
|
862
|
+
pier = int(parts[7]) if parts[7] != 'INDEF' else 0
|
|
863
|
+
|
|
864
|
+
# Convert mag to flux (using ZP=25.0)
|
|
865
|
+
if np.isfinite(mag):
|
|
866
|
+
flux = 10 ** ((25.0 - mag) / 2.5)
|
|
867
|
+
if np.isfinite(magerr) and magerr > 0:
|
|
868
|
+
fluxerr = flux * magerr * np.log(10) / 2.5
|
|
869
|
+
else:
|
|
870
|
+
fluxerr = np.nan
|
|
871
|
+
else:
|
|
872
|
+
flux = np.nan
|
|
873
|
+
fluxerr = np.nan
|
|
874
|
+
|
|
875
|
+
obj['flux'][obj_id - 1] = flux
|
|
876
|
+
obj['fluxerr'][obj_id - 1] = fluxerr
|
|
877
|
+
obj['mag'][obj_id - 1] = mag
|
|
878
|
+
obj['magerr'][obj_id - 1] = magerr
|
|
879
|
+
obj['x_psf'][obj_id - 1] = x_fit
|
|
880
|
+
obj['y_psf'][obj_id - 1] = y_fit
|
|
881
|
+
obj['qfit_psf'][obj_id - 1] = chi
|
|
882
|
+
obj['cfit_psf'][obj_id - 1] = sharpness
|
|
883
|
+
obj['flags_psf'][obj_id - 1] = pier
|
|
884
|
+
|
|
885
|
+
# Initialize flags column if not present
|
|
886
|
+
if 'flags' not in obj.colnames:
|
|
887
|
+
obj['flags'] = 0
|
|
888
|
+
|
|
889
|
+
# Flag objects with non-zero pier flags
|
|
890
|
+
obj['flags'][obj['flags_psf'] != 0] |= 0x100
|
|
891
|
+
obj['flags'][~np.isfinite(obj['flux'])] |= 0x100
|
|
892
|
+
|
|
893
|
+
# Apply filters
|
|
894
|
+
if sn is not None and sn > 0:
|
|
895
|
+
log(f'Filtering out measurements with S/N < {sn:.1f}')
|
|
896
|
+
idx = np.isfinite(obj['magerr'])
|
|
897
|
+
idx[idx] &= obj['magerr'][idx] < 1 / sn
|
|
898
|
+
obj = obj[idx]
|
|
899
|
+
|
|
900
|
+
if not keep_negative:
|
|
901
|
+
log('Filtering out measurements with negative fluxes')
|
|
902
|
+
idx = obj['flux'] > 0
|
|
903
|
+
obj = obj[idx]
|
|
904
|
+
|
|
905
|
+
finally:
|
|
906
|
+
# Clean up
|
|
907
|
+
if remove_workdir and os.path.exists(workdir):
|
|
908
|
+
shutil.rmtree(workdir)
|
|
909
|
+
|
|
910
|
+
if get_bg:
|
|
911
|
+
return obj, bg_used, err
|
|
912
|
+
else:
|
|
913
|
+
return obj
|