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 ADDED
@@ -0,0 +1,6 @@
1
+ __version__ = "0.1.0"
2
+
3
+ from .geometry import PhotGeometry # noqa: E402,F401
4
+ from .live import LiveViewer # noqa: E402,F401
5
+ from .pipeline import PhotometryResult, run_photometry # noqa: E402,F401
6
+ from .report import make_report # noqa: E402,F401
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
+ )