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.
@@ -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