wcc-sim 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.
- wcc_phot/__init__.py +6 -0
- wcc_phot/apphot.py +69 -0
- wcc_phot/centroid.py +41 -0
- wcc_phot/cli.py +110 -0
- wcc_phot/flags.py +7 -0
- wcc_phot/geometry.py +93 -0
- wcc_phot/io.py +110 -0
- wcc_phot/lightcurve.py +103 -0
- wcc_phot/live.py +141 -0
- wcc_phot/pipeline.py +315 -0
- wcc_phot/psfphot.py +78 -0
- wcc_phot/report.py +454 -0
- wcc_phot/select.py +83 -0
- wcc_phot/validation.py +243 -0
- wcc_sim/__init__.py +29 -0
- wcc_sim/astrometry.py +136 -0
- wcc_sim/brightcat.py +292 -0
- wcc_sim/catalog.py +133 -0
- wcc_sim/chromatic.py +179 -0
- wcc_sim/cli.py +92 -0
- wcc_sim/data/gaia_dr3_bp.dat +426 -0
- wcc_sim/data/gaia_dr3_g.dat +731 -0
- wcc_sim/data/gaia_dr3_rp.dat +471 -0
- wcc_sim/data/plot_default.mplstyle +38 -0
- wcc_sim/data/psfs/lazuli_scatter_450nm_profile.ecsv +237 -0
- wcc_sim/data/spt_synthetic_colors.csv +28 -0
- wcc_sim/detectors.py +82 -0
- wcc_sim/extended.py +178 -0
- wcc_sim/fitswriter.py +102 -0
- wcc_sim/pipeline.py +514 -0
- wcc_sim/plotting.py +283 -0
- wcc_sim/psf.py +95 -0
- wcc_sim/psfreport.py +719 -0
- wcc_sim/render.py +208 -0
- wcc_sim/scatter.py +422 -0
- wcc_sim/starflux.py +289 -0
- wcc_sim/wcsutil.py +24 -0
- wcc_sim/wings.py +157 -0
- wcc_sim-0.1.0.dist-info/METADATA +220 -0
- wcc_sim-0.1.0.dist-info/RECORD +44 -0
- wcc_sim-0.1.0.dist-info/WHEEL +5 -0
- wcc_sim-0.1.0.dist-info/entry_points.txt +3 -0
- wcc_sim-0.1.0.dist-info/licenses/LICENSE +28 -0
- wcc_sim-0.1.0.dist-info/top_level.txt +2 -0
wcc_phot/__init__.py
ADDED
wcc_phot/apphot.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Aperture photometry with CCD-equation errors from the frame header rates."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
from astropy.stats import SigmaClip
|
|
5
|
+
from photutils.aperture import ApertureStats, CircularAnnulus, CircularAperture
|
|
6
|
+
|
|
7
|
+
from .flags import FLAG_EDGE, FLAG_SATURATED
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _saturated_within(satmask, x, y, radius):
|
|
11
|
+
"""True if any saturated pixel lies within `radius` px of (x, y)."""
|
|
12
|
+
ny, nx = satmask.shape
|
|
13
|
+
r = int(np.ceil(radius))
|
|
14
|
+
x0, y0 = int(round(x)), int(round(y))
|
|
15
|
+
y_lo, y_hi = max(y0 - r, 0), min(y0 + r + 1, ny)
|
|
16
|
+
x_lo, x_hi = max(x0 - r, 0), min(x0 + r + 1, nx)
|
|
17
|
+
if y_lo >= y_hi or x_lo >= x_hi:
|
|
18
|
+
return False
|
|
19
|
+
return bool(satmask[y_lo:y_hi, x_lo:x_hi].any())
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def aperture_photometry_frame(frame, x, y, geom):
|
|
23
|
+
"""Background-subtracted aperture fluxes at (x, y) on the electron image.
|
|
24
|
+
|
|
25
|
+
Returns (flux_e, flux_err_e, bkg_e_pix, flags). Background is the
|
|
26
|
+
sigma-clipped annulus median; the variance is Poisson of the star plus
|
|
27
|
+
the per-pixel background variance from the header rates
|
|
28
|
+
((sky+dark)*exptime + n_reads*read_noise^2), including the
|
|
29
|
+
background-estimate term (area^2/n_annulus).
|
|
30
|
+
"""
|
|
31
|
+
x = np.atleast_1d(np.asarray(x, dtype=float))
|
|
32
|
+
y = np.atleast_1d(np.asarray(y, dtype=float))
|
|
33
|
+
positions = np.column_stack([x, y])
|
|
34
|
+
aper = CircularAperture(positions, r=geom.r_ap)
|
|
35
|
+
annulus = CircularAnnulus(positions, r_in=geom.r_in, r_out=geom.r_out)
|
|
36
|
+
|
|
37
|
+
bkg = np.asarray(
|
|
38
|
+
ApertureStats(
|
|
39
|
+
frame.image_e, annulus, sigma_clip=SigmaClip(sigma=3.0)
|
|
40
|
+
).median,
|
|
41
|
+
dtype=float,
|
|
42
|
+
)
|
|
43
|
+
raw = np.asarray(ApertureStats(frame.image_e, aper).sum, dtype=float)
|
|
44
|
+
area = aper.area
|
|
45
|
+
flux = raw - bkg * area
|
|
46
|
+
|
|
47
|
+
m = frame.meta
|
|
48
|
+
var_pix = (
|
|
49
|
+
(float(m["sky_e_s"]) + float(m["dark_e_s"])) * float(m["exptime"])
|
|
50
|
+
+ int(m["n_reads"]) * float(m["read_noise"]) ** 2
|
|
51
|
+
)
|
|
52
|
+
variance = np.clip(flux, 0, None) + area * var_pix * (
|
|
53
|
+
1.0 + area / annulus.area
|
|
54
|
+
)
|
|
55
|
+
flux_err = np.sqrt(variance)
|
|
56
|
+
|
|
57
|
+
ny, nx = frame.image_e.shape
|
|
58
|
+
flags = np.zeros(x.size, dtype=int)
|
|
59
|
+
edge = (
|
|
60
|
+
(x < geom.r_out)
|
|
61
|
+
| (x > nx - 1 - geom.r_out)
|
|
62
|
+
| (y < geom.r_out)
|
|
63
|
+
| (y > ny - 1 - geom.r_out)
|
|
64
|
+
)
|
|
65
|
+
flags[edge] |= FLAG_EDGE
|
|
66
|
+
for i in range(x.size):
|
|
67
|
+
if _saturated_within(frame.satmask, x[i], y[i], geom.r_ap):
|
|
68
|
+
flags[i] |= FLAG_SATURATED
|
|
69
|
+
return flux, flux_err, bkg, flags
|
wcc_phot/centroid.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Per-frame re-centroiding of WCS-predicted star positions.
|
|
2
|
+
|
|
3
|
+
Center-of-mass (not a Gaussian fit) because the +1/+2-wave defocus PSFs are
|
|
4
|
+
centrally-depressed donuts; COM in a box sized to the PSF works for both.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import warnings
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
from photutils.centroids import centroid_com, centroid_sources
|
|
11
|
+
|
|
12
|
+
from .flags import FLAG_CENTROID
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def centroid_stars(frame, x0, y0, box):
|
|
16
|
+
"""Refine (x0, y0) with a background-subtracted COM in a `box` px window.
|
|
17
|
+
|
|
18
|
+
Returns (x, y, flags); a failed or runaway centroid (>box px from the
|
|
19
|
+
start) falls back to the input position with FLAG_CENTROID set.
|
|
20
|
+
"""
|
|
21
|
+
x0 = np.atleast_1d(np.asarray(x0, dtype=float))
|
|
22
|
+
y0 = np.atleast_1d(np.asarray(y0, dtype=float))
|
|
23
|
+
data = frame.image_e - np.median(frame.image_e)
|
|
24
|
+
ny, nx = data.shape
|
|
25
|
+
x_start = np.clip(x0, 0, nx - 1)
|
|
26
|
+
y_start = np.clip(y0, 0, ny - 1)
|
|
27
|
+
with warnings.catch_warnings():
|
|
28
|
+
warnings.simplefilter("ignore") # partial-overlap warnings at edges
|
|
29
|
+
x, y = centroid_sources(
|
|
30
|
+
data, x_start, y_start, box_size=box, centroid_func=centroid_com
|
|
31
|
+
)
|
|
32
|
+
flags = np.zeros(x0.size, dtype=int)
|
|
33
|
+
bad = (
|
|
34
|
+
~np.isfinite(x)
|
|
35
|
+
| ~np.isfinite(y)
|
|
36
|
+
| (np.hypot(x - x_start, y - y_start) > box)
|
|
37
|
+
)
|
|
38
|
+
flags[bad] |= FLAG_CENTROID
|
|
39
|
+
x[bad] = x0[bad]
|
|
40
|
+
y[bad] = y0[bad]
|
|
41
|
+
return x, y, flags
|
wcc_phot/cli.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Command-line interface: wcc-phot frame1.fits frame2.fits ... -o phot.fits"""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
from .pipeline import run_photometry
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def build_parser():
|
|
11
|
+
p = argparse.ArgumentParser(
|
|
12
|
+
prog="wcc-phot",
|
|
13
|
+
description=(
|
|
14
|
+
"Differential aperture/PSF photometry of a target and its best "
|
|
15
|
+
"reference stars across a series of wcc-sim FITS frames."
|
|
16
|
+
),
|
|
17
|
+
)
|
|
18
|
+
p.add_argument("frames", nargs="+", help="wcc-sim FITS frames (same field)")
|
|
19
|
+
tgt = p.add_argument_group("target (source id or coordinates)")
|
|
20
|
+
tgt.add_argument("--source-id", type=int, default=None,
|
|
21
|
+
help="Gaia source_id of the target")
|
|
22
|
+
tgt.add_argument("--ra", type=float, default=None, help="target RA [deg]")
|
|
23
|
+
tgt.add_argument("--dec", type=float, default=None, help="target Dec [deg]")
|
|
24
|
+
p.add_argument("--method", choices=["aperture", "psf"], default="aperture")
|
|
25
|
+
p.add_argument("--n-ref", type=int, default=10,
|
|
26
|
+
help="number of reference stars")
|
|
27
|
+
p.add_argument("--r-ap", type=float, default=None,
|
|
28
|
+
help="aperture radius [px] (default: model-PSF EE radius)")
|
|
29
|
+
p.add_argument("--r-in", type=float, default=None,
|
|
30
|
+
help="annulus inner radius [px] (default: 1.5 r_ap)")
|
|
31
|
+
p.add_argument("--r-out", type=float, default=None,
|
|
32
|
+
help="annulus outer radius [px] (default: 2.5 r_ap)")
|
|
33
|
+
p.add_argument("--centroid-box", type=int, default=None,
|
|
34
|
+
help="centroid box side [px, odd]")
|
|
35
|
+
p.add_argument("--fit-shape", type=int, default=None,
|
|
36
|
+
help="PSF fit region side [px, odd]")
|
|
37
|
+
p.add_argument("--ee", type=float, default=0.95,
|
|
38
|
+
help="encircled-energy fraction for the default r_ap")
|
|
39
|
+
p.add_argument("--iso-dmag", type=float, default=1.0,
|
|
40
|
+
help="reject refs with a neighbor brighter than G+iso_dmag")
|
|
41
|
+
p.add_argument("--lc-csv", default=None,
|
|
42
|
+
help="also write the light curve as ECSV")
|
|
43
|
+
p.add_argument("--report", default=None, metavar="PATH",
|
|
44
|
+
help="write a one-page PDF + PNG diagnostic report "
|
|
45
|
+
"(field + apertures, light curve, centroids, "
|
|
46
|
+
"noise metrics)")
|
|
47
|
+
p.add_argument("--live", action="store_true",
|
|
48
|
+
help="live matplotlib window: frame + apertures + light "
|
|
49
|
+
"curve updated as each image is analyzed")
|
|
50
|
+
p.add_argument("--live-pause", type=float, default=0.2,
|
|
51
|
+
help="seconds to pause on each frame in live mode")
|
|
52
|
+
p.add_argument("--live-zoom", type=int, default=None,
|
|
53
|
+
help="crop the live image panel to +-N px around the target")
|
|
54
|
+
p.add_argument("-o", "--output", required=True,
|
|
55
|
+
help="output FITS path (STARS/PHOT/LC)")
|
|
56
|
+
return p
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def main(argv=None):
|
|
60
|
+
args = build_parser().parse_args(argv)
|
|
61
|
+
if args.source_id is not None:
|
|
62
|
+
target = args.source_id
|
|
63
|
+
elif args.ra is not None and args.dec is not None:
|
|
64
|
+
target = (args.ra, args.dec)
|
|
65
|
+
else:
|
|
66
|
+
build_parser().error("give --source-id or both --ra and --dec")
|
|
67
|
+
|
|
68
|
+
viewer = None
|
|
69
|
+
if args.live:
|
|
70
|
+
from .live import LiveViewer
|
|
71
|
+
|
|
72
|
+
viewer = LiveViewer(pause=args.live_pause, zoom=args.live_zoom)
|
|
73
|
+
|
|
74
|
+
result = run_photometry(
|
|
75
|
+
args.frames,
|
|
76
|
+
target,
|
|
77
|
+
method=args.method,
|
|
78
|
+
n_ref=args.n_ref,
|
|
79
|
+
r_ap=args.r_ap,
|
|
80
|
+
r_in=args.r_in,
|
|
81
|
+
r_out=args.r_out,
|
|
82
|
+
centroid_box=args.centroid_box,
|
|
83
|
+
fit_shape=args.fit_shape,
|
|
84
|
+
ee=args.ee,
|
|
85
|
+
iso_dmag=args.iso_dmag,
|
|
86
|
+
output=args.output,
|
|
87
|
+
on_frame=viewer,
|
|
88
|
+
report=args.report,
|
|
89
|
+
)
|
|
90
|
+
if args.lc_csv is not None:
|
|
91
|
+
result.lightcurve.write(args.lc_csv, format="ascii.ecsv", overwrite=True)
|
|
92
|
+
|
|
93
|
+
lc = result.lightcurve
|
|
94
|
+
rms_ppm = 1e6 * float(np.nanstd(lc["rel_flux_norm"]))
|
|
95
|
+
err_ppm = 1e6 * float(np.nanmedian(lc["rel_flux_norm_err"]))
|
|
96
|
+
print(
|
|
97
|
+
f"Wrote {args.output}: {result.params['n_frames']} frames, "
|
|
98
|
+
f"target {result.params['target_source_id']} "
|
|
99
|
+
f"(G={result.stars['gmag'][0]:.2f}), "
|
|
100
|
+
f"{result.params['n_ref']} refs, {args.method} r_ap="
|
|
101
|
+
f"{result.params['r_ap']:.1f} px; rel-flux rms {rms_ppm:.0f} ppm "
|
|
102
|
+
f"(median error {err_ppm:.0f} ppm)"
|
|
103
|
+
)
|
|
104
|
+
if viewer is not None:
|
|
105
|
+
viewer.hold()
|
|
106
|
+
return 0
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
if __name__ == "__main__":
|
|
110
|
+
raise SystemExit(main())
|
wcc_phot/flags.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Per-measurement quality flag bits (bitwise OR-able)."""
|
|
2
|
+
|
|
3
|
+
FLAG_CENTROID = 1 # centroid failed or ran away; WCS position used instead
|
|
4
|
+
FLAG_SATURATED = 2 # saturated pixel within the aperture radius
|
|
5
|
+
FLAG_EDGE = 4 # aperture/annulus clipped by the frame edge
|
|
6
|
+
FLAG_FIT = 8 # PSF fit flagged by photutils or returned a non-finite flux
|
|
7
|
+
FLAG_NOFLUX = 16 # relative flux undefined: non-finite target or ensemble <= 0
|
wcc_phot/geometry.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Aperture/centroid geometry, with defaults derived from the wcc-sim PSF model."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class PhotGeometry:
|
|
10
|
+
"""Pixel-space geometry shared by centroiding and photometry."""
|
|
11
|
+
|
|
12
|
+
r_ap: float # aperture radius [px]
|
|
13
|
+
r_in: float # background annulus inner radius [px]
|
|
14
|
+
r_out: float # background annulus outer radius [px]
|
|
15
|
+
centroid_box: int # COM centroid box side [px, odd]
|
|
16
|
+
fit_shape: int # PSF-fit region side [px, odd]
|
|
17
|
+
ee_fraction: float | None = None # model EE inside r_ap (None if user-set)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _odd(value):
|
|
21
|
+
n = int(np.ceil(value))
|
|
22
|
+
return n + 1 if n % 2 == 0 else n
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def geometry_from_r_ap(
|
|
26
|
+
r_ap,
|
|
27
|
+
r_in=None,
|
|
28
|
+
r_out=None,
|
|
29
|
+
centroid_box=None,
|
|
30
|
+
fit_shape=None,
|
|
31
|
+
ee_fraction=None,
|
|
32
|
+
):
|
|
33
|
+
"""PhotGeometry from an aperture radius; unset pieces get scaled defaults."""
|
|
34
|
+
r_ap = float(r_ap)
|
|
35
|
+
if r_ap <= 0:
|
|
36
|
+
raise ValueError(f"r_ap must be positive, got {r_ap}")
|
|
37
|
+
r_in = float(r_in) if r_in is not None else 1.5 * r_ap
|
|
38
|
+
r_out = float(r_out) if r_out is not None else 2.5 * r_ap
|
|
39
|
+
if not r_ap <= r_in < r_out:
|
|
40
|
+
raise ValueError(
|
|
41
|
+
f"need r_ap <= r_in < r_out, got {r_ap}, {r_in}, {r_out}"
|
|
42
|
+
)
|
|
43
|
+
centroid_box = (
|
|
44
|
+
int(centroid_box) if centroid_box is not None else _odd(2 * r_ap + 1)
|
|
45
|
+
)
|
|
46
|
+
fit_shape = int(fit_shape) if fit_shape is not None else _odd(2 * r_ap + 1)
|
|
47
|
+
if centroid_box % 2 == 0 or fit_shape % 2 == 0:
|
|
48
|
+
raise ValueError("centroid_box and fit_shape must be odd")
|
|
49
|
+
return PhotGeometry(r_ap, r_in, r_out, centroid_box, fit_shape, ee_fraction)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def render_model_psf(meta, oversample=5):
|
|
53
|
+
"""Oversampled model PSF for a frame's sensorfilter/focus/jitter."""
|
|
54
|
+
from wcc_sim.detectors import make_base_simulation
|
|
55
|
+
from wcc_sim.psf import render_oversampled_psf
|
|
56
|
+
|
|
57
|
+
sim = make_base_simulation(meta["sensorfilter"])
|
|
58
|
+
return render_oversampled_psf(
|
|
59
|
+
sim,
|
|
60
|
+
int(meta["focus"]),
|
|
61
|
+
oversample=oversample,
|
|
62
|
+
jitter_sigma_mas=float(meta["jitter_sigma_mas"]),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def ee_radius(psf_det, fraction=0.95):
|
|
67
|
+
"""Radius [px] enclosing `fraction` of a centered detector-sampled PSF."""
|
|
68
|
+
n = psf_det.shape[0]
|
|
69
|
+
center = (n - 1) / 2
|
|
70
|
+
yy, xx = np.mgrid[:n, :n]
|
|
71
|
+
r = np.hypot(xx - center, yy - center).ravel()
|
|
72
|
+
order = np.argsort(r)
|
|
73
|
+
cum = np.cumsum(psf_det.ravel()[order])
|
|
74
|
+
cum /= cum[-1]
|
|
75
|
+
i = int(np.searchsorted(cum, fraction))
|
|
76
|
+
return float(r[order][min(i, r.size - 1)])
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def default_geometry(meta, ee=0.95, oversample=5, psf_os=None):
|
|
80
|
+
"""Model-PSF-driven geometry: r_ap = EE(ee) radius, annulus 1.5-2.5 r_ap."""
|
|
81
|
+
from wcc_sim.render import bin_oversampled
|
|
82
|
+
|
|
83
|
+
if psf_os is None:
|
|
84
|
+
psf_os = render_model_psf(meta, oversample=oversample)
|
|
85
|
+
psf_det = bin_oversampled(psf_os, oversample)
|
|
86
|
+
r_ap = float(np.ceil(max(ee_radius(psf_det, ee), 2.0)))
|
|
87
|
+
|
|
88
|
+
n = psf_det.shape[0]
|
|
89
|
+
center = (n - 1) / 2
|
|
90
|
+
yy, xx = np.mgrid[:n, :n]
|
|
91
|
+
inside = np.hypot(xx - center, yy - center) <= r_ap
|
|
92
|
+
ee_fraction = float(psf_det[inside].sum() / psf_det.sum())
|
|
93
|
+
return geometry_from_r_ap(r_ap, ee_fraction=ee_fraction)
|
wcc_phot/io.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Load WCC frames (wcc-sim FITS path, HDUList, or SimulatedField) for photometry."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import warnings
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
from astropy.io import fits
|
|
9
|
+
from astropy.table import Table
|
|
10
|
+
from astropy.wcs import WCS
|
|
11
|
+
|
|
12
|
+
# meta key -> SCI header card (subset of wcc_sim.fitswriter._CARDS we need)
|
|
13
|
+
_META_CARDS = {
|
|
14
|
+
"exptime": "EXPTIME",
|
|
15
|
+
"n_reads": "NREADS",
|
|
16
|
+
"gain": "GAIN",
|
|
17
|
+
"read_noise": "RDNOISE",
|
|
18
|
+
"sky_e_s": "SKYRATE",
|
|
19
|
+
"dark_e_s": "DARK",
|
|
20
|
+
"focus": "FOCUS",
|
|
21
|
+
"sensorfilter": "SENSORF",
|
|
22
|
+
"jitter_sigma_mas": "JITTER",
|
|
23
|
+
"plate_scale_mas": "PLTSCL",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class Frame:
|
|
29
|
+
"""One WCC exposure: delivered electron image + catalog + WCS + metadata.
|
|
30
|
+
|
|
31
|
+
``image_e`` is the delivered product, ``(ADU - bias) * gain``, whether the
|
|
32
|
+
frame came from a SimulatedField's ``image_adu`` or a file's ``SCI``: the
|
|
33
|
+
ADC ceiling and the zero clip are in it on every path (issue #11).
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
image_e: np.ndarray # [e-] (ADU - bias) * gain
|
|
37
|
+
satmask: np.ndarray # bool
|
|
38
|
+
catalog: Table
|
|
39
|
+
wcs: WCS
|
|
40
|
+
meta: dict
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _electrons(adu, meta):
|
|
44
|
+
"""Delivered electrons: (ADU - bias) * gain, the one product photometry sees."""
|
|
45
|
+
return (np.asarray(adu, dtype=float) - meta["bias"]) * float(meta["gain"])
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _meta_from_params(params):
|
|
49
|
+
meta = {key: params[key] for key in _META_CARDS}
|
|
50
|
+
meta["bias"] = float(params.get("bias", 0.0))
|
|
51
|
+
return meta
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _meta_from_header(header):
|
|
55
|
+
try:
|
|
56
|
+
meta = {key: header[card] for key, card in _META_CARDS.items()}
|
|
57
|
+
except KeyError as err:
|
|
58
|
+
raise ValueError(
|
|
59
|
+
f"missing header card {err} — is this a wcc-sim FITS file?"
|
|
60
|
+
) from None
|
|
61
|
+
meta["bias"] = float(header.get("BIAS", 0.0)) # files written before BIAS
|
|
62
|
+
return meta
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def load_frame(source):
|
|
66
|
+
"""Build a Frame from a FITS path, an open HDUList, or a SimulatedField."""
|
|
67
|
+
if isinstance(source, Frame):
|
|
68
|
+
return source
|
|
69
|
+
if hasattr(source, "image_adu") and hasattr(source, "params"):
|
|
70
|
+
meta = _meta_from_params(source.params)
|
|
71
|
+
return Frame(
|
|
72
|
+
image_e=_electrons(source.image_adu, meta),
|
|
73
|
+
satmask=np.asarray(source.saturation_mask, dtype=bool),
|
|
74
|
+
catalog=source.catalog.copy(),
|
|
75
|
+
wcs=source.wcs,
|
|
76
|
+
meta=meta,
|
|
77
|
+
)
|
|
78
|
+
if isinstance(source, (str, os.PathLike)):
|
|
79
|
+
with fits.open(source) as hdul:
|
|
80
|
+
return _frame_from_hdulist(hdul)
|
|
81
|
+
return _frame_from_hdulist(source)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def frame_meta(source):
|
|
85
|
+
"""Read a frame's header metadata without loading the pixel data.
|
|
86
|
+
|
|
87
|
+
Same dispatch as load_frame (Frame / SimulatedField / FITS path /
|
|
88
|
+
HDUList) but for a path it reads only the SCI header, so a frame
|
|
89
|
+
series can be validated up front without holding every image in
|
|
90
|
+
memory. Returns the same meta dict load_frame builds.
|
|
91
|
+
"""
|
|
92
|
+
if isinstance(source, Frame):
|
|
93
|
+
return source.meta
|
|
94
|
+
if hasattr(source, "image_adu") and hasattr(source, "params"):
|
|
95
|
+
return _meta_from_params(source.params)
|
|
96
|
+
if isinstance(source, (str, os.PathLike)):
|
|
97
|
+
return _meta_from_header(fits.getheader(source, "SCI"))
|
|
98
|
+
return _meta_from_header(source["SCI"].header)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _frame_from_hdulist(hdul):
|
|
102
|
+
header = hdul["SCI"].header
|
|
103
|
+
meta = _meta_from_header(header)
|
|
104
|
+
image_e = _electrons(hdul["SCI"].data, meta)
|
|
105
|
+
satmask = np.asarray(hdul["SATMASK"].data, dtype=bool)
|
|
106
|
+
catalog = Table(hdul["CAT"].data)
|
|
107
|
+
with warnings.catch_warnings():
|
|
108
|
+
warnings.simplefilter("ignore") # FITSFixedWarning from DATE etc.
|
|
109
|
+
wcs = WCS(header)
|
|
110
|
+
return Frame(image_e, satmask, catalog, wcs, meta)
|
wcc_phot/lightcurve.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Differential light curve from the per-star, per-frame measurement table."""
|
|
2
|
+
|
|
3
|
+
import warnings
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from astropy.table import Table
|
|
7
|
+
|
|
8
|
+
from .flags import FLAG_NOFLUX
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def ensemble_ratio(flux_t, err_t, ref_flux, ref_err):
|
|
12
|
+
"""Target / reference-ensemble flux for one frame.
|
|
13
|
+
|
|
14
|
+
Returns (rel, rel_err, flux_ens, err_ens, ok). The ensemble is the sum
|
|
15
|
+
of the given reference fluxes. The error uses
|
|
16
|
+
var(T/R) = var(T)/R^2 + T^2 var(R)/R^4, which never divides by the
|
|
17
|
+
target flux, so a zero target is a valid measurement of zero rather
|
|
18
|
+
than a ZeroDivisionError. `ok` is False, and rel/rel_err are NaN, when
|
|
19
|
+
the target flux is not finite or the ensemble is not finite or <= 0.
|
|
20
|
+
"""
|
|
21
|
+
flux_t, err_t = float(flux_t), float(err_t)
|
|
22
|
+
ref_flux = np.asarray(ref_flux, dtype=float)
|
|
23
|
+
ref_err = np.asarray(ref_err, dtype=float)
|
|
24
|
+
flux_ens = float(np.sum(ref_flux))
|
|
25
|
+
err_ens = float(np.sqrt(np.sum(ref_err**2)))
|
|
26
|
+
ok = bool(np.isfinite(flux_t) and np.isfinite(flux_ens) and flux_ens > 0)
|
|
27
|
+
if not ok:
|
|
28
|
+
return np.nan, np.nan, flux_ens, err_ens, False
|
|
29
|
+
rel = flux_t / flux_ens
|
|
30
|
+
rel_err = np.sqrt(err_t**2 / flux_ens**2 + flux_t**2 * err_ens**2 / flux_ens**4)
|
|
31
|
+
return rel, float(rel_err), flux_ens, err_ens, True
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def usable_references(measurements):
|
|
35
|
+
"""Star indices of the references with flags == 0 in every frame.
|
|
36
|
+
|
|
37
|
+
A fixed subset keeps the ensemble baseline stable: dropping a reference
|
|
38
|
+
from some frames only would step the light curve by that star's share
|
|
39
|
+
of the ensemble, an artificial transit. If no reference is clean in
|
|
40
|
+
every frame, all of them are used and a warning says so.
|
|
41
|
+
"""
|
|
42
|
+
refs = measurements[measurements["role"] == "ref"]
|
|
43
|
+
stars = np.unique(np.asarray(refs["star"]))
|
|
44
|
+
clean = [
|
|
45
|
+
int(s) for s in stars
|
|
46
|
+
if not np.any(np.asarray(refs["flags"][refs["star"] == s]) != 0)
|
|
47
|
+
]
|
|
48
|
+
if not clean and len(stars):
|
|
49
|
+
warnings.warn(
|
|
50
|
+
"every reference star is flagged in at least one frame; using "
|
|
51
|
+
"all of them in the ensemble",
|
|
52
|
+
UserWarning,
|
|
53
|
+
)
|
|
54
|
+
clean = [int(s) for s in stars]
|
|
55
|
+
return clean
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def build_lightcurve(measurements, refs=None):
|
|
59
|
+
"""One row per frame: target, reference-ensemble, and relative fluxes.
|
|
60
|
+
|
|
61
|
+
The ensemble is the straight sum of the usable reference fluxes
|
|
62
|
+
(`refs`, default `usable_references`); rel_flux = flux_target /
|
|
63
|
+
flux_ens with errors propagated (see `ensemble_ratio`), and
|
|
64
|
+
rel_flux_norm is rel_flux divided by its median over the frames where
|
|
65
|
+
it is defined. A frame whose ratio is undefined keeps its row with NaN
|
|
66
|
+
fluxes and FLAG_NOFLUX set instead of aborting the series. `flags` is
|
|
67
|
+
the target's own flags plus FLAG_NOFLUX; reference flags are reported
|
|
68
|
+
through the selection, not here.
|
|
69
|
+
"""
|
|
70
|
+
if refs is None:
|
|
71
|
+
refs = usable_references(measurements)
|
|
72
|
+
refs = np.asarray(refs, dtype=int)
|
|
73
|
+
rows = []
|
|
74
|
+
for k in np.unique(measurements["frame"]):
|
|
75
|
+
mk = measurements[measurements["frame"] == k]
|
|
76
|
+
target = mk[mk["role"] == "target"][0]
|
|
77
|
+
ref_rows = mk[(mk["role"] == "ref") & np.isin(mk["star"], refs)]
|
|
78
|
+
rel, rel_err, flux_ens, err_ens, ok = ensemble_ratio(
|
|
79
|
+
target["flux_e"], target["flux_err_e"],
|
|
80
|
+
ref_rows["flux_e"], ref_rows["flux_err_e"],
|
|
81
|
+
)
|
|
82
|
+
rows.append(
|
|
83
|
+
{
|
|
84
|
+
"frame": int(k),
|
|
85
|
+
"time": float(target["time"]),
|
|
86
|
+
"flux_target": float(target["flux_e"]),
|
|
87
|
+
"flux_target_err": float(target["flux_err_e"]),
|
|
88
|
+
"flux_ens": flux_ens,
|
|
89
|
+
"flux_ens_err": err_ens,
|
|
90
|
+
"rel_flux": rel,
|
|
91
|
+
"rel_flux_err": rel_err,
|
|
92
|
+
"n_ref": len(ref_rows),
|
|
93
|
+
"flags": int(target["flags"]) | (0 if ok else FLAG_NOFLUX),
|
|
94
|
+
}
|
|
95
|
+
)
|
|
96
|
+
lc = Table(rows)
|
|
97
|
+
rel = np.asarray(lc["rel_flux"], dtype=float)
|
|
98
|
+
median = float(np.nanmedian(rel)) if np.isfinite(rel).any() else np.nan
|
|
99
|
+
if not (np.isfinite(median) and median != 0):
|
|
100
|
+
median = np.nan
|
|
101
|
+
lc["rel_flux_norm"] = rel / median
|
|
102
|
+
lc["rel_flux_norm_err"] = np.asarray(lc["rel_flux_err"], dtype=float) / median
|
|
103
|
+
return lc
|
wcc_phot/live.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Live matplotlib view of the photometry as each frame is analyzed.
|
|
2
|
+
|
|
3
|
+
Pass a LiveViewer as `run_photometry(..., on_frame=viewer)` (or use the
|
|
4
|
+
CLI `--live` flag): the left panel shows the current frame with the
|
|
5
|
+
target (red) and reference (teal) apertures at their re-centroided
|
|
6
|
+
positions, the right panel grows the relative light curve point by
|
|
7
|
+
point. Any callable accepting the same event dict works as `on_frame`,
|
|
8
|
+
so custom displays (GUIs, DS9, web) can hook in the same way.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import warnings
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
from wcc_sim.plotting import REF_COLOR, TARGET_COLOR # noqa: F401
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class LiveViewer:
|
|
18
|
+
"""Interactive two-panel display updated once per analyzed frame."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, pause=0.2, percent=99.5, cmap="gray_r", zoom=None):
|
|
21
|
+
"""`zoom` (px, optional) crops the image panel around the target."""
|
|
22
|
+
self.pause = pause
|
|
23
|
+
self.percent = percent
|
|
24
|
+
self.cmap = cmap
|
|
25
|
+
self.zoom = zoom
|
|
26
|
+
self._fig = None
|
|
27
|
+
|
|
28
|
+
# -- pipeline callback ------------------------------------------------
|
|
29
|
+
def __call__(self, event):
|
|
30
|
+
import matplotlib.pyplot as plt
|
|
31
|
+
|
|
32
|
+
if self._fig is None:
|
|
33
|
+
self._setup(event)
|
|
34
|
+
self._update(event)
|
|
35
|
+
self._fig.canvas.draw_idle()
|
|
36
|
+
with warnings.catch_warnings():
|
|
37
|
+
warnings.simplefilter("ignore") # non-GUI backend chatter
|
|
38
|
+
plt.pause(self.pause)
|
|
39
|
+
|
|
40
|
+
def hold(self):
|
|
41
|
+
"""Keep the window open after the run (no-op without a figure)."""
|
|
42
|
+
import matplotlib.pyplot as plt
|
|
43
|
+
|
|
44
|
+
if self._fig is not None:
|
|
45
|
+
with warnings.catch_warnings():
|
|
46
|
+
warnings.simplefilter("ignore")
|
|
47
|
+
plt.ioff()
|
|
48
|
+
plt.show()
|
|
49
|
+
|
|
50
|
+
def close(self):
|
|
51
|
+
import matplotlib.pyplot as plt
|
|
52
|
+
|
|
53
|
+
if self._fig is not None:
|
|
54
|
+
plt.close(self._fig)
|
|
55
|
+
self._fig = None
|
|
56
|
+
|
|
57
|
+
# -- internals ---------------------------------------------------------
|
|
58
|
+
def _setup(self, event):
|
|
59
|
+
import matplotlib.pyplot as plt
|
|
60
|
+
from astropy.visualization import simple_norm
|
|
61
|
+
|
|
62
|
+
plt.ion()
|
|
63
|
+
self._fig, (self._ax_img, self._ax_lc) = plt.subplots(
|
|
64
|
+
1, 2, figsize=(12, 5.5), width_ratios=[1.15, 1]
|
|
65
|
+
)
|
|
66
|
+
self._norm = simple_norm(event["image_e"], "asinh", percent=self.percent)
|
|
67
|
+
self._im = self._ax_img.imshow(
|
|
68
|
+
event["image_e"], origin="lower", cmap=self.cmap, norm=self._norm
|
|
69
|
+
)
|
|
70
|
+
if self.zoom is not None:
|
|
71
|
+
x0, y0 = event["x"][0], event["y"][0]
|
|
72
|
+
self._ax_img.set_xlim(x0 - self.zoom, x0 + self.zoom)
|
|
73
|
+
self._ax_img.set_ylim(y0 - self.zoom, y0 + self.zoom)
|
|
74
|
+
self._ax_img.set_xlabel("x [px]")
|
|
75
|
+
self._ax_img.set_ylabel("y [px]")
|
|
76
|
+
|
|
77
|
+
from matplotlib.patches import Circle
|
|
78
|
+
|
|
79
|
+
geom = event["geom"]
|
|
80
|
+
self._circles = []
|
|
81
|
+
for j, role in enumerate(event["roles"]):
|
|
82
|
+
color = TARGET_COLOR if role == "target" else REF_COLOR
|
|
83
|
+
circle = Circle(
|
|
84
|
+
(event["x"][j], event["y"][j]),
|
|
85
|
+
geom.r_ap,
|
|
86
|
+
fill=False,
|
|
87
|
+
color=color,
|
|
88
|
+
lw=1.4,
|
|
89
|
+
)
|
|
90
|
+
self._ax_img.add_patch(circle)
|
|
91
|
+
self._circles.append(circle)
|
|
92
|
+
self._annuli = []
|
|
93
|
+
for radius in (geom.r_in, geom.r_out):
|
|
94
|
+
ring = Circle(
|
|
95
|
+
(event["x"][0], event["y"][0]),
|
|
96
|
+
radius,
|
|
97
|
+
fill=False,
|
|
98
|
+
color=TARGET_COLOR,
|
|
99
|
+
lw=0.8,
|
|
100
|
+
ls="--",
|
|
101
|
+
alpha=0.7,
|
|
102
|
+
)
|
|
103
|
+
self._ax_img.add_patch(ring)
|
|
104
|
+
self._annuli.append(ring)
|
|
105
|
+
|
|
106
|
+
self._times, self._rel, self._rel_err = [], [], []
|
|
107
|
+
self._ax_lc.set_xlabel("time")
|
|
108
|
+
self._ax_lc.set_ylabel("target / ref ensemble")
|
|
109
|
+
self._fig.tight_layout()
|
|
110
|
+
|
|
111
|
+
def _update(self, event):
|
|
112
|
+
self._im.set_data(event["image_e"])
|
|
113
|
+
for circle, x, y in zip(self._circles, event["x"], event["y"]):
|
|
114
|
+
circle.center = (x, y)
|
|
115
|
+
for ring in self._annuli:
|
|
116
|
+
ring.center = (event["x"][0], event["y"][0])
|
|
117
|
+
self._ax_img.set_title(
|
|
118
|
+
f"frame {event['frame'] + 1}/{event['n_frames']}"
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
self._times.append(event["time"])
|
|
122
|
+
self._rel.append(event["rel_flux"])
|
|
123
|
+
self._rel_err.append(event["rel_flux_err"])
|
|
124
|
+
self._ax_lc.clear()
|
|
125
|
+
self._ax_lc.errorbar(
|
|
126
|
+
self._times,
|
|
127
|
+
self._rel,
|
|
128
|
+
yerr=self._rel_err,
|
|
129
|
+
fmt="o",
|
|
130
|
+
color=TARGET_COLOR,
|
|
131
|
+
ecolor=REF_COLOR,
|
|
132
|
+
capsize=2,
|
|
133
|
+
)
|
|
134
|
+
median = float(np.median(self._rel))
|
|
135
|
+
self._ax_lc.axhline(median, color=REF_COLOR, lw=0.8, ls=":")
|
|
136
|
+
self._ax_lc.set_xlabel("time")
|
|
137
|
+
self._ax_lc.set_ylabel("target / ref ensemble")
|
|
138
|
+
self._ax_lc.set_title(
|
|
139
|
+
f"rel flux = {event['rel_flux']:.5f} "
|
|
140
|
+
f"± {event['rel_flux_err']:.5f}"
|
|
141
|
+
)
|