Add the seti-tools technosignature toolkit, its guide, ledger, results, papers and coverage store
aa1ffa8 Download setitools/ultraviolet.py from phanerozoic/seti-tools: direct link, hf CLI and curl.
- Browser
- Download file 156 kB
-
https://huggingface.co/phanerozoic/seti-tools/resolve/main/setitools/ultraviolet.py
- Command line
-
hf download hf://phanerozoic/seti-tools/setitools/ultraviolet.py
-
curl -L -o ultraviolet.py https://huggingface.co/phanerozoic/seti-tools/resolve/main/setitools/ultraviolet.py
156 kB
| """Laser lines and photon pulses in archival ultraviolet data of nearby stars.""" | |
| import argparse | |
| import glob | |
| import json | |
| import math | |
| import os | |
| import re | |
| import time | |
| import urllib.parse | |
| import urllib.request | |
| from concurrent.futures import ThreadPoolExecutor | |
| import numpy as np | |
| from setitools import core | |
| from setitools.core import quiet_warnings | |
| # -------------------------------------------------------------------------------------------------------------- | |
| # archive pointings carry catalogue positions of any epoch since 1950: match proper-motion tracks, nearest star within the aperture | |
| def uv_nearby(plx): | |
| """SIMBAD stars above plx mas, planets excluded: main_id, ra, dec, pmra, pmdec, plx_value, sp_type, rvz_radvel, otype.""" | |
| return core.simbad("SELECT main_id, ra, dec, pmra, pmdec, plx_value, sp_type, rvz_radvel, otype FROM basic " | |
| "WHERE plx_value > %g AND otype NOT IN ('Pl', 'Pl?') AND ra IS NOT NULL" % plx) | |
| def uv_track_dist(star, ra, dec, mjd): | |
| """Arcsec from pointings (deg, MJD) to a star's proper-motion track from 1950 to the observation or 2000, whichever is later.""" | |
| ra0, de0 = float(star["ra"]), float(star["dec"]) | |
| x = ((np.asarray(ra, float) - ra0 + 180.0) % 360.0 - 180.0) * math.cos(math.radians(de0)) * 3600.0 | |
| y = (np.asarray(dec, float) - de0) * 3600.0 | |
| mx, my = float(star.get("pmra") or 0) / 1000.0, float(star.get("pmdec") or 0) / 1000.0 | |
| t1 = np.maximum((np.asarray(mjd, float) - 51544.5) / 365.25, 0.0) | |
| v2 = mx * mx + my * my | |
| t = np.clip((x * mx + y * my) / v2, -50.0, t1) if v2 > 0 else np.zeros_like(x) | |
| return np.hypot(x - mx * t, y - my * t) | |
| def uv_owner(dists, tol): | |
| """Nearest star to a pointing from {name: track distance} within tol arcsec, or None.""" | |
| if not dists: | |
| return None | |
| name = min(dists, key=dists.get) | |
| return name if dists[name] <= tol else None | |
| def uv_groups(lams, tols): | |
| """Indices of ascending wavelengths grouped where neighbours lie within the lower one's tolerance.""" | |
| if not len(lams): | |
| return [] | |
| out, cur = [], [0] | |
| for i in range(1, len(lams)): | |
| if lams[i] - lams[i - 1] < tols[i - 1]: | |
| cur.append(i) | |
| else: | |
| out.append(cur) | |
| cur = [i] | |
| return out + [cur] | |
| def uv_forced(items, lam_of, z_of, thr=5.0): | |
| """Stars whose forced measurement exceeds thr: lam_of(star) the wavelength in the star's frame, z_of(path, lam) the excess in noise units or None.""" | |
| hit = set() | |
| for star, path in items: | |
| if star in hit: | |
| continue | |
| z = z_of(path, lam_of(star)) | |
| if z is not None and z > thr: | |
| hit.add(star) | |
| return hit | |
| def uv_resolve(names): | |
| """{target name: SIMBAD main_id} for names SIMBAD knows, a spaced component letter ('HD 48915 B') also tried joined.""" | |
| var = {} | |
| for n in names: | |
| n = " ".join(str(n).split()) | |
| for v in (n, re.sub(r"(\d) ([A-Ca-c])$", r"\1\2", n)): | |
| var.setdefault(v, n) | |
| out = {} | |
| keys = sorted(var) | |
| for k in range(0, len(keys), 50): | |
| q = ("SELECT i.id, b.main_id FROM ident AS i JOIN basic AS b ON i.oidref = b.oid WHERE i.id IN (%s)" | |
| % ",".join("'%s'" % x.replace("'", "''") for x in keys[k:k + 50])) | |
| for r in core.simbad(q): | |
| out.setdefault(var.get(" ".join(str(r["id"]).split()), ""), " ".join(str(r["main_id"]).split())) | |
| out.pop("", None) | |
| return out | |
| # -------------------------------------------------------------------------------------------------------------- | |
| starcat_DOC = r"""Laser lines in StarCAT HST/STIS echelle spectra (Ayres 2010): unresolved 6-sigma peaks persistent in two exposures of a setting, absent from other stars in either frame. | |
| python -I seti.py ultraviolet starcat fetch | |
| python -I seti.py ultraviolet starcat search""" | |
| starcat_ROOT = os.environ.get("STARCAT_ROOT", core.data("starcat")) | |
| starcat_HERE = core.RESULTS | |
| starcat_BASE = "https://archive.stsci.edu/hlsps/starcat/fits/" | |
| starcat_RES = {"E140M": 45800.0, "E230M": 30000.0, "E140H": 114000.0, "E230H": 114000.0} | |
| starcat_AIRGLOW = (1215.67, 1302.17, 1304.86, 1306.03, 1355.60, 1358.51, 1199.55, 1200.22, 1200.71) | |
| # chromospheric and transition-region lines whose self-reversed cores leave narrow peaks: C III, Si III, N V, C II, Si IV, C IV, He II, Mg II | |
| starcat_CHROMOSPHERIC = (1175.71, 1206.50, 1238.82, 1242.80, 1334.53, 1335.71, 1393.76, 1402.77, 1548.20, 1550.77, 1640.4, | |
| 2795.53, 2796.35, 2802.70, 2803.53) | |
| starcat_Z_MIN = 6.0 | |
| starcat_C_KMS = 299792.458 | |
| starcat_ENVELOPE_A = 1.0 | |
| starcat_ENVELOPE_PCT = 90 | |
| def starcat_get(u): | |
| return core.get(u, timeout=180, retries=3, soft=True, pause=2) | |
| def starcat_fetch(): | |
| os.makedirs(os.path.join(starcat_ROOT, "fits"), exist_ok=True) | |
| top = starcat_get(starcat_BASE).decode("utf-8", "replace") | |
| objs = [o for o in re.findall(r'href="([^"?/][^"]*)/"', top)] | |
| jobs = [] | |
| def listing(o): | |
| t = starcat_get(starcat_BASE + o + "/") | |
| return [(o, f) for f in re.findall(r'href="(h_o[0-9a-z]+_spc\.fits)"', t.decode("utf-8", "replace"))] if t else [] | |
| with ThreadPoolExecutor(6) as ex: | |
| for js in ex.map(listing, objs): | |
| jobs += js | |
| print("%d objects, %d single exposures" % (len(objs), len(jobs)), flush=True) | |
| def one(j): | |
| o, f = j | |
| d = os.path.join(starcat_ROOT, "fits", o) | |
| p = os.path.join(d, f) | |
| if os.path.exists(p): | |
| return 1 | |
| b = starcat_get(starcat_BASE + o + "/" + f) | |
| if not b or not b.startswith(b"SIMPLE"): | |
| return 0 | |
| os.makedirs(d, exist_ok=True) | |
| open(p + ".tmp", "wb").write(b) | |
| os.replace(p + ".tmp", p) | |
| return 1 | |
| with ThreadPoolExecutor(6) as ex: | |
| n = sum(ex.map(one, jobs)) | |
| print("%d exposures on disk" % n) | |
| def starcat_load(path): | |
| """(wavelength, flux, error) of an exposure's good samples and its metadata; None for a setting outside RES.""" | |
| from astropy.io import fits | |
| with fits.open(path) as h: | |
| hd = h[0].header | |
| d = h[1].data[0] | |
| w, f, e, dq = (np.asarray(d[k], float) for k in ("WAVE", "FLUX", "ERROR", "DQ")) | |
| mode = str(hd.get("OPT_ELEM", "")).strip() | |
| if mode not in starcat_RES: | |
| return None | |
| meta = dict(obj=str(hd.get("CATNAME", "")).strip(), mode=mode, cenwave=hd.get("CENWAVE"), mjd=hd.get("EXPSTART"), | |
| ra=hd.get("RA_TARG"), dec=hd.get("DEC_TARG"), file=os.path.basename(path), exptime=hd.get("EXPTIME")) | |
| good = (e > 0) & np.isfinite(f) & ((dq.astype(int) & (4 | 8 | 16 | 32 | 64 | 128 | 256)) == 0) | |
| return w[good], f[good], e[good], meta | |
| def starcat_features(path): | |
| x = starcat_load(path) | |
| if x is None: | |
| return None | |
| w, f, e, meta = x | |
| if len(w) < 1000: | |
| return dict(meta, feats=[]) | |
| return dict(meta, feats=starcat_detect(w, f, e, meta["mode"])) | |
| def starcat_detect(w, f, e, mode): | |
| """Unresolved peaks: 6 sigma above the running upper envelope, 3 sigma above the brightest samples within 2.5 A, width 0.5-1.5 instrumental.""" | |
| from scipy.optimize import curve_fit | |
| step = float(np.median(np.diff(w))) | |
| # upper envelope: a running median between dense absorption lines makes the windows read as peaks | |
| from scipy.ndimage import percentile_filter | |
| win = max(int(starcat_ENVELOPE_A / step) | 1, 31) | |
| cont = percentile_filter(f, starcat_ENVELOPE_PCT, size=win, mode="nearest") | |
| z = (f - cont) / e | |
| # and above the brightest neighbours within 2.5 A, which line-dense windows reach | |
| from scipy.ndimage import maximum_filter1d | |
| half = max(int(2.5 / step), 30) | |
| left = maximum_filter1d(np.concatenate([np.full(half, -np.inf), f[:-half]]), size=half, origin=(half - 1) // 2) | |
| right = maximum_filter1d(np.concatenate([f[half:], np.full(half, -np.inf)]), size=half, origin=-(half // 2)) | |
| top = np.maximum(left, right) | |
| out = [] | |
| cand = np.where((z >= starcat_Z_MIN) & (z >= np.roll(z, 1)) & (z >= np.roll(z, -1)))[0] | |
| for i in cand: | |
| if i < 8 or i > len(w) - 9: | |
| continue | |
| lam = w[i] | |
| if any(abs(lam - a) < 0.6 for a in starcat_AIRGLOW) or any(abs(lam - a) < 1.2 for a in starcat_CHROMOSPHERIC): | |
| continue | |
| if not f[i] - top[i] > 3 * e[i]: | |
| continue | |
| inst = lam / starcat_RES[mode] | |
| sl = slice(i - 8, i + 9) | |
| x, y, s = w[sl], (f - cont)[sl], e[sl] | |
| try: | |
| p, _ = curve_fit(lambda x, a, m, sg: a * np.exp(-0.5 * ((x - m) / sg) ** 2), x, y, | |
| p0=[y[8], lam, inst / 2.355], sigma=s, maxfev=2000) | |
| except Exception: | |
| continue | |
| fwhm = 2.355 * abs(p[2]) | |
| if 0.5 * inst <= fwhm <= 1.5 * inst and abs(p[1] - lam) < inst: | |
| out.append(dict(lam=float(p[1]), z=float(z[i]), fwhm=float(fwhm), inst=float(inst), flux=float(p[0]))) | |
| return out | |
| def starcat_resolve(objs): | |
| """SIMBAD main_id, parallax, spectral type and RV of StarCAT objects {name: (ra, dec)}: by identifier, else by position among stars.""" | |
| def num(x): | |
| return float(x) if x not in (None, "") else None | |
| out = {} | |
| ident = {k: re.sub(r"^(HD|HR|HIP|GJ|GL|BD|CD|LHS|LTT|WD)(?=[0-9+-])", r"\1 ", k.replace("-", " "), flags=re.I) | |
| for k in objs} | |
| ids = sorted(set(ident.values())) | |
| for j in range(0, len(ids), 50): | |
| q = ("SELECT i.id, b.main_id, b.plx_value, b.sp_type, b.rvz_radvel FROM ident AS i JOIN basic AS b ON " | |
| "i.oidref = b.oid WHERE i.id IN (%s)" % ",".join("'%s'" % x.replace("'", "''") for x in ids[j:j + 50])) | |
| for r in core.simbad(q): | |
| key = " ".join(str(r["id"]).split()).lower() | |
| for k, v in ident.items(): | |
| if key == " ".join(v.split()).lower(): | |
| out[k] = dict(main_id=" ".join(r["main_id"].split()), plx=num(r.get("plx_value")), | |
| sp=r.get("sp_type") or "", rv=num(r.get("rvz_radvel"))) | |
| rest = {k: v for k, v in objs.items() if k not in out and v[0] is not None} | |
| m = core.xmatch([dict(source_id=k, ra=v[0], dec=v[1]) for k, v in rest.items()], "simbad", 10.0) if rest else [] | |
| best = {} | |
| for r in m: | |
| if r.get("otype") in ("Pl", "Pl?") or "Planet" in (r.get("main_type") or ""): | |
| continue | |
| if r["source_id"] not in best or float(r["angDist"]) < float(best[r["source_id"]]["angDist"]): | |
| best[r["source_id"]] = r | |
| for k, r in best.items(): | |
| out[k] = dict(main_id=" ".join(r["main_id"].split()), plx=num(r.get("plx")), sp=r.get("sp_type") or "", | |
| rv=num(r.get("radvel"))) | |
| return out | |
| def starcat_forced_z(w, f, e, lam): | |
| """Largest excess over the upper envelope in noise units within two samples of lam; None without coverage.""" | |
| from scipy.ndimage import percentile_filter | |
| j = int(np.searchsorted(w, lam)) | |
| if j < 3 or j > len(w) - 4: | |
| return None | |
| step = float(np.median(np.diff(w[max(j - 50, 0):j + 50]))) | |
| if abs(w[j] - lam) > 2 * step: | |
| return None | |
| lo, hi = max(j - 600, 0), min(j + 600, len(w)) | |
| cont = percentile_filter(f[lo:hi], starcat_ENVELOPE_PCT, size=max(int(starcat_ENVELOPE_A / step) | 1, 31), | |
| mode="nearest") | |
| k = j - lo | |
| return float(np.max((f[lo:hi][k - 2:k + 3] - cont[k - 2:k + 3]) / e[lo:hi][k - 2:k + 3])) | |
| def starcat__forced(job): | |
| """Forced measurements of candidate features in one exposure: (object, [(z at rest wavelength, z at observed wavelength in the same setting)]).""" | |
| quiet_warnings() | |
| path, rv, feats = job | |
| x = starcat_load(path) | |
| if x is None: | |
| return None | |
| w, f, e, meta = x | |
| out = [] | |
| for p in feats: | |
| zr = starcat_forced_z(w, f, e, p["rest"] * (1 + rv / starcat_C_KMS)) | |
| same = meta["mode"] == p["mode"] and meta["cenwave"] in p["cenwaves"] | |
| out.append((zr, starcat_forced_z(w, f, e, p["lam"]) if same else None)) | |
| return meta["obj"], out | |
| def starcat_search(): | |
| from concurrent.futures import ProcessPoolExecutor | |
| paths = [os.path.join(dp, f) for dp, _, fs in os.walk(os.path.join(starcat_ROOT, "fits")) for f in fs if f.endswith("_spc.fits")] | |
| procs = int(os.environ.get("UV_PROCS", "2")) | |
| with ProcessPoolExecutor(procs) as ex: | |
| res = [r for r in ex.map(starcat_features, paths, chunksize=8) if r] | |
| print("%d exposures of %d objects; %d narrow peaks at %d sigma or more" % ( | |
| len(res), len({r["obj"] for r in res}), sum(len(r["feats"]) for r in res), starcat_Z_MIN), flush=True) | |
| # persistent features per object and setting | |
| by = {} | |
| for r in res: | |
| by.setdefault((r["obj"], r["mode"]), []).append(r) | |
| pers = [] | |
| for (obj, mode), rs in by.items(): | |
| if len(rs) < 2: | |
| continue | |
| allf = sorted(((x, r["file"], r["cenwave"]) for r in rs for x in r["feats"]), key=lambda t: t[0]["lam"]) | |
| for grp in uv_groups([x["lam"] for x, _, _ in allf], [x["inst"] / 3 for x, _, _ in allf]): | |
| files = {allf[j][1] for j in grp} | |
| if len(files) >= 2: | |
| xs = [allf[j][0] for j in grp] | |
| pers.append(dict(obj=obj, mode=mode, lam=float(np.mean([y["lam"] for y in xs])), n=len(files), | |
| of=len(rs), z=float(max(y["z"] for y in xs)), inst=xs[0]["inst"], | |
| cenwaves=sorted({allf[j][2] for j in grp}), ra=rs[0]["ra"], dec=rs[0]["dec"])) | |
| print("%d persistent features (two or more exposures in one setting) in %d objects" % ( | |
| len(pers), len({p["obj"] for p in pers})), flush=True) | |
| names = starcat_resolve({r["obj"]: (r["ra"], r["dec"]) for r in res}) | |
| rv = {k: v["rv"] or 0.0 for k, v in names.items()} | |
| for p in pers: | |
| p["rest"] = p["lam"] / (1 + rv.get(p["obj"], 0.0) / starcat_C_KMS) | |
| p["main_id"], p["sp"] = names.get(p["obj"], {}).get("main_id", p["obj"]), names.get(p["obj"], {}).get("sp", "") | |
| # other stars' peaks: rest frame (stellar), observed frame (instrument or sky) | |
| pk = [(x["lam"], r["obj"]) for r in res for x in r["feats"]] | |
| obsw = np.array([lam for lam, _ in pk]) | |
| rest = np.array([lam / (1 + rv.get(o, 0.0) / starcat_C_KMS) for lam, o in pk]) | |
| objs = np.array([o for _, o in pk]) | |
| for p in pers: | |
| near_r = np.abs(rest - p["rest"]) / p["rest"] * starcat_C_KMS < 15.0 | |
| near_o = np.abs(obsw - p["lam"]) / p["lam"] * starcat_C_KMS < 15.0 | |
| p["stars_rest"] = len(set(objs[near_r]) - {p["obj"]}) | |
| p["stars_obs"] = len(set(objs[near_o]) - {p["obj"]}) | |
| left = [p for p in pers if p["stars_rest"] < 2 and p["stars_obs"] < 2] | |
| # forced measurements in other stars below detection: rest wavelength (stellar), observed wavelength in the same setting (instrument) | |
| obj_of = {r["file"]: r["obj"] for r in res} | |
| jobs = [(q, rv.get(obj_of.get(os.path.basename(q)), 0.0), left) for q in paths if os.path.basename(q) in obj_of] | |
| hits_r, hits_o = [set() for _ in left], [set() for _ in left] | |
| with ProcessPoolExecutor(procs) as ex: | |
| for out in ex.map(starcat__forced, jobs, chunksize=8): | |
| if not out: | |
| continue | |
| obj, zs = out | |
| for k, (zr, zo) in enumerate(zs): | |
| if obj != left[k]["obj"]: | |
| if zr is not None and zr > 5.0: | |
| hits_r[k].add(obj) | |
| if zo is not None and zo > 5.0: | |
| hits_o[k].add(obj) | |
| for k, p in enumerate(left): | |
| p["forced_rest"], p["forced_obs"] = sorted(hits_r[k]), sorted(hits_o[k]) | |
| unique = sorted([p for p in left if len(p["forced_rest"]) < 2 and len(p["forced_obs"]) < 2], key=lambda p: -p["z"]) | |
| print("%d persistent features in at most one other star in either frame; %d of them above 5 sigma in two or more " | |
| "other stars when measured there; %d left:" % (len(left), len(left) - len(unique), len(unique))) | |
| for p in unique[:60]: | |
| print(" %-18s %s %.3f A (rest %.3f), in %d of %d exposures, peak %.1f sigma, other stars %d/%d, measured %d/%d" | |
| % (p["obj"], p["mode"], p["lam"], p["rest"], p["n"], p["of"], p["z"], p["stars_rest"], p["stars_obs"], | |
| len(p["forced_rest"]), len(p["forced_obs"]))) | |
| json.dump(dict(exposures=len(res), objects=len({r["obj"] for r in res}), peaks=sum(len(r["feats"]) for r in res), | |
| persistent=pers), open(os.path.join(starcat_HERE, "uv_laser.json"), "w"), indent=1) | |
| def starcat_main(): | |
| p = argparse.ArgumentParser() | |
| p.add_argument("mode", choices=("fetch", "search")) | |
| a = p.parse_args() | |
| {"fetch": starcat_fetch, "search": starcat_search}[a.mode]() | |
| def starcat_cli(): | |
| quiet_warnings() | |
| starcat_main() | |
| # -------------------------------------------------------------------------------------------------------------- | |
| cos_DOC = r"""Laser lines in HST/COS FUV spectra of stars within 10 pc: unresolved peaks at p 1e-9, persistent across FP-POS, absent from other stars and their summed spectra. | |
| python -I seti.py ultraviolet cos spectra (STIS_ROOT event files; COS_PROCS processes) | |
| python -I seti.py ultraviolet cos search | |
| python -I seti.py ultraviolet cos identify (NIST ASD lines, cached beside STIS_ROOT)""" | |
| cos_ROOT = os.environ.get("STIS_ROOT", core.data("stis_tag")) | |
| cos_HERE = core.RESULTS | |
| cos_C_KMS = 299792.458 | |
| cos_BIN_PX = 2 | |
| cos_LSF_PX = 6.5 | |
| cos_AIRGLOW = ((1215.67, 2.0), (1302.17, 0.6), (1304.86, 0.6), (1306.03, 0.6), (1355.60, 0.6), (1358.51, 0.6), | |
| (1199.55, 0.6), (1200.22, 0.6), (1200.71, 0.6)) | |
| cos_STRONG = (1175.71, 1206.50, 1238.82, 1242.80, 1334.53, 1335.71, 1393.76, 1402.77, 1548.20, 1550.77, 1640.4) | |
| cos_P_MAX = 1e-9 | |
| cos_DQ_BAD = 2 | 8 | 16 | 32 | 64 | 128 | 512 | 1024 | 2048 | 4096 | 8192 | 16384 | |
| cos_APERTURE_TOL = 3.0 # arcsec; the primary science aperture is 2.5 arcsec across | |
| def cos_owned(rs): | |
| """Spectra whose pointing holds a star within 10 pc, each given that star as 'star'.""" | |
| inv = json.load(open(os.path.join(cos_ROOT, "inventory.json"))) | |
| tags = {t["file"]: t for t in inv["tags"]} | |
| out = [] | |
| for r in rs: | |
| tg = tags.get(r["file"]) | |
| o = inv["obs"].get(tg["parent"], inv["obs"].get(tg["obsid"], {})) if tg else {} | |
| star = uv_owner(o.get("dist", {}), cos_APERTURE_TOL) | |
| if star: | |
| out.append(dict(r, star=star)) | |
| return out | |
| cos_CAL_TARGETS = ("DARK", "BIAS", "DEUTERIUM", "WAVE", "WAVEHITM", "WAVELINE", "FLAT", "CCDFLAT", "NONE") | |
| def cos_left(p): | |
| """True for a persistent feature no two other stars show in either frame and absent from their summed spectra.""" | |
| return p["stars_rest"] < 2 and p["stars_obs"] < 2 and p.get("template_p", 1.0) > 1e-5 | |
| def cos_science(hd): | |
| """False for calibration exposures (darks, flats, lamps) filed under a science visit.""" | |
| et = str(hd.get("EXPTYPE", "EXTERNAL/SCI")).strip().upper() | |
| tn = str(hd.get("TARGNAME", "")).strip().upper() | |
| return et in ("EXTERNAL/SCI", "") and not any(tn.startswith(c) for c in cos_CAL_TARGETS) | |
| def cos_spectrum(path): | |
| """Heliocentric count spectrum of one COS FUV corrtag segment: on-band and scaled background counts per two-pixel bin, with metadata.""" | |
| from astropy.io import fits | |
| with fits.open(path) as h: | |
| hd = h[0].header | |
| if str(hd.get("DETECTOR", "")).strip() != "FUV": | |
| return None | |
| if not cos_science(hd): | |
| return None | |
| seg = str(hd.get("SEGMENT", "")).strip()[-1:] | |
| e = h["EVENTS"] | |
| eh, d = e.header, e.data | |
| if d is None or len(d) < 100: | |
| return None | |
| cols = d.columns.names | |
| t = np.asarray(d["TIME"], float) | |
| xf = np.asarray(d["XFULL"], float) | |
| yf = np.asarray(d["YFULL"], float) | |
| wl = np.asarray(d["WAVELENGTH"], float) | |
| good = (np.asarray(d["DQ"], int) & cos_DQ_BAD) == 0 if "DQ" in cols else np.ones(len(t), bool) | |
| if "PHA" in cols: | |
| pha = np.asarray(d["PHA"], int) | |
| good &= (pha >= int(eh.get("PHALOWR" + seg, 2))) & (pha <= int(eh.get("PHAUPPR" + seg, 23))) | |
| good &= wl > 0 | |
| for i in range(1, 40): | |
| if "LMP_ON%d" % i in eh: | |
| on = float(eh["LMP_ON%d" % i]) | |
| off = float(eh.get("LMPOFF%d" % i, on + 15.0)) | |
| good &= ~((t >= on - 2.0) & (t <= off + 2.0)) | |
| loc, hgt = eh.get("SP_LOC_" + seg), eh.get("SP_HGT_" + seg) | |
| if loc is None or hgt is None: | |
| return None | |
| slp = float(eh.get("SP_SLP_" + seg, 0.0) or 0.0) | |
| best = None | |
| for xr in (0.0, 8192.0): | |
| inb = np.abs(yf - (float(loc) + slp * (xf - xr))) <= float(hgt) / 2 | |
| if best is None or (good & inb).sum() > (good & best).sum(): | |
| best = inb | |
| on_band = good & best | |
| bk = np.zeros(len(t), bool) | |
| bh = 0.0 | |
| for k in (1, 2): | |
| b, bhh = eh.get("B_BKG%d_%s" % (k, seg)), eh.get("B_HGT%d_%s" % (k, seg)) | |
| if b is not None and bhh: | |
| bk |= np.abs(yf - float(b)) <= float(bhh) / 2 | |
| bh += float(bhh) | |
| bk &= good | |
| vh = float(eh.get("V_HELIO", 0.0) or 0.0) | |
| meta = dict(file=os.path.basename(path), grating=str(hd.get("OPT_ELEM", "")).strip(), cenwave=hd.get("CENWAVE"), | |
| fppos=hd.get("FPPOS"), segment=seg, target=str(hd.get("TARGNAME", "")).strip(), | |
| mjd=float(eh.get("EXPSTART", 0) or 0), exptime=float(eh.get("EXPTIME", 0) or 0), v_helio=vh) | |
| if on_band.sum() < 200: | |
| return None | |
| edges = np.arange(0, 16384 + cos_BIN_PX, cos_BIN_PX) | |
| c_on = np.histogram(xf[on_band], edges)[0].astype(float) | |
| c_bk = np.histogram(xf[bk], edges)[0].astype(float) * (float(hgt) / bh if bh else 0.0) | |
| # dispersion: cubic wavelength against XFULL per segment from the band's events | |
| sel = on_band | |
| if sel.sum() > 50: | |
| cf = np.polyfit(xf[sel], wl[sel], 3) | |
| else: | |
| return None | |
| centres = 0.5 * (edges[:-1] + edges[1:]) | |
| lam = np.polyval(cf, centres) | |
| lo, hi = np.percentile(xf[on_band], [0.5, 99.5]) | |
| keep = (centres >= lo) & (centres <= hi) | |
| return dict(meta, lam=lam[keep].tolist(), on=c_on[keep].tolist(), bk=c_bk[keep].tolist(), | |
| disp=float(abs(np.polyval(np.polyder(cf), 8192.0)))) | |
| def cos_spectra(): | |
| from concurrent.futures import ProcessPoolExecutor | |
| inv = json.load(open(os.path.join(cos_ROOT, "inventory.json"))) | |
| obs = inv["obs"] | |
| files = [(os.path.join(cos_ROOT, "events", tg["file"]), obs.get(tg["parent"], obs.get(tg["obsid"], {})).get("stars", [])) | |
| for tg in inv["tags"] if tg["file"].endswith(("_corrtag_a.fits", "_corrtag_b.fits"))] | |
| files = [(p, s) for p, s in files if os.path.exists(p)] | |
| out = [] | |
| with ProcessPoolExecutor(int(os.environ.get("COS_PROCS", "4"))) as ex: | |
| for (p, s), r in zip(files, ex.map(cos__safe_spectrum, [p for p, _ in files], chunksize=4)): | |
| if r: | |
| r["stars"] = [" ".join(x.split()) for x in s] | |
| out.append(r) | |
| json.dump(out, open(os.path.join(cos_ROOT, "cos_spectra.json"), "w")) | |
| print("%d COS FUV segment spectra of %d targets" % (len(out), len({r["target"] for r in out}))) | |
| def cos__safe_spectrum(p): | |
| try: | |
| return cos_spectrum(p) | |
| except Exception as e: | |
| print(os.path.basename(p), "failed:", str(e)[:80], flush=True) | |
| return None | |
| def cos_peaks(r, sign, p_max=cos_P_MAX): | |
| """Narrow peaks of one spectrum: (heliocentric wavelength, significance, width in pixels, net counts).""" | |
| from scipy.ndimage import percentile_filter | |
| from scipy.optimize import curve_fit | |
| from scipy.stats import poisson | |
| lam = np.array(r["lam"]) * (1 + sign * r["v_helio"] / cos_C_KMS) | |
| on, bk = np.array(r["on"]), np.array(r["bk"]) | |
| if len(lam) < 200: | |
| return [] | |
| s3 = np.convolve(on, np.ones(3), "same") | |
| b3 = np.convolve(bk, np.ones(3), "same") | |
| step = abs(float(np.median(np.diff(lam)))) | |
| win = max(int(1.0 / step) * 2 + 1, 31) | |
| env = percentile_filter(s3, 90, size=win, mode="nearest") | |
| mu = np.maximum(env, b3 + 0.5) | |
| p = poisson.sf(s3 - 1, mu) | |
| cand = np.where((p < p_max) & (s3 >= np.roll(s3, 1)) & (s3 >= np.roll(s3, -1)))[0] | |
| out = [] | |
| net = on - bk | |
| for i in cand: | |
| if i < 8 or i > len(lam) - 9: | |
| continue | |
| l0 = lam[i] | |
| if any(abs(l0 - a) < w for a, w in cos_AIRGLOW) or any(abs(l0 - a) < 1.2 for a in cos_STRONG): | |
| continue | |
| x = np.arange(-8, 9) * cos_BIN_PX | |
| y = net[i - 8:i + 9] - np.median(net[max(i - 40, 0):i + 40]) | |
| try: | |
| q, _ = curve_fit(lambda x, a, m, sg: a * np.exp(-0.5 * ((x - m) / sg) ** 2), x, y, | |
| p0=[max(y[8], 1.0), 0.0, cos_LSF_PX / 2.355], maxfev=2000) | |
| except Exception: | |
| continue | |
| fw = 2.355 * abs(q[2]) | |
| if 0.5 * cos_LSF_PX <= fw <= 1.5 * cos_LSF_PX and abs(q[1]) < cos_LSF_PX: | |
| out.append(dict(lam=float(np.interp(i + q[1] / cos_BIN_PX, np.arange(len(lam)), lam)), | |
| p=float(max(p[i], 1e-300)), fwhm_px=float(fw), net=float(q[0]))) | |
| return out | |
| def cos_search(): | |
| rs = cos_owned(json.load(open(os.path.join(cos_ROOT, "cos_spectra.json")))) | |
| # CalCOS HELCORR already makes WAVELENGTH heliocentric | |
| best_sign = 0 | |
| feats, loose = [], [] | |
| for r in rs: | |
| for f in cos_peaks(r, best_sign, 1e-5): | |
| g = dict(f, file=r["file"], grating=r["grating"], fppos=r["fppos"], target=r["target"], disp=r["disp"], | |
| star=r["star"]) | |
| loose.append(g) | |
| if f["p"] < cos_P_MAX: | |
| feats.append(g) | |
| print("%d spectra of %d stars; %d narrow peaks at p < %.0e" % (len(rs), len({r["star"] for r in rs}), len(feats), | |
| cos_P_MAX), flush=True) | |
| by = {} | |
| for f in feats: | |
| by.setdefault((f["star"], f["grating"]), []).append(f) | |
| pers = [] | |
| for (star, grating), fs in by.items(): | |
| fs.sort(key=lambda f: f["lam"]) | |
| used = set() | |
| for k, f in enumerate(fs): | |
| if k in used: | |
| continue | |
| tol = cos_BIN_PX * f["disp"] * 3 / 3.0 | |
| grp = [j for j, g in enumerate(fs) if abs(g["lam"] - f["lam"]) < tol] | |
| files = {fs[j]["file"][:9] for j in grp} | |
| # persistence across FP-POS: a fixed wavelength moves on the detector | |
| if len(files) >= 2 and len({fs[j]["fppos"] for j in grp}) >= 2: | |
| used.update(grp) | |
| pers.append(dict(star=star, grating=grating, lam=float(np.mean([fs[j]["lam"] for j in grp])), | |
| n=len(files), p=min(fs[j]["p"] for j in grp), | |
| fppos=sorted({fs[j]["fppos"] for j in grp}), disp=f["disp"])) | |
| print("%d persistent features (two or more exposures) in %d stars" % (len(pers), len({p["star"] for p in pers})), | |
| flush=True) | |
| names = sorted({p["star"] for p in pers} | {g["star"] for g in loose}) | |
| rv = {} | |
| for n in names: | |
| try: | |
| q = core.simbad("SELECT b.rvz_radvel FROM basic b JOIN ident i ON i.oidref = b.oid WHERE i.id = '%s'" | |
| % n.replace("'", "''")) | |
| rv[n] = float(q[0]["rvz_radvel"]) if q and q[0]["rvz_radvel"] not in ("", None) else 0.0 | |
| except Exception: | |
| rv[n] = 0.0 | |
| for p in pers: | |
| p["rest"] = p["lam"] / (1 + rv.get(p["star"], 0.0) / cos_C_KMS) | |
| # other stars' peaks at p < 1e-5 within 15 km/s: rest frame (stellar), observed frame (instrument or sky) | |
| rest = np.array([g["lam"] / (1 + rv.get(g["star"], 0.0) / cos_C_KMS) for g in loose]) | |
| obsw = np.array([g["lam"] for g in loose]) | |
| star = np.array([g["star"] for g in loose]) | |
| for p in pers: | |
| near_r = np.abs(rest - p["rest"]) / p["rest"] * cos_C_KMS < 15.0 | |
| near_o = np.abs(obsw - p["lam"]) / p["lam"] * cos_C_KMS < 15.0 | |
| p["stars_rest"] = len(set(star[near_r]) - {p["star"]}) | |
| p["stars_obs"] = len(set(star[near_o]) - {p["star"]}) | |
| # deep test: other stars summed in their rest frames, a peak at Poisson p < 1e-5 per resolution element over the running 90th percentile | |
| from scipy.stats import poisson | |
| grid = np.arange(1100.0, 1800.0, 0.01) | |
| stack = {} | |
| for r in rs: | |
| st = r["star"] | |
| lam = np.array(r["lam"]) * (1 + best_sign * r["v_helio"] / cos_C_KMS) / (1 + rv.get(st, 0.0) / cos_C_KMS) | |
| on = np.array(r["on"]) | |
| idx = np.clip(np.searchsorted(grid, lam), 0, len(grid) - 1) | |
| h = stack.setdefault((r["grating"], st), np.zeros(len(grid))) | |
| np.add.at(h, idx, on) | |
| for p in pers: | |
| tot = np.zeros(len(grid)) | |
| for (g, st), h in stack.items(): | |
| if g == p["grating"] and st != p["star"]: | |
| tot += h | |
| res_el = 3 * cos_BIN_PX * p["disp"] | |
| k = max(int(round(res_el / 0.01)), 1) | |
| s_k = np.convolve(tot, np.ones(k), "same") | |
| j = int(np.searchsorted(grid, p["rest"])) | |
| lo, hi = max(j - 100, 0), min(j + 101, len(grid)) | |
| env = np.percentile(s_k[lo:hi], 90) | |
| i0 = int(np.argmax(s_k[max(j - k, 0):j + k + 1])) + max(j - k, 0) | |
| p["template_counts"] = float(s_k[i0]) | |
| p["template_p"] = float(poisson.sf(s_k[i0] - 1, max(env, 0.5))) | |
| unique = sorted([p for p in pers if cos_left(p)], key=lambda p: p["p"]) | |
| print("%d persistent features also in the other stars' summed spectra (template p < 1e-5)" | |
| % sum(1 for p in pers if p["template_p"] <= 1e-5)) | |
| from collections import Counter | |
| print("persistent features by star: " + ", ".join("%s %d" % kv for kv in Counter(p["star"] for p in pers).most_common())) | |
| print("%d persistent features in at most one other star in either frame and absent from the summed spectra:" | |
| % len(unique)) | |
| for p in unique[:80]: | |
| print(" %-22s %s %.3f A (rest %.3f), in %d exposures (FP-POS %s), p %.1e, other stars %d/%d, other stars' " | |
| "sum %.0f counts (p %.1e)" % (p["star"][:22], p["grating"], p["lam"], p["rest"], p["n"], | |
| ",".join(str(x) for x in p["fppos"]), p["p"], p["stars_rest"], | |
| p["stars_obs"], p["template_counts"], p["template_p"])) | |
| json.dump(dict(sign=best_sign, spectra=len(rs), stars=len({r["star"] for r in rs}), | |
| peaks=len(feats), persistent=pers), open(os.path.join(cos_HERE, "cos_laser.json"), "w"), indent=1) | |
| cos_IONS = ("H I", "He II", "C I", "C II", "C III", "C IV", "N I", "N II", "N III", "N IV", "N V", "O I", "O II", "O III", | |
| "O IV", "O V", "Si I", "Si II", "Si III", "Si IV", "S I", "S II", "S III", "S IV", "Fe I", "Fe II", "Fe III", | |
| "Fe XII", "Fe XXI", "Mg I", "Mg II", "Al I", "Al II", "Al III", "Ca II", "Cl I", "Cl II", "P I", "P II", "Ni II", | |
| "Cr II", "Mn II", "Co II", "Zn II", "Ti II", "Ne V") | |
| def cos_identify(): | |
| """Persistent features within 15 km/s of NIST chromospheric and transition-region lines (intensity listed or A above 1e7 /s, low levels for neutral and singly ionised).""" | |
| lines = [] | |
| for ion in cos_IONS: | |
| lines += core.nist_lines(ion, 1100, 1800, cache_dir=os.path.join(os.path.dirname(cos_ROOT), "nist")) | |
| def num(x): | |
| try: | |
| return float(str(x).strip("[]()+x? ")) | |
| except ValueError: | |
| return float("nan") | |
| strong = [] | |
| for ln in lines: | |
| stage = ln["ion"].split()[1] | |
| ei, aki = num(ln["ei"]), num(ln["aki"]) | |
| if (ln["intens"].strip() or aki > 1e7) and (stage not in ("I", "II") or ei < 30000): | |
| strong.append(ln) | |
| strong.sort(key=lambda ln: ln["lam"]) | |
| lam = np.array([ln["lam"] for ln in strong]) | |
| d = json.load(open(os.path.join(cos_HERE, "cos_laser.json"))) | |
| left = [p for p in d["persistent"] if cos_left(p)] | |
| rng = np.random.default_rng(1) | |
| r = rng.uniform(1150, 1750, 20000) | |
| k = np.searchsorted(lam, r).clip(1, len(lam) - 1) | |
| dv = np.minimum(np.abs(lam[k] - r), np.abs(lam[k - 1] - r)) / r * cos_C_KMS | |
| print("%d NIST lines, %d strong or low-excitation; a random wavelength lies within 2 and 15 km/s of one with " | |
| "probability %.2f and %.2f" % (len(lines), len(strong), np.mean(dv < 2), np.mean(dv < 15))) | |
| unid = [] | |
| for p in sorted(left, key=lambda p: p["p"]): | |
| dv = (lam - p["rest"]) / p["rest"] * cos_C_KMS | |
| j = int(np.argmin(np.abs(dv))) | |
| p["id"] = "%s %.3f (%+.0f km/s)" % (strong[j]["ion"], strong[j]["lam"], dv[j]) if abs(dv[j]) < 15 else None | |
| if not p["id"]: | |
| unid.append(p) | |
| print(" %-14s %s rest %.3f, %d exposures, p %.0e: %s" % (p["star"][:14], p["grating"], p["rest"], p["n"], p["p"], | |
| p["id"] or "no line within 15 km/s")) | |
| print("%d of %d persistent features unique to one star have no line within 15 km/s" % (len(unid), len(left))) | |
| json.dump(dict(d, identified=left), open(os.path.join(cos_HERE, "cos_laser.json"), "w"), indent=1) | |
| def cos_main(): | |
| p = argparse.ArgumentParser() | |
| p.add_argument("mode", choices=("spectra", "search", "identify")) | |
| a = p.parse_args() | |
| {"spectra": cos_spectra, "search": cos_search, "identify": cos_identify}[a.mode]() | |
| def cos_cli(): | |
| quiet_warnings() | |
| cos_main() | |
| # -------------------------------------------------------------------------------------------------------------- | |
| iue_DOC = r"""Laser lines in IUE high-dispersion spectra of stars within 10 pc: unresolved 6-sigma peaks persistent in two images of one camera and aperture, absent from other stars and the other aperture. | |
| python -I seti.py ultraviolet iue inventory | |
| python -I seti.py ultraviolet iue fetch [--threads 4] | |
| python -I seti.py ultraviolet iue search""" | |
| iue_ROOT = os.environ.get("IUE_ROOT", core.data("iue")) | |
| iue_HERE = core.RESULTS | |
| iue_DL = "https://mast.stsci.edu/api/v0.1/Download/file?uri=mast:IUE/url/missions/iue/data/%s/%05d/%s.mxhi.gz" | |
| iue_R = 10000.0 | |
| iue_Z_MIN = float(os.environ.get("IUE_ZMIN", "6.0")) | |
| iue_N_MIN = int(os.environ.get("IUE_NMIN", "2")) | |
| iue_C_KMS = 299792.458 | |
| iue_MATCH_ARCSEC = 30.0 | |
| iue_APERTURE_TOL = 10.0 # arcsec; the large aperture is 10 by 20 arcsec | |
| def iue_owned(inv): | |
| """One inventory entry per image: the nearest star within the aperture tolerance.""" | |
| by = {} | |
| for o in inv: | |
| by.setdefault(o["obs_id"], []).append(o) | |
| out = [] | |
| for v in by.values(): | |
| # archived coordinates err by up to 30 arcsec: the target name's star first | |
| named = [o for o in v if o.get("named")] | |
| o = min(named or v, key=lambda o: o["sep"]) | |
| if named or o["sep"] <= iue_APERTURE_TOL: | |
| out.append(o) | |
| return out | |
| def iue_inventory(): | |
| os.makedirs(iue_ROOT, exist_ok=True) | |
| stars = uv_nearby(100) | |
| every, page = [], 1 | |
| while True: | |
| d = hst_pulses_invoke({"service": "Mast.Caom.Filtered", "format": "json", "pagesize": 50000, "page": page, | |
| "params": {"columns": "obsid,obs_id,instrument_name,target_name,t_min,s_ra,s_dec", | |
| "filters": [{"paramName": "obs_collection", "values": ["IUE"]}, | |
| {"paramName": "filters", "values": ["HIGH DISP"]}]}}) | |
| rows = d.get("data", []) | |
| every += rows | |
| if len(rows) < 50000: | |
| break | |
| page += 1 | |
| print("%d IUE high-dispersion observations" % len(every), flush=True) | |
| ora = np.array([float(x["s_ra"]) if x.get("s_ra") is not None else np.nan for x in every]) | |
| ode = np.array([float(x["s_dec"]) if x.get("s_dec") is not None else np.nan for x in every]) | |
| tmin = np.array([float(x["t_min"]) if x.get("t_min") is not None else 46000.0 for x in every]) | |
| out = [] | |
| for s in stars: | |
| sep = uv_track_dist(s, ora, ode, tmin) | |
| for j in np.where(sep < iue_MATCH_ARCSEC)[0]: | |
| x = every[j] | |
| out.append(dict(star=" ".join(s["main_id"].split()), rv=s.get("rvz_radvel"), obs_id=x["obs_id"], | |
| camera=x["instrument_name"], target=x["target_name"], mjd=float(tmin[j]), sep=float(sep[j]))) | |
| names = uv_resolve({o["target"] for o in out}) | |
| for o in out: | |
| o["named"] = names.get(" ".join(str(o["target"]).split())) == o["star"] | |
| json.dump(out, open(os.path.join(iue_ROOT, "inventory.json"), "w"), indent=1) | |
| print("%d high-dispersion images of %d objects within 10 pc" % (len(out), len({o["star"] for o in out}))) | |
| def iue_url(obs_id): | |
| cam = "".join(c for c in obs_id if c.isalpha()).lower() | |
| num = int("".join(c for c in obs_id if c.isdigit())) | |
| return iue_DL % (cam, num // 1000 * 1000, obs_id.lower()) | |
| def iue_fetch(threads): | |
| from concurrent.futures import ThreadPoolExecutor | |
| inv = json.load(open(os.path.join(iue_ROOT, "inventory.json"))) | |
| os.makedirs(os.path.join(iue_ROOT, "mxhi"), exist_ok=True) | |
| def one(o): | |
| p = os.path.join(iue_ROOT, "mxhi", o + ".mxhi.gz") | |
| if os.path.exists(p): | |
| return 1 | |
| b = core.get(iue_url(o), timeout=180, retries=3, soft=True, pause=2) | |
| if not b or b[:2] != b"\x1f\x8b" or len(b) < 5000: | |
| return 0 | |
| open(p + ".tmp", "wb").write(b) | |
| os.replace(p + ".tmp", p) | |
| return 1 | |
| ids = sorted({o["obs_id"] for o in inv}) | |
| with ThreadPoolExecutor(threads) as ex: | |
| n = sum(ex.map(one, ids)) | |
| print("%d of %d MXHI files on disk" % (n, len(ids))) | |
| def iue_orders(path): | |
| """[(wavelength, net, noise, abs_cal)] of an image's orders at QUALITY 0; ABS_CAL converts flux numbers to erg/cm2/s/A.""" | |
| from astropy.io import fits | |
| out = [] | |
| with fits.open(path) as h: | |
| d = h[1].data | |
| names = [c.upper() for c in d.columns.names] | |
| for row in d: | |
| n = int(row["NPOINTS"]) | |
| if n < 100: | |
| continue | |
| w = float(row["WAVELENGTH"]) + float(row["DELTAW"]) * np.arange(n) | |
| f = np.asarray(row["NET"][:n], float) | |
| e = np.asarray(row["NOISE"][:n], float) | |
| q = np.asarray(row["QUALITY"][:n], int) | |
| cal = np.asarray(row["ABS_CAL"][:n], float) if "ABS_CAL" in names else np.full(n, np.nan) | |
| ok = (q == 0) & np.isfinite(f) & np.isfinite(e) & (e > 0) | |
| if ok.sum() < 80: | |
| continue | |
| out.append((w[ok], f[ok], e[ok], cal[ok])) | |
| return out | |
| def iue_detect(w, f, e): | |
| """Unresolved peaks of one order: 6 sigma above a 5 A running upper envelope and 3 sigma above the brightest samples within 2.5 A.""" | |
| from scipy.ndimage import maximum_filter1d, percentile_filter | |
| from scipy.optimize import curve_fit | |
| out = [] | |
| step = float(np.median(np.diff(w))) | |
| env = percentile_filter(f, 90, size=max(int(5.0 / step) | 1, 61), mode="nearest") | |
| z = (f - env) / e | |
| half = max(int(2.5 / step), 10) | |
| left = maximum_filter1d(np.concatenate([np.full(half, -np.inf), f[:-half]]), size=half, origin=(half - 1) // 2) | |
| right = maximum_filter1d(np.concatenate([f[half:], np.full(half, -np.inf)]), size=half, origin=-(half // 2)) | |
| top = np.maximum(left, right) | |
| for i in np.where((z >= iue_Z_MIN) & (z >= np.roll(z, 1)) & (z >= np.roll(z, -1)))[0]: | |
| if i < 6 or i > len(w) - 7 or not f[i] - top[i] > 3 * e[i]: | |
| continue | |
| lam = w[i] | |
| if any(abs(lam - a) < 0.6 for a in starcat_AIRGLOW) or any(abs(lam - a) < 1.2 for a in starcat_CHROMOSPHERIC): | |
| continue | |
| inst = lam / iue_R | |
| x, y = w[i - 6:i + 7], (f - env)[i - 6:i + 7] | |
| try: | |
| p, _ = curve_fit(lambda x, a, m, sg: a * np.exp(-0.5 * ((x - m) / sg) ** 2), x, y, | |
| p0=[y[6], lam, inst / 2.355], sigma=e[i - 6:i + 7], maxfev=2000) | |
| except Exception: | |
| continue | |
| fw = 2.355 * abs(p[2]) | |
| if 0.5 * inst <= fw <= 1.5 * inst and abs(p[1] - lam) < inst: | |
| out.append(dict(lam=float(p[1]), z=float(z[i]), inst=float(inst))) | |
| return out | |
| def iue_features(path): | |
| return [x for w, f, e, _ in iue_orders(path) for x in iue_detect(w, f, e)] | |
| def iue_aperture(path): | |
| """LARGE or SMALL: the aperture whose spectrum an MXHI file holds.""" | |
| from astropy.io import fits | |
| with fits.open(path) as h: | |
| return str(h[0].header.get("APERTURE", "")).strip().upper() or "?" | |
| def iue_forced_z(path, lam): | |
| """Largest excess over the upper envelope in noise units within two pixels of lam; None without coverage.""" | |
| from scipy.ndimage import percentile_filter | |
| best = None | |
| for w, f, e, _ in iue_orders(path): | |
| if w[0] + 1 < lam < w[-1] - 1: | |
| j = int(np.searchsorted(w, lam)) | |
| step = float(np.median(np.diff(w))) | |
| env = percentile_filter(f, 90, size=max(int(5.0 / step) | 1, 61), mode="nearest") | |
| v = float(np.max((f[j - 2:j + 3] - env[j - 2:j + 3]) / e[j - 2:j + 3])) | |
| best = v if best is None else max(best, v) | |
| return best | |
| def iue__feat(job): | |
| quiet_warnings() | |
| p = os.path.join(iue_ROOT, "mxhi", job["obs_id"] + ".mxhi.gz") | |
| if not os.path.exists(p): | |
| return job, None | |
| try: | |
| return job, (iue_features(p), iue_aperture(p)) | |
| except Exception: | |
| return job, None | |
| def iue_search(): | |
| from concurrent.futures import ProcessPoolExecutor | |
| inv = iue_owned(json.load(open(os.path.join(iue_ROOT, "inventory.json")))) | |
| feats, n_read, apert, rvs = [], 0, {}, {} | |
| with ProcessPoolExecutor(int(os.environ.get("IUE_PROCS", "4"))) as ex: | |
| for job, res in ex.map(iue__feat, inv, chunksize=8): | |
| if res is None: | |
| continue | |
| fs, ap = res | |
| n_read += 1 | |
| apert[(job["star"], job["obs_id"])] = (job["camera"], ap) | |
| rv = float(job["rv"]) if job.get("rv") not in (None, "") else 0.0 | |
| rvs[job["star"]] = rv | |
| for f in fs: | |
| feats.append(dict(f, star=job["star"], camera=job["camera"], aperture=ap, obs_id=job["obs_id"], | |
| rest=f["lam"] / (1 + rv / iue_C_KMS))) | |
| print("%d images of %d objects read; %d narrow peaks at %.0f sigma" % (n_read, len({j["star"] for j in inv}), | |
| len(feats), iue_Z_MIN), flush=True) | |
| # persistence within one camera and aperture, each with its own detector position | |
| by = {} | |
| for f in feats: | |
| by.setdefault((f["star"], f["camera"], f["aperture"]), []).append(f) | |
| pers = [] | |
| for (star, cam, ap), fs in by.items(): | |
| fs.sort(key=lambda f: f["lam"]) | |
| for grp in uv_groups([f["lam"] for f in fs], [f["inst"] / 3 for f in fs]): | |
| imgs = {fs[j]["obs_id"] for j in grp} | |
| if len(imgs) >= iue_N_MIN: | |
| pers.append(dict(star=star, camera=cam, aperture=ap, lam=float(np.mean([fs[j]["lam"] for j in grp])), | |
| rest=float(np.mean([fs[j]["rest"] for j in grp])), n=len(imgs), | |
| of=sum(1 for (s, o), (c, a) in apert.items() if s == star and c == cam and a == ap), | |
| z=max(fs[j]["z"] for j in grp), inst=fs[grp[0]]["inst"])) | |
| print("%d persistent features in %d stars" % (len(pers), len({p["star"] for p in pers})), flush=True) | |
| rest = np.array([f["rest"] for f in feats]) | |
| obsw = np.array([f["lam"] for f in feats]) | |
| star = np.array([f["star"] for f in feats]) | |
| for p in pers: | |
| near_r = np.abs(rest - p["rest"]) / p["rest"] * iue_C_KMS < 15.0 | |
| near_o = np.abs(obsw - p["lam"]) / p["lam"] * iue_C_KMS < 15.0 | |
| p["stars_rest"] = len(set(star[near_r]) - {p["star"]}) | |
| p["stars_obs"] = len(set(star[near_o]) - {p["star"]}) | |
| left = sorted([p for p in pers if p["stars_rest"] < 2 and p["stars_obs"] < 2], key=lambda p: -p["z"]) | |
| # a detector-position feature is absent through the other aperture | |
| for p in left: | |
| other = [o for (s, o), (c, a) in apert.items() if s == p["star"] and c == p["camera"] and a != p["aperture"]] | |
| zs = [z for z in (iue_forced_z(os.path.join(iue_ROOT, "mxhi", o + ".mxhi.gz"), p["lam"]) for o in other) | |
| if z is not None] | |
| p["other_aperture_z"] = [round(z, 1) for z in zs] | |
| p["aperture_fixed"] = bool(zs) and max(zs) < 2.0 | |
| # forced measurements in other stars below detection: rest wavelength, same camera (stellar); observed wavelength, same camera and aperture (detector) | |
| for p in left: | |
| imgs = [(s, os.path.join(iue_ROOT, "mxhi", o + ".mxhi.gz"), a) for (s, o), (c, a) in apert.items() | |
| if s != p["star"] and c == p["camera"]] | |
| p["forced_rest"] = sorted(uv_forced([(s, q) for s, q, _ in imgs], | |
| lambda s: p["rest"] * (1 + rvs.get(s, 0.0) / iue_C_KMS), iue_forced_z)) | |
| p["forced_obs"] = sorted(uv_forced([(s, q) for s, q, a in imgs if a == p["aperture"]], lambda s: p["lam"], | |
| iue_forced_z)) | |
| kept = [p for p in left if not p["aperture_fixed"] and len(p["forced_rest"]) < 2 and len(p["forced_obs"]) < 2] | |
| print("%d persistent features in at most one other star in either frame; %d of them absent from the same star's " | |
| "images through the other aperture; %d above 5 sigma in two or more other stars when measured there; " | |
| "%d left:" % (len(left), sum(p["aperture_fixed"] for p in left), | |
| sum(1 for p in left if not p["aperture_fixed"] and p not in kept), len(kept))) | |
| for p in kept[:60]: | |
| print(" %-20s %s %s %.3f A (rest %.3f), %d of %d images, peak %.1f sigma, other stars %d/%d, other aperture %s" | |
| % (p["star"][:20], p["camera"], p["aperture"], p["lam"], p["rest"], p["n"], p["of"], p["z"], | |
| p["stars_rest"], p["stars_obs"], p["other_aperture_z"] or "none")) | |
| json.dump(dict(images=n_read, peaks=len(feats), persistent=pers), open(os.path.join(iue_HERE, "iue_laser.json"), "w"), | |
| indent=1) | |
| def iue_main(): | |
| p = argparse.ArgumentParser() | |
| p.add_argument("mode", choices=("inventory", "fetch", "search")) | |
| p.add_argument("--threads", type=int, default=4) | |
| a = p.parse_args() | |
| {"inventory": iue_inventory, "fetch": lambda: iue_fetch(a.threads), "search": iue_search}[a.mode]() | |
| def iue_cli(): | |
| quiet_warnings() | |
| iue_main() | |
| # -------------------------------------------------------------------------------------------------------------- | |
| fuse_DOC = r"""Laser lines in FUSE spectra (905-1187 A) of stars within 20 pc: point-source peaks at p 1e-6 recurring in a second channel, away from stellar and airglow lines and other stars' peaks. | |
| python -I seti.py ultraviolet fuse inventory [--plx 50] | |
| python -I seti.py ultraviolet fuse fetch | |
| python -I seti.py ultraviolet fuse search""" | |
| fuse_ROOT = os.environ.get("FUSE_ROOT", core.data("fuse")) | |
| fuse_HERE = core.RESULTS | |
| fuse_MAST = "https://mast.stsci.edu/api/v0/invoke" | |
| fuse_DOWNLOAD = "https://mast.stsci.edu/api/v0.1/Download/file?uri=" | |
| def fuse_invoke(req): | |
| data = urllib.parse.urlencode({"request": json.dumps(req)}).encode() | |
| for attempt in range(3): | |
| try: | |
| return json.loads(urllib.request.urlopen(urllib.request.Request( | |
| fuse_MAST, data=data, headers=dict(core.UA, **{"Content-Type": "application/x-www-form-urlencoded"})), timeout=240).read()) | |
| except Exception: | |
| time.sleep(5 * (attempt + 1)) | |
| return {"data": []} | |
| def fuse_inventory(plx): | |
| os.makedirs(fuse_ROOT, exist_ok=True) | |
| stars = uv_nearby(plx) | |
| every, page = [], 1 | |
| while True: | |
| d = fuse_invoke({"service": "Mast.Caom.Filtered", "format": "json", "pagesize": 5000, "page": page, | |
| "params": {"columns": "obsid,obs_id,t_exptime,target_name,s_ra,s_dec,t_min", | |
| "filters": [{"paramName": "obs_collection", "values": ["FUSE"]}]}}) | |
| rows = d.get("data", []) | |
| every += rows | |
| if len(rows) < 5000: | |
| break | |
| page += 1 | |
| print("%d FUSE observations; %d SIMBAD objects with parallax above %g mas" % (len(every), len(stars), plx), flush=True) | |
| ora = np.array([float(x["s_ra"]) if x.get("s_ra") is not None else np.nan for x in every]) | |
| ode = np.array([float(x["s_dec"]) if x.get("s_dec") is not None else np.nan for x in every]) | |
| tmin = np.array([float(x["t_min"]) if x.get("t_min") is not None else 52500.0 for x in every]) | |
| obs = {} | |
| for s in stars: | |
| dist = uv_track_dist(s, ora, ode, tmin) | |
| for j in np.where(dist < 108.0)[0]: | |
| x = every[j] | |
| o = obs.setdefault(str(x["obsid"]), dict(x, stars=[])) | |
| o["stars"].append(dict(main_id=s["main_id"], plx=s["plx_value"], sp=s["sp_type"], otype=s["otype"], | |
| rv=s["rvz_radvel"], dist=round(float(dist[j]), 2))) | |
| files = [] | |
| ids = list(obs) | |
| for k in range(0, len(ids), 50): | |
| p = fuse_invoke({"service": "Mast.Caom.Products", "format": "json", "pagesize": 20000, | |
| "params": {"obsid": ",".join(ids[k:k + 50])}}) | |
| for x in p.get("data", []): | |
| fn = x["productFilename"] | |
| if fn.endswith("00000all4ttagfcal.fit.gz") or fn.endswith("00000all4histfcal.fit.gz"): | |
| files.append(dict(file=fn, uri=x["dataURI"], size=int(x.get("size") or 0), obsid=str(x.get("obsID")))) | |
| json.dump(dict(obs=obs, files=files), open(os.path.join(fuse_ROOT, "inventory.json"), "w"), indent=1) | |
| names = {s["main_id"] for o in obs.values() for s in o["stars"]} | |
| print("%d FUSE observations of %d objects within %.0f pc; %d combined files, %.0f MB" | |
| % (len(obs), len(names), 1000.0 / plx, len(files), sum(f["size"] for f in files) / 1e6)) | |
| def fuse_fetch(): | |
| inv = json.load(open(os.path.join(fuse_ROOT, "inventory.json"))) | |
| d = os.path.join(fuse_ROOT, "all") | |
| os.makedirs(d, exist_ok=True) | |
| def one(f): | |
| p = os.path.join(d, f["file"]) | |
| if os.path.exists(p): | |
| return 1 | |
| b = core.get(fuse_DOWNLOAD + f["uri"], timeout=300, retries=3, soft=True, pause=5) | |
| if not b: | |
| return 0 | |
| open(p + ".tmp", "wb").write(b) | |
| os.replace(p + ".tmp", p) | |
| return 1 | |
| with ThreadPoolExecutor(4) as ex: | |
| n = sum(ex.map(one, inv["files"])) | |
| print("%d of %d combined files on disk" % (n, len(inv["files"]))) | |
| fuse_C_KMS = 299792.458 | |
| fuse_LSF_SIG = 0.026 # A, point-source line spread function (FWHM about 0.06 A) | |
| fuse_THR = 5.0 | |
| fuse_TOL_KMS = 20.0 # km/s; channel wavelength zero points differ by up to this | |
| fuse_APERTURE_TOL = 15.0 # arcsec; the large square aperture is 30 arcsec across | |
| def fuse_owner(o): | |
| """Inventory entry of the star in an observation's aperture, or None.""" | |
| st = {" ".join(s["main_id"].split()): s for s in o.get("stars") or []} | |
| name = uv_owner({k: s["dist"] for k, s in st.items()}, fuse_APERTURE_TOL) | |
| return dict(st[name], main_id=name) if name else None | |
| def fuse_on_target(path): | |
| """False for observations that put the star at the reference point, outside every aperture.""" | |
| from astropy.io import fits | |
| return str(fits.getheader(path, 0).get("APERTURE", "")).strip().upper() != "RFPT" | |
| def fuse_channels(path): | |
| """{channel: (wave, flux, error)} of a combined file, pixels without an error dropped.""" | |
| from astropy.io import fits | |
| out = {} | |
| with fits.open(path) as h: | |
| for e in h[1:]: | |
| d = e.data | |
| if d is None or len(d) < 500: | |
| continue | |
| w, f, s = (np.asarray(d[c], float) for c in ("WAVE", "FLUX", "ERROR")) | |
| ok = np.isfinite(w) & np.isfinite(f) & np.isfinite(s) & (s > 0) | |
| if ok.sum() > 500: | |
| out[e.name.strip()] = (w[ok], f[ok], s[ok]) | |
| return out | |
| def fuse_matched(w, f, s): | |
| """Point-source line amplitude over its error at every pixel, above a 2 A running 90th percentile.""" | |
| from scipy.ndimage import percentile_filter | |
| step = float(np.median(np.diff(w))) | |
| sp = fuse_LSF_SIG / step | |
| x = np.arange(-int(4 * sp), int(4 * sp) + 1) | |
| k = np.exp(-0.5 * (x / sp) ** 2) | |
| r = f - percentile_filter(f, 90, size=int(2.0 / step) | 1, mode="nearest") | |
| iv = 1.0 / s ** 2 | |
| num = np.convolve(r * iv, k[::-1], mode="same") | |
| den = np.convolve(iv, (k ** 2)[::-1], mode="same") | |
| a = num / np.maximum(den, 1e-300) | |
| err = 1.0 / np.sqrt(np.maximum(den, 1e-300)) | |
| return a / err, r, err | |
| def fuse_line_limit(w, f, s): | |
| """Median THR-sigma flux of an unresolved line over the channel, erg cm-2 s-1.""" | |
| _, _, err = fuse_matched(w, f, s) | |
| return float(np.median(fuse_THR * err) * np.sqrt(2 * np.pi) * fuse_LSF_SIG) | |
| def fuse_channel_peaks(w, f, s, thr): | |
| """[(wavelength, z, fitted sigma A, amplitude)] of point-source peaks above thr.""" | |
| from scipy.optimize import curve_fit | |
| z, r, _ = fuse_matched(w, f, s) | |
| out = [] | |
| step = float(np.median(np.diff(w))) | |
| edge = int(1.0 / step) | |
| cand = np.nonzero(z > thr)[0] | |
| cand = cand[(cand > edge) & (cand < len(w) - edge)] | |
| used = set() | |
| for i in cand: | |
| if i in used: | |
| continue | |
| j = i + int(np.argmax(z[i:i + 12])) | |
| used.update(range(j - 12, j + 13)) | |
| sl = slice(max(j - 20, 0), j + 21) | |
| try: | |
| p, _ = curve_fit(lambda t, a, m, sg, b: a * np.exp(-0.5 * ((t - m) / sg) ** 2) + b, w[sl], r[sl], | |
| p0=(r[j], w[j], fuse_LSF_SIG, 0.0), sigma=s[sl], maxfev=3000) | |
| except Exception: | |
| continue | |
| a, m, sg, b = p | |
| sg = abs(sg) | |
| if a > 0 and abs(m - w[j]) < 0.05 and 0.5 * fuse_LSF_SIG <= sg <= 2.0 * fuse_LSF_SIG: | |
| out.append((float(m), float(z[j]), float(sg), float(a))) | |
| return out | |
| def fuse_search(): | |
| inv = json.load(open(os.path.join(fuse_ROOT, "inventory.json"))) | |
| rv = {} | |
| found, limits, n_read = [], {}, 0 | |
| for f in inv["files"]: | |
| p = os.path.join(fuse_ROOT, "all", f["file"]) | |
| own = fuse_owner(inv["obs"].get(f["obsid"]) or {}) | |
| if own is None or not os.path.exists(p) or not fuse_on_target(p): | |
| continue | |
| n_read += 1 | |
| star = own["main_id"] | |
| rv[star] = float(own["rv"]) if own.get("rv") not in (None, "") else 0.0 | |
| ch = fuse_channels(p) | |
| lim = [fuse_line_limit(*v) for v in ch.values()] | |
| if lim: | |
| plx = float(own.get("plx") or "nan") | |
| limits.setdefault(star, []).append(dict(file=f["file"], flux=float(np.median(lim)), plx=plx)) | |
| pk = {c: fuse_channel_peaks(*v, thr=fuse_THR) for c, v in ch.items()} | |
| low = {c: fuse_channel_peaks(*v, thr=3.0) for c, v in ch.items()} | |
| for c, lst in pk.items(): | |
| for lam, z, sg, a in lst: | |
| cover = [d for d, (w, _, _) in ch.items() if d != c and w[0] + 1 < lam < w[-1] - 1] | |
| hits = [d for d in cover if any(abs(l2 - lam) / lam * fuse_C_KMS < fuse_TOL_KMS for l2, _, _, _ in low[d])] | |
| found.append(dict(file=f["file"], star=star, rv=rv.get(star, 0.0), channel=c, lam=lam, z=z, sig=sg, | |
| amp=a, cover=cover, confirmed=hits)) | |
| multi = [x for x in found if x["confirmed"]] | |
| for x in multi: | |
| x["rest"] = x["lam"] / (1 + x["rv"] / fuse_C_KMS) | |
| # stars with a confirmed peak at the same wavelength, stellar and heliocentric frames | |
| for x in multi: | |
| x["stars_rest"] = len({y["star"] for y in multi if y["star"] != x["star"] | |
| and abs(y["rest"] - x["rest"]) / x["rest"] * fuse_C_KMS < fuse_TOL_KMS}) | |
| x["stars_obs"] = len({y["star"] for y in multi if y["star"] != x["star"] | |
| and abs(y["lam"] - x["lam"]) / x["lam"] * fuse_C_KMS < fuse_TOL_KMS}) | |
| left = [x for x in multi if x["stars_rest"] < 2 and x["stars_obs"] < 2] | |
| lam, strong = fuse_nist_strong() | |
| rng = np.random.default_rng(1) | |
| r = rng.uniform(910, 1185, 20000) | |
| k = np.searchsorted(lam, r).clip(1, len(lam) - 1) | |
| chance = float(np.mean(np.minimum(np.abs(lam[k] - r), np.abs(lam[k - 1] - r)) / r * fuse_C_KMS < fuse_TOL_KMS)) | |
| for x in left: | |
| dv = (lam - x["rest"]) / x["rest"] * fuse_C_KMS | |
| j = int(np.argmin(np.abs(dv))) | |
| x["id"] = "%s %.3f (%+.0f km/s)" % (strong[j]["ion"], strong[j]["lam"], dv[j]) if abs(dv[j]) < fuse_TOL_KMS else None | |
| dh = (lam - x["lam"]) / x["lam"] * fuse_C_KMS | |
| j = int(np.argmin(np.abs(dh))) | |
| if x["id"] is None and abs(dh[j]) < fuse_TOL_KMS and strong[j]["ion"] in fuse_AIRGLOW: | |
| x["id"] = "airglow %s %.3f (%+.0f km/s)" % (strong[j]["ion"], strong[j]["lam"], dh[j]) | |
| best = {} | |
| for star, ls in limits.items(): | |
| x = min(ls, key=lambda x: x["flux"]) | |
| d_cm = 1000.0 / x["plx"] * 3.0857e18 if x["plx"] > 0 else float("nan") | |
| best[star] = dict(flux=x["flux"], plx=x["plx"], lum=4 * np.pi * d_cm ** 2 * x["flux"], file=x["file"]) | |
| fl = np.array([b["flux"] for b in best.values()]) | |
| lu = np.array([b["lum"] for b in best.values() if np.isfinite(b["lum"])]) | |
| print("%d objects: median %g-sigma unresolved-line limit %.1e erg cm-2 s-1, isotropic %.1e erg/s (%.1e W)" | |
| % (len(best), fuse_THR, np.median(fl), np.median(lu), np.median(lu) * 1e-7)) | |
| json.dump(dict(files=n_read, objects=len(limits), found=found, left=left, chance=chance, limits=best), | |
| open(os.path.join(fuse_HERE, "fuse_laser.json"), "w"), indent=1) | |
| print("%d observations with a star in the aperture; %d point-source peaks above %g; %d confirmed in a second " | |
| "channel; %d not shared with two or more other stars; a random wavelength lies within %.0f km/s of a strong " | |
| "NIST line with probability %.2f" % (n_read, len(found), fuse_THR, len(multi), len(left), fuse_TOL_KMS, chance)) | |
| seen = set() | |
| for x in sorted(left, key=lambda x: -x["z"]): | |
| key = (x["star"], round(x["rest"], 1)) | |
| if key in seen: | |
| continue | |
| seen.add(key) | |
| print(" %-22s %s %.3f A (rest %.3f), z %.1f, sigma %.3f A, confirmed in %s of %s, other stars %d/%d: %s" | |
| % (x["star"], x["channel"], x["lam"], x["rest"], x["z"], x["sig"], x["confirmed"], x["cover"], | |
| x["stars_rest"], x["stars_obs"], x["id"] or "no line within %.0f km/s" % fuse_TOL_KMS)) | |
| print("%d distinct features have no strong line within %.0f km/s" | |
| % (len({(x["star"], round(x["rest"], 1)) for x in left if not x["id"]}), fuse_TOL_KMS)) | |
| fuse_IONS = ("H I", "He I", "He II", "C I", "C II", "C III", "C IV", "N I", "N II", "N III", "N IV", "N V", "O I", "O II", | |
| "O III", "O IV", "O V", "O VI", "Ne V", "Ne VI", "Si II", "Si III", "Si IV", "P II", "P IV", "P V", "S II", | |
| "S III", "S IV", "S V", "S VI", "Ar I", "Ar II", "Fe II", "Fe III", "Fe XVIII", "Fe XIX") | |
| fuse_AIRGLOW = ("H I", "O I", "N I", "N II", "He I") | |
| def fuse_nist_strong(): | |
| """NIST's strong FUV lines of chromospheric, transition-region, coronal and airglow ions (intensity listed or A above 1e7 /s, low levels for neutral and singly ionised).""" | |
| lines = [] | |
| for ion in fuse_IONS: | |
| lines += core.nist_lines(ion, 900, 1190, cache_dir=os.path.join(os.path.dirname(fuse_ROOT), "nist")) | |
| def num(x): | |
| try: | |
| return float(str(x).strip("[]()+x? ")) | |
| except ValueError: | |
| return float("nan") | |
| strong = sorted((ln for ln in lines if (ln["intens"].strip() or num(ln["aki"]) > 1e7) | |
| and (ln["ion"].split()[1] not in ("I", "II") or num(ln["ei"]) < 30000)), key=lambda ln: ln["lam"]) | |
| return np.array([ln["lam"] for ln in strong]), strong | |
| def fuse_main(): | |
| p = argparse.ArgumentParser() | |
| p.add_argument("mode", choices=("inventory", "fetch", "search")) | |
| p.add_argument("--plx", type=float, default=50.0) | |
| a = p.parse_args() | |
| if a.mode == "inventory": | |
| fuse_inventory(a.plx) | |
| elif a.mode == "fetch": | |
| fuse_fetch() | |
| else: | |
| fuse_search() | |
| def fuse_cli(): | |
| quiet_warnings() | |
| fuse_main() | |
| # -------------------------------------------------------------------------------------------------------------- | |
| stis_DOC = r"""Laser lines in post-StarCAT HST/STIS echelle x1d spectra (2004 July on) of stars whose tracks lie within 3 arcsec, through StarCAT's detector and tests. | |
| python -I seti.py ultraviolet stis inventory | |
| python -I seti.py ultraviolet stis fetch [--threads 4] | |
| python -I seti.py ultraviolet stis search (UV_PROCS processes) | |
| python -I seti.py ultraviolet stis identify (the features left, against NIST's lines)""" | |
| stis_ROOT = os.environ.get("STIS_X1D_ROOT", core.data("stis_x1d")) | |
| stis_HERE = core.RESULTS | |
| stis_T0 = 53200.0 # 2004 July, after StarCAT's last exposures | |
| stis_TOL = 3.0 # arcsec; the echelle apertures are 0.2 arcsec across | |
| stis_TOL_NAMED = 15.0 # arcsec from the track of the star the target name resolves to | |
| stis_NOT_STARS = ("QSO", "G", "GiG", "GiC", "GiP", "BiC", "Sy1", "Sy2", "Sy*", "AGN", "Bla", "BLL", "LIN", "SBG", "rG", | |
| "PN", "SNR", "HII", "Cl*", "GlC", "OpC", "ISM", "MoC", "Pl", "Pl?", "LSB", "EmG", "H2G", "bCG", "IG") | |
| def stis_inventory(): | |
| os.makedirs(stis_ROOT, exist_ok=True) | |
| every, page = [], 1 | |
| while True: | |
| d = hst_pulses_invoke({"service": "Mast.Caom.Filtered", "format": "json", "pagesize": 50000, "page": page, | |
| "params": {"columns": "obsid,obs_id,instrument_name,filters,target_name,s_ra,s_dec,t_min", | |
| "filters": [{"paramName": "obs_collection", "values": ["HST"]}, | |
| {"paramName": "instrument_name", | |
| "values": ["STIS/FUV-MAMA", "STIS/NUV-MAMA"]}, | |
| {"paramName": "filters", "values": list(starcat_RES)}, | |
| {"paramName": "t_min", "values": [{"min": stis_T0, "max": 99999}]}]}}) | |
| rows = d.get("data", []) | |
| every += rows | |
| if len(rows) < 50000: | |
| break | |
| page += 1 | |
| def finite(x): | |
| try: | |
| return math.isfinite(float(x)) | |
| except (TypeError, ValueError): | |
| return False | |
| every = [r for r in every if finite(r.get("s_ra")) and finite(r.get("s_dec"))] | |
| pts = {} | |
| for r in every: | |
| pts.setdefault((round(float(r["s_ra"]), 5), round(float(r["s_dec"]), 5)), []).append(r) | |
| rows = [dict(source_id="%d" % i, ra=k[0], dec=k[1]) for i, k in enumerate(pts)] | |
| keys = list(pts) | |
| m = core.xmatch(rows, "simbad", 30.0) | |
| cands = {} | |
| for x in m: | |
| if (x.get("otype") or "").strip() in stis_NOT_STARS: | |
| continue | |
| cands.setdefault(int(x["source_id"]), []).append(x) | |
| # a resolved target name credits its star within stis_TOL_NAMED, since binary orbits carry stars off their tracks | |
| tnames = sorted({str(r["target_name"]) for r in every if r.get("target_name")}) | |
| ident = {t: re.sub(r"^(HD|HR|HIP|GJ|GL|BD|CD|LHS|LTT|WD)(?=[0-9+-])", r"\1 ", t.replace("-", " "), flags=re.I) | |
| for t in tnames} | |
| named = uv_resolve(sorted(set(ident.values()))) | |
| target_star = {t: named.get(" ".join(v.split())) for t, v in ident.items()} | |
| stars = sorted({s for s in target_star.values() if s}) | |
| info = {} | |
| for j in range(0, len(stars), 50): | |
| q = ("SELECT main_id, ra, dec, pmra, pmdec, plx_value AS plx, sp_type, rvz_radvel AS radvel, otype FROM basic " | |
| "WHERE main_id IN (%s)" % ",".join("'%s'" % s.replace("'", "''") for s in stars[j:j + 50])) | |
| for x in core.simbad(q): | |
| if (x.get("otype") or "").strip() not in stis_NOT_STARS and x.get("ra") is not None: | |
| info[" ".join(str(x["main_id"]).split())] = x | |
| obs = [] | |
| for i, k in enumerate(keys): | |
| best = None | |
| for x in cands.get(i, []) + [info[s] for s in {target_star.get(str(r.get("target_name"))) for r in pts[k]} if s in info]: | |
| nm = " ".join(str(x["main_id"]).split()) in {target_star.get(str(r.get("target_name"))) for r in pts[k]} | |
| for r in pts[k]: | |
| dist = float(uv_track_dist(dict(ra=x["ra"], dec=x["dec"], pmra=x.get("pmra") or 0, pmdec=x.get("pmdec") or 0), | |
| [float(r["s_ra"])], [float(r["s_dec"])], [float(r["t_min"])])[0]) | |
| ok = dist <= (stis_TOL_NAMED if nm else stis_TOL) | |
| if ok and (best is None or (not nm, dist) < (not best[2], best[0])): | |
| best = (dist, x, nm) | |
| if best is None: | |
| continue | |
| x = best[1] | |
| for r in pts[k]: | |
| obs.append(dict(r, star=" ".join(str(x["main_id"]).split()), otype=x.get("otype"), sp=x.get("sp_type"), | |
| rv=x.get("radvel"), plx=x.get("plx"), dist=round(best[0], 2), named=best[2])) | |
| ids = sorted({str(o["obsid"]) for o in obs}) | |
| files = [] | |
| for k in range(0, len(ids), 50): | |
| p = hst_pulses_invoke({"service": "Mast.Caom.Products", "format": "json", "pagesize": 20000, | |
| "params": {"obsid": ",".join(ids[k:k + 50])}}) | |
| for x in p.get("data", []): | |
| if x["productFilename"].endswith("_x1d.fits"): | |
| files.append(dict(file=x["productFilename"], uri=x["dataURI"], size=int(x.get("size") or 0), | |
| obsid=str(x.get("obsID")), parent=str(x.get("parent_obsid")))) | |
| files = list({f["file"]: f for f in files}.values()) | |
| json.dump(dict(obs=obs, files=files), open(os.path.join(stis_ROOT, "inventory.json"), "w"), indent=1) | |
| print("%d STIS echelle observations since 2004 July; %d of %d stars; %d x1d files, %.1f GB" | |
| % (len(every), len(obs), len({o["star"] for o in obs}), len(files), sum(f["size"] for f in files) / 1e9)) | |
| def stis_fetch(threads): | |
| inv = json.load(open(os.path.join(stis_ROOT, "inventory.json"))) | |
| d = os.path.join(stis_ROOT, "x1d") | |
| os.makedirs(d, exist_ok=True) | |
| def one(f): | |
| p = os.path.join(d, f["file"]) | |
| if os.path.exists(p): | |
| return 1 | |
| b = core.get(hst_pulses_DOWNLOAD + f["uri"], timeout=300, retries=3, soft=True, pause=5) | |
| if not b or not b.startswith(b"SIMPLE"): | |
| return 0 | |
| open(p + ".tmp", "wb").write(b) | |
| os.replace(p + ".tmp", p) | |
| return 1 | |
| with ThreadPoolExecutor(threads) as ex: | |
| n = sum(ex.map(one, inv["files"])) | |
| print("%d of %d x1d files on disk" % (n, len(inv["files"]))) | |
| def stis_orders(path): | |
| """[(wavelength, flux, error)] of each echelle order at good pixels, with the exposure's metadata.""" | |
| from astropy.io import fits | |
| out = [] | |
| with fits.open(path) as h: | |
| hd = h[0].header | |
| meta = dict(mode=str(hd.get("OPT_ELEM", "")).strip(), cenwave=hd.get("CENWAVE"), file=os.path.basename(path), | |
| rootname=str(hd.get("ROOTNAME", "")).strip().lower(), target=str(hd.get("TARGNAME", "")).strip()) | |
| for e in h[1:]: | |
| if e.name != "SCI" or e.data is None: | |
| continue | |
| meta["mjd"] = e.header.get("EXPSTART", meta.get("mjd")) | |
| for row in e.data: | |
| w, f, s, dq = (np.asarray(row[c], float) for c in ("WAVELENGTH", "FLUX", "ERROR", "DQ")) | |
| good = (s > 0) & np.isfinite(f) & ((dq.astype(int) & (4 | 8 | 16 | 32 | 64 | 128 | 256)) == 0) | |
| if good.sum() > 200: | |
| out.append((w[good], f[good], s[good])) | |
| return out, meta | |
| def stis__feat(job): | |
| quiet_warnings() | |
| path, star = job | |
| try: | |
| od, meta = stis_orders(path) | |
| except Exception: | |
| return None | |
| if meta["mode"] not in starcat_RES: | |
| return None | |
| return dict(meta, star=star, feats=[x for w, f, e in od for x in starcat_detect(w, f, e, meta["mode"])]) | |
| def stis_forced(job): | |
| """(star, [(z at each rest wavelength, z at each observed wavelength in the same grating and central wavelength)]) for one exposure.""" | |
| quiet_warnings() | |
| path, star, rv, feats = job | |
| try: | |
| od, meta = stis_orders(path) | |
| except Exception: | |
| return None | |
| out = [] | |
| for p in feats: | |
| zr = zo = None | |
| for w, f, e in od: | |
| a = starcat_forced_z(w, f, e, p["rest"] * (1 + rv / starcat_C_KMS)) | |
| zr = a if zr is None or (a is not None and a > zr) else zr | |
| if meta["mode"] == p["mode"] and meta["cenwave"] in p["cenwaves"]: | |
| b = starcat_forced_z(w, f, e, p["lam"]) | |
| zo = b if zo is None or (b is not None and b > zo) else zo | |
| out.append((zr, zo)) | |
| return star, out | |
| def stis_search(): | |
| from concurrent.futures import ProcessPoolExecutor | |
| inv = json.load(open(os.path.join(stis_ROOT, "inventory.json"))) | |
| star_of = {str(o["obsid"]): o for o in inv["obs"]} | |
| jobs = [] | |
| for f in inv["files"]: | |
| o = star_of.get(f["parent"]) or star_of.get(f["obsid"]) | |
| p = os.path.join(stis_ROOT, "x1d", f["file"]) | |
| if o and os.path.exists(p): | |
| jobs.append((p, o["star"])) | |
| rv = {o["star"]: float(o["rv"]) if o.get("rv") not in (None, "") else 0.0 for o in inv["obs"]} | |
| procs = int(os.environ.get("UV_PROCS", "2")) | |
| with ProcessPoolExecutor(procs) as ex: | |
| res = [r for r in ex.map(stis__feat, jobs, chunksize=4) if r] | |
| print("%d exposures of %d stars; %d narrow peaks at %d sigma or more" % ( | |
| len(res), len({r["star"] for r in res}), sum(len(r["feats"]) for r in res), starcat_Z_MIN), flush=True) | |
| by = {} | |
| for r in res: | |
| by.setdefault((r["star"], r["mode"]), []).append(r) | |
| pers = [] | |
| for (star, mode), rs in by.items(): | |
| if len({r["rootname"] for r in rs}) < 2: | |
| continue | |
| allf = sorted(((x, r["rootname"], r["cenwave"]) for r in rs for x in r["feats"]), key=lambda t: t[0]["lam"]) | |
| for grp in uv_groups([x["lam"] for x, _, _ in allf], [x["inst"] / 3 for x, _, _ in allf]): | |
| files = {allf[j][1] for j in grp} | |
| if len(files) >= 2: | |
| xs = [allf[j][0] for j in grp] | |
| lam = float(np.mean([y["lam"] for y in xs])) | |
| pers.append(dict(star=star, mode=mode, lam=lam, rest=lam / (1 + rv.get(star, 0.0) / starcat_C_KMS), | |
| n=len(files), of=len({r["rootname"] for r in rs}), z=float(max(y["z"] for y in xs)), | |
| inst=xs[0]["inst"], cenwaves=sorted({allf[j][2] for j in grp}))) | |
| print("%d persistent features (two or more exposures in one grating) in %d stars" | |
| % (len(pers), len({p["star"] for p in pers})), flush=True) | |
| pk = [(x["lam"], r["star"]) for r in res for x in r["feats"]] | |
| obsw = np.array([lam for lam, _ in pk]) | |
| rest = np.array([lam / (1 + rv.get(s, 0.0) / starcat_C_KMS) for lam, s in pk]) | |
| who = np.array([s for _, s in pk]) | |
| for p in pers: | |
| p["stars_rest"] = len(set(who[np.abs(rest - p["rest"]) / p["rest"] * starcat_C_KMS < 15.0]) - {p["star"]}) | |
| p["stars_obs"] = len(set(who[np.abs(obsw - p["lam"]) / p["lam"] * starcat_C_KMS < 15.0]) - {p["star"]}) | |
| left = [p for p in pers if p["stars_rest"] < 2 and p["stars_obs"] < 2] | |
| hits_r, hits_o = [set() for _ in left], [set() for _ in left] | |
| with ProcessPoolExecutor(procs) as ex: | |
| for out in ex.map(stis_forced, [(p, s, rv.get(s, 0.0), left) for p, s in jobs], chunksize=4): | |
| if not out: | |
| continue | |
| s, zs = out | |
| for k, (zr, zo) in enumerate(zs): | |
| if s != left[k]["star"]: | |
| if zr is not None and zr > 5.0: | |
| hits_r[k].add(s) | |
| if zo is not None and zo > 5.0: | |
| hits_o[k].add(s) | |
| for k, p in enumerate(left): | |
| p["forced_rest"], p["forced_obs"] = sorted(hits_r[k]), sorted(hits_o[k]) | |
| unique = sorted([p for p in left if len(p["forced_rest"]) < 2 and len(p["forced_obs"]) < 2], key=lambda p: -p["z"]) | |
| print("%d persistent features in at most one other star in either frame; %d of them above 5 sigma in two or more " | |
| "other stars when measured there; %d left:" % (len(left), len(left) - len(unique), len(unique))) | |
| for p in unique[:60]: | |
| print(" %-24s %s %.3f A (rest %.3f), in %d of %d exposures, peak %.1f sigma, other stars %d/%d" | |
| % (p["star"][:24], p["mode"], p["lam"], p["rest"], p["n"], p["of"], p["z"], p["stars_rest"], p["stars_obs"])) | |
| json.dump(dict(exposures=len(res), objects=len({r["star"] for r in res}), peaks=sum(len(r["feats"]) for r in res), | |
| persistent=pers), open(os.path.join(stis_HERE, "stis_laser.json"), "w"), indent=1) | |
| def stis_identify(): | |
| """Surviving STIS features against NIST lines by cos identify's rule, with the chance a random wavelength lies as close.""" | |
| lines = [] | |
| for ion in cos_IONS: | |
| lines += core.nist_lines(ion, 1140, 3150, cache_dir=os.path.join(os.path.dirname(stis_ROOT), "nist")) | |
| def num(x): | |
| try: | |
| return float(str(x).strip("[]()+x? ")) | |
| except ValueError: | |
| return float("nan") | |
| strong = sorted((ln for ln in lines if (ln["intens"].strip() or num(ln["aki"]) > 1e7) | |
| and (ln["ion"].split()[1] not in ("I", "II") or num(ln["ei"]) < 30000)), key=lambda ln: ln["lam"]) | |
| lam = np.array([ln["lam"] for ln in strong]) | |
| path = os.path.join(stis_HERE, "stis_laser.json") | |
| d = json.load(open(path)) | |
| left = [p for p in d["persistent"] if "forced_rest" in p and len(p["forced_rest"]) < 2 and len(p["forced_obs"]) < 2] | |
| r = np.random.default_rng(1).uniform(1150, 3100, 20000) | |
| k = np.searchsorted(lam, r).clip(1, len(lam) - 1) | |
| near = np.minimum(np.abs(lam[k] - r), np.abs(lam[k - 1] - r)) / r * cos_C_KMS | |
| print("%d NIST lines, %d strong or low-excitation; a random wavelength lies within 15 km/s of one with probability " | |
| "%.2f (FUV %.2f, NUV %.2f)" % (len(lines), len(strong), np.mean(near < 15), np.mean(near[r < 1700] < 15), | |
| np.mean(near[r >= 1700] < 15))) | |
| unid = [] | |
| for p in sorted(left, key=lambda p: -p["z"]): | |
| dv = (lam - p["rest"]) / p["rest"] * cos_C_KMS | |
| j = int(np.argmin(np.abs(dv))) | |
| p["id"] = "%s %.3f (%+.0f km/s)" % (strong[j]["ion"], strong[j]["lam"], dv[j]) if abs(dv[j]) < 15 else None | |
| if not p["id"]: | |
| unid.append(p) | |
| by = {} | |
| for p in left: | |
| by.setdefault(p["star"], [0, 0])[0 if p["id"] else 1] += 1 | |
| print("%d features left in %d stars; %d without a line within 15 km/s" % (len(left), len(by), len(unid))) | |
| for s, (a, b) in sorted(by.items(), key=lambda kv: -sum(kv[1])): | |
| print(" %-24s identified %d, unidentified %d" % (s[:24], a, b)) | |
| for p in unid[:40]: | |
| print(" unidentified %-20s %s rest %.3f, in %d of %d exposures, %.1f sigma" | |
| % (p["star"][:20], p["mode"], p["rest"], p["n"], p["of"], p["z"])) | |
| json.dump(dict(d, identified=left), open(path, "w"), indent=1) | |
| def stis_main(): | |
| p = argparse.ArgumentParser() | |
| p.add_argument("mode", choices=("inventory", "fetch", "search", "identify")) | |
| p.add_argument("--threads", type=int, default=4) | |
| a = p.parse_args() | |
| if a.mode == "inventory": | |
| stis_inventory() | |
| elif a.mode == "fetch": | |
| stis_fetch(a.threads) | |
| elif a.mode == "identify": | |
| stis_identify() | |
| else: | |
| stis_search() | |
| def stis_cli(): | |
| quiet_warnings() | |
| stis_main() | |
| # -------------------------------------------------------------------------------------------------------------- | |
| limits_DOC = r"""Sensitivity of the ultraviolet laser searches: nominal unresolved-line thresholds per exposure and injection recovery, a star's limit m90 times its second-best exposure. | |
| python -I seti.py ultraviolet limits starcat|iue|fuse|stis [--n 300] [--procs 4] | |
| python -I seti.py ultraviolet limits cos [--n 300] [--procs 4] (where the COS spectra are, STIS_ROOT) | |
| python -I seti.py ultraviolet limits table (per-star limits, distances and powers) | |
| python -I seti.py ultraviolet limits record (the four searches and their limits, coverage store)""" | |
| limits_HERE = core.RESULTS | |
| limits_MULT = (1.0, 1.5, 2.0, 3.0, 5.0, 8.0) | |
| limits_PER = 6 # lines injected per exposure and multiple, 6 instrumental widths apart | |
| limits_ROOT2PI = math.sqrt(2 * math.pi) | |
| def limits_m_at(frac, mult, rec): | |
| """Multiple recovered with probability frac, interpolated in the recovery curve.""" | |
| rec = np.maximum.accumulate(np.asarray(rec, float)) | |
| if rec[-1] < frac: | |
| return float("inf") | |
| return float(np.interp(frac, rec, mult)) if rec[0] < frac else float(mult[0]) | |
| def limits_masked(lam, masks, widths): | |
| return any(abs(lam - a) < w for a, w in zip(masks, widths)) | |
| # ------------------------------------------------------------------------------------------------------- StarCAT | |
| def limits_starcat_one(args): | |
| path, seed = args | |
| x = starcat_load(path) | |
| if x is None or len(x[0]) < 1000: | |
| return None | |
| w, f, e, meta = x | |
| R = starcat_RES[meta["mode"]] | |
| sig = w / R / 2.3548 | |
| nom = starcat_Z_MIN * e * limits_ROOT2PI * sig | |
| free = np.array([not limits_masked(lam, starcat_AIRGLOW + starcat_CHROMOSPHERIC, | |
| [0.6] * len(starcat_AIRGLOW) + [1.2] * len(starcat_CHROMOSPHERIC)) for lam in w]) | |
| out = dict(file=meta["file"], obj=meta["obj"], mode=meta["mode"], nominal=float(np.median(nom[free])), | |
| lam_med=float(np.median(w)), ra=meta["ra"], dec=meta["dec"]) | |
| if seed is None: | |
| return out | |
| rng = np.random.default_rng(seed) | |
| lo, hi = w[0] + 10, w[-1] - 10 | |
| rec = [] | |
| for m in limits_MULT: | |
| inj = np.zeros_like(f) | |
| lams = [] | |
| for _ in range(limits_PER * 20): | |
| l0 = rng.uniform(lo, hi) | |
| j = int(np.searchsorted(w, l0)) | |
| if j <= 0 or j >= len(w) or not free[j] or any(abs(l0 - q) < 6 * l0 / R for q in lams): | |
| continue | |
| lams.append(l0) | |
| inj += m * nom[j] / (limits_ROOT2PI * sig[j]) * np.exp(-0.5 * ((w - l0) / sig[j]) ** 2) | |
| if len(lams) == limits_PER: | |
| break | |
| found = starcat_detect(w, f + inj, e, meta["mode"]) | |
| rec.append(sum(1 for l0 in lams if any(abs(q["lam"] - l0) < 0.5 * l0 / R for q in found)) / max(len(lams), 1)) | |
| out["rec"] = rec | |
| return out | |
| def limits_stis_one(args): | |
| """Nominal threshold and recovery of one post-2004 STIS exposure, one line per echelle order and multiple.""" | |
| path, seed, star = args | |
| try: | |
| od, meta = stis_orders(path) | |
| except Exception: | |
| return None | |
| if meta["mode"] not in starcat_RES or not od: | |
| return None | |
| R = starcat_RES[meta["mode"]] | |
| noms, lams = [], [] | |
| rng = np.random.default_rng(seed) if seed is not None else None | |
| hits, tries = np.zeros(len(limits_MULT)), np.zeros(len(limits_MULT)) | |
| masks = starcat_AIRGLOW + starcat_CHROMOSPHERIC | |
| widths = [0.6] * len(starcat_AIRGLOW) + [1.2] * len(starcat_CHROMOSPHERIC) | |
| for w, f, e in od: | |
| sig = w / R / 2.3548 | |
| nom = starcat_Z_MIN * e * limits_ROOT2PI * sig | |
| free = np.array([not limits_masked(lam, masks, widths) for lam in w]) | |
| noms += list(nom[free]) | |
| lams += list(w[free]) | |
| if rng is None or len(w) < 200: | |
| continue | |
| edge = min(3.0, 0.25 * (w[-1] - w[0])) # an E140H order spans about 4 A | |
| if edge <= 0: | |
| continue | |
| for k, m in enumerate(limits_MULT): | |
| l0 = rng.uniform(w[0] + edge, w[-1] - edge) | |
| j = int(np.searchsorted(w, l0)) | |
| if j <= 0 or j >= len(w) or not free[j]: | |
| continue | |
| inj = m * nom[j] / (limits_ROOT2PI * sig[j]) * np.exp(-0.5 * ((w - l0) / sig[j]) ** 2) | |
| found = starcat_detect(w, f + inj, e, meta["mode"]) | |
| tries[k] += 1 | |
| hits[k] += any(abs(q["lam"] - l0) < 0.5 * l0 / R for q in found) | |
| if not noms: | |
| return None | |
| out = dict(file=meta["file"], star=star, mode=meta["mode"], nominal=float(np.median(noms)), | |
| lam_med=float(np.median(lams))) | |
| if rng is not None: | |
| out["rec"] = (hits / np.maximum(tries, 1)).tolist() | |
| return out | |
| # ------------------------------------------------------------------------------------------------------------ IUE | |
| def limits_iue_one(args): | |
| path, seed = args | |
| try: | |
| od = iue_orders(path) | |
| except Exception: | |
| return None | |
| if not od: | |
| return None | |
| R = iue_R | |
| noms, lam_all = [], [] | |
| rng = np.random.default_rng(seed) if seed is not None else None | |
| hits = np.zeros(len(limits_MULT)) | |
| tries = np.zeros(len(limits_MULT)) | |
| for w, f, e, cal in od: | |
| okc = np.isfinite(cal) & (np.abs(f) > 5 * e) | |
| if okc.sum() < 10: | |
| continue | |
| conv = np.interp(w, w[okc], (cal / f)[okc]) # erg/cm2/s/A per flux number | |
| sig = w / R / 2.3548 | |
| nom = iue_Z_MIN * e * np.abs(conv) * limits_ROOT2PI * sig | |
| free = np.array([not limits_masked(lam, starcat_AIRGLOW + starcat_CHROMOSPHERIC, | |
| [0.6] * len(starcat_AIRGLOW) + [1.2] * len(starcat_CHROMOSPHERIC)) for lam in w]) | |
| noms += list(nom[free]) | |
| lam_all += list(w[free]) | |
| if rng is None or len(w) < 200: | |
| continue | |
| for k, m in enumerate(limits_MULT): | |
| l0 = rng.uniform(w[0] + 2, w[-1] - 2) | |
| j = int(np.searchsorted(w, l0)) | |
| if j <= 0 or j >= len(w) or not free[j]: | |
| continue | |
| amp = m * iue_Z_MIN * e[j] # peak in flux numbers | |
| found = iue_detect(w, f + amp * np.exp(-0.5 * ((w - l0) / sig[j]) ** 2), e) | |
| tries[k] += 1 | |
| hits[k] += any(abs(q["lam"] - l0) < 0.5 * l0 / R for q in found) | |
| if not noms: | |
| return None | |
| out = dict(file=os.path.basename(path), nominal=float(np.median(noms)), lam_med=float(np.median(lam_all))) | |
| if rng is not None: | |
| out["rec"] = (hits / np.maximum(tries, 1)).tolist() | |
| out["tries"] = tries.tolist() | |
| return out | |
| # ----------------------------------------------------------------------------------------------------------- FUSE | |
| def limits_fuse_threshold(ch, errs, l0): | |
| """Unresolved-line flux at l0 reaching THR in the most sensitive channel and 3 sigma in the next; None under two channels.""" | |
| cover = [c for c, (w, _, _) in ch.items() if w[0] + 1 < l0 < w[-1] - 1] | |
| if len(cover) < 2: | |
| return None, cover | |
| e = sorted(float(errs[c][min(int(np.searchsorted(ch[c][0], l0)), len(ch[c][0]) - 1)]) for c in cover) | |
| return max(fuse_THR * e[0], 3.0 * e[1]) * limits_ROOT2PI * fuse_LSF_SIG, cover | |
| def limits_fuse_one(args): | |
| path, seed = args | |
| if not fuse_on_target(path): | |
| return None | |
| ch = fuse_channels(path) | |
| if not ch: | |
| return None | |
| errs = {c: fuse_matched(*v)[2] for c, v in ch.items()} | |
| grid = np.arange(910.0, 1187.0, 0.5) | |
| noms = [x for x in (limits_fuse_threshold(ch, errs, lam)[0] for lam in grid) if x is not None and np.isfinite(x)] | |
| if not noms: | |
| return None | |
| out = dict(file=os.path.basename(path), nominal=float(np.median(noms)), lam_med=1040.0) | |
| if seed is None: | |
| return out | |
| rng = np.random.default_rng(seed) | |
| rec = [] | |
| for m in limits_MULT: | |
| hit = n = 0 | |
| for _ in range(limits_PER): | |
| c0 = list(ch)[rng.integers(len(ch))] | |
| w0 = ch[c0][0] | |
| l0 = rng.uniform(w0[0] + 2, w0[-1] - 2) | |
| F, cover = limits_fuse_threshold(ch, errs, l0) | |
| if F is None or not np.isfinite(F): | |
| continue | |
| F = m * F | |
| det5, det3 = [], [] | |
| for c in cover: | |
| w, f, s = ch[c] | |
| g = F / (limits_ROOT2PI * fuse_LSF_SIG) * np.exp(-0.5 * ((w - l0) / fuse_LSF_SIG) ** 2) | |
| if any(abs(x[0] - l0) / l0 * fuse_C_KMS < fuse_TOL_KMS for x in fuse_channel_peaks(w, f + g, s, thr=fuse_THR)): | |
| det5.append(c) | |
| if any(abs(x[0] - l0) / l0 * fuse_C_KMS < fuse_TOL_KMS for x in fuse_channel_peaks(w, f + g, s, thr=3.0)): | |
| det3.append(c) | |
| n += 1 | |
| hit += any(any(d != c for d in det3) for c in det5) | |
| rec.append(hit / max(n, 1)) | |
| out["rec"] = rec | |
| return out | |
| # ------------------------------------------------------------------------------------------------------------ COS | |
| def limits_cos_one(args): | |
| r, seed, sens = args | |
| from scipy.stats import poisson | |
| lam = np.array(r["lam"]) | |
| on, bk = np.array(r["on"]), np.array(r["bk"]) | |
| if len(lam) < 200 or r["exptime"] <= 0: | |
| return None | |
| from scipy.ndimage import percentile_filter | |
| s3 = np.convolve(on, np.ones(3), "same") | |
| b3 = np.convolve(bk, np.ones(3), "same") | |
| step = abs(float(np.median(np.diff(lam)))) | |
| env = percentile_filter(s3, 90, size=max(int(1.0 / step) * 2 + 1, 31), mode="nearest") | |
| mu = np.maximum(env, b3 + 0.5) | |
| # 3-bin counts for p < P_MAX, scaled by the 72 per cent of the line in 6 pixels | |
| thr = poisson.isf(cos_P_MAX, mu) + 1 | |
| sig_px = cos_LSF_PX / 2.3548 | |
| frac = math.erf(3.0 / (sig_px * math.sqrt(2))) | |
| nline = np.maximum(thr - mu, 1.0) / frac | |
| key = "%s/%s" % (r["grating"], r["cenwave"]) | |
| s = sens.get(key) | |
| if s is None: | |
| return None | |
| S = np.interp(lam, s["lam"], s["sens"], left=np.nan, right=np.nan) # counts/s/pixel per erg/cm2/s/A | |
| k = r["disp"] / (r["exptime"] * S) # erg/cm2/s per count | |
| free = np.array([not (any(abs(x - a) < w for a, w in cos_AIRGLOW) or any(abs(x - a) < 1.2 | |
| for a in cos_STRONG)) | |
| for x in lam]) & np.isfinite(k) | |
| if free.sum() < 100: | |
| return None | |
| nom = nline * k | |
| out = dict(file=r["file"], star=r["star"], grating=r["grating"], fppos=r["fppos"], nominal=float(np.median(nom[free])), | |
| lam_med=float(np.median(lam[free]))) | |
| if seed is None: | |
| return out | |
| rng = np.random.default_rng(seed) | |
| rec = [] | |
| px = np.arange(len(lam)) * cos_BIN_PX | |
| for m in limits_MULT: | |
| hit = n = 0 | |
| for _ in range(limits_PER): | |
| j = int(rng.integers(20, len(lam) - 20)) | |
| if not free[j]: | |
| continue | |
| prof = np.exp(-0.5 * ((px - px[j]) / sig_px) ** 2) | |
| prof = prof / prof.sum() | |
| add = rng.poisson(m * nline[j] * prof) | |
| rr = dict(r, on=(on + add).tolist()) | |
| found = cos_peaks(rr, 0, cos_P_MAX) | |
| n += 1 | |
| hit += any(abs(q["lam"] - lam[j]) < 3 * r["disp"] * cos_BIN_PX for q in found) | |
| rec.append(hit / max(n, 1)) | |
| out["rec"] = rec | |
| return out | |
| def limits_cos_sensitivity(): | |
| """NET/FLUX per pixel against wavelength per grating and central wavelength, from one x1d each.""" | |
| import urllib.request | |
| from astropy.io import fits | |
| root = os.environ.get("STIS_ROOT", core.data("stis_tag")) | |
| rs = cos_owned(json.load(open(os.path.join(root, "cos_spectra.json")))) | |
| path = os.path.join(root, "cos_sens.json") | |
| if os.path.exists(path): | |
| return json.load(open(path)), rs | |
| pick = {} | |
| for r in rs: | |
| pick.setdefault("%s/%s" % (r["grating"], r["cenwave"]), r["file"][:9]) | |
| os.makedirs(os.path.join(root, "x1d"), exist_ok=True) | |
| sens = {} | |
| for key, rootname in pick.items(): | |
| p = os.path.join(root, "x1d", rootname + "_x1d.fits") | |
| if not os.path.exists(p): | |
| u = "https://mast.stsci.edu/api/v0.1/Download/file?uri=mast:HST/product/%s_x1d.fits" % rootname | |
| try: | |
| b = urllib.request.urlopen(urllib.request.Request(u, headers=core.UA), | |
| timeout=300).read() | |
| except Exception: | |
| continue | |
| if not b.startswith(b"SIMPLE"): | |
| continue | |
| open(p, "wb").write(b) | |
| lam, sv = [], [] | |
| with fits.open(p) as h: | |
| for row in h[1].data: | |
| w, fl, rate = (np.asarray(row[c], float) for c in ("WAVELENGTH", "FLUX", "NET")) | |
| ok = (fl > 0) & (rate > 0) & np.isfinite(fl) & np.isfinite(rate) | |
| lam += list(w[ok]) | |
| sv += list((rate / fl)[ok]) | |
| if lam: | |
| o = np.argsort(lam) | |
| lam, sv = np.array(lam)[o], np.array(sv)[o] | |
| # 51-pixel running median of the smooth ratio | |
| from scipy.ndimage import median_filter | |
| sens[key] = dict(lam=lam[::10].tolist(), sens=median_filter(sv, 51)[::10].tolist()) | |
| json.dump(sens, open(path, "w")) | |
| return sens, rs | |
| # ---------------------------------------------------------------------------------------------------------- driver | |
| def limits_run(which, n, procs): | |
| from concurrent.futures import ProcessPoolExecutor | |
| rng = np.random.default_rng(7) | |
| if which == "starcat": | |
| paths = sorted(os.path.join(dp, f) for dp, _, fs in os.walk(os.path.join(starcat_ROOT, "fits")) | |
| for f in fs if f.endswith("_spc.fits")) | |
| fn, items = limits_starcat_one, paths | |
| elif which == "iue": | |
| items = sorted(os.path.join(iue_ROOT, "mxhi", f) for f in os.listdir(os.path.join(iue_ROOT, "mxhi")) | |
| if f.endswith(".gz")) | |
| fn = limits_iue_one | |
| elif which == "fuse": | |
| items = sorted(os.path.join(fuse_ROOT, "all", f) for f in os.listdir(os.path.join(fuse_ROOT, "all"))) | |
| fn = limits_fuse_one | |
| elif which == "stis": | |
| inv = json.load(open(os.path.join(stis_ROOT, "inventory.json"))) | |
| star_of = {str(o["obsid"]): o["star"] for o in inv["obs"]} | |
| items = sorted((os.path.join(stis_ROOT, "x1d", f["file"]), star_of.get(f["parent"]) or star_of.get(f["obsid"])) | |
| for f in inv["files"] if os.path.exists(os.path.join(stis_ROOT, "x1d", f["file"]))) | |
| fn = limits_stis_one | |
| else: | |
| sens, rs = limits_cos_sensitivity() | |
| items = rs | |
| fn = limits_cos_one | |
| inj = set(rng.choice(len(items), size=min(n, len(items)), replace=False).tolist()) | |
| args = [(it, (int(rng.integers(1 << 30)) if i in inj else None)) for i, it in enumerate(items)] | |
| if which == "cos": | |
| args = [(it, s, sens) for it, s in args] | |
| elif which == "stis": | |
| args = [(it[0], s, it[1]) for it, s in args] | |
| with ProcessPoolExecutor(procs) as ex: | |
| res = [r for r in ex.map(fn, args, chunksize=4) if r] | |
| recs = np.array([r["rec"] for r in res if "rec" in r]) | |
| curve = recs.mean(axis=0).tolist() if len(recs) else [] | |
| # plateau: findable fraction of the band; the limit is 90 per cent of it | |
| plateau = float(max(curve)) if curve else None | |
| out = dict(set=which, mult=list(limits_MULT), recovery=curve, n_injected=int(len(recs)), plateau=plateau, | |
| m50=limits_m_at(0.5 * plateau, limits_MULT, curve) if curve else None, | |
| m90=limits_m_at(0.9 * plateau, limits_MULT, curve) if curve else None, exposures=res) | |
| json.dump(out, open(os.path.join(limits_HERE, "uv_limits_%s.json" % which), "w")) | |
| print("%s: %d exposures, median nominal limit %.2e erg cm-2 s-1; recovery at %s times nominal: %s; plateau %.2f; " | |
| "half and 90 per cent of it at %.2f and %.2f times nominal" | |
| % (which, len(res), np.median([r["nominal"] for r in res]), "/".join("%g" % m for m in limits_MULT), | |
| " ".join("%.2f" % x for x in curve), plateau or float("nan"), out["m50"] or float("nan"), | |
| out["m90"] or float("nan"))) | |
| limits_PC_M = 3.0857e16 | |
| limits_D_TX = 10.0 # transmitter aperture, m | |
| def limits_star_of_file(): | |
| """{file: SIMBAD main_id} for IUE images and FUSE observations with a star in the aperture.""" | |
| out = {} | |
| for o in iue_owned(json.load(open(os.path.join(iue_ROOT, "inventory.json")))): | |
| out[o["obs_id"].lower() + ".mxhi.gz"] = o["star"] | |
| fi = json.load(open(os.path.join(fuse_ROOT, "inventory.json"))) | |
| for f in fi["files"]: | |
| own = fuse_owner(fi["obs"].get(f["obsid"]) or {}) | |
| if own: | |
| out[f["file"]] = own["main_id"] | |
| return out | |
| def limits_table(): | |
| """Per-star limits: m90 times the second most sensitive exposure's nominal limit (best observation for FUSE), with distance, isotropic luminosity and 10 m diffraction-limited transmitter power.""" | |
| sets = {w: json.load(open(os.path.join(limits_HERE, "uv_limits_%s.json" % w))) | |
| for w in ("starcat", "iue", "cos", "fuse", "stis") | |
| if os.path.exists(os.path.join(limits_HERE, "uv_limits_%s.json" % w))} | |
| fmap = limits_star_of_file() | |
| plx, sp = {}, {} | |
| if "starcat" in sets: | |
| objs = {e["obj"]: (float(e["ra"]), float(e["dec"])) for e in sets["starcat"]["exposures"] if e.get("ra") is not None} | |
| for k, v in starcat_resolve(objs).items(): | |
| plx[k] = v["plx"] | |
| sp[k] = "%s; %s" % (v["main_id"], v["sp"]) | |
| names = set() | |
| for w in ("iue", "cos", "fuse", "stis"): | |
| for e in sets.get(w, {}).get("exposures", []): | |
| n = e.get("star") or fmap.get(e["file"]) | |
| if n: | |
| names.add(n) | |
| names = sorted(names) | |
| for k in range(0, len(names), 50): | |
| q = ("SELECT i.id, b.plx_value, b.sp_type FROM ident AS i JOIN basic AS b ON i.oidref = b.oid WHERE i.id IN (%s)" | |
| % ",".join("'%s'" % n.replace("'", "''") for n in names[k:k + 50])) | |
| for r in core.simbad(q): | |
| n = " ".join(str(r["id"]).split()) | |
| plx[n] = float(r["plx_value"]) if r.get("plx_value") not in (None, "") else None | |
| sp[n] = r.get("sp_type") or "" | |
| rows = [] | |
| for which, s in sets.items(): | |
| m90 = s["m90"] | |
| by = {} | |
| for e in s["exposures"]: | |
| star = e.get("obj") if which == "starcat" else e.get("star") or fmap.get(e["file"]) | |
| if not star: | |
| continue | |
| setting = e.get("mode") or e.get("grating") or e["file"][:3] | |
| by.setdefault((star, setting), []).append(e) | |
| best = {} | |
| for (star, setting), es in by.items(): | |
| v = sorted(x["nominal"] for x in es) | |
| lim = v[0] if which == "fuse" else (v[1] if len(v) >= 2 else None) | |
| if lim is None: | |
| continue | |
| lam = float(np.median([x["lam_med"] for x in es])) | |
| if star not in best or lim < best[star][0]: | |
| best[star] = (lim, setting, lam, len(es)) | |
| for star, (lim, setting, lam, n) in best.items(): | |
| F = lim * m90 # erg cm-2 s-1 | |
| p = plx.get(star) | |
| d = 1000.0 / p * limits_PC_M if p and p > 0 else None | |
| L = 4 * math.pi * d ** 2 * F * 1e-3 if d else None # W (erg cm-2 s-1 = 1e-3 W m-2) | |
| P = F * 1e-3 * d ** 2 * (lam * 1e-10 / limits_D_TX) ** 2 if d else None | |
| rows.append(dict(set=which, star=star, setting=setting, n=n, lam=lam, flux=F, plx=p, | |
| sp=sp.get(star, ""), L_iso_W=L, P_10m_W=P)) | |
| json.dump(dict(m90={w: s["m90"] for w, s in sets.items()}, rows=rows), | |
| open(os.path.join(limits_HERE, "uv_limits_table.json"), "w"), indent=1) | |
| for which in sets: | |
| rs = [r for r in rows if r["set"] == which] | |
| near = [r for r in rs if r["plx"] and r["plx"] > 100] | |
| print("%s: m90 %.2f; %d stars with two exposures, median %.1e erg cm-2 s-1; %d within 10 pc, median %.1e W " | |
| "isotropic, %.1e W through 10 m" % (which, sets[which]["m90"], len(rs), np.median([r["flux"] for r in rs]), | |
| len(near), np.median([r["L_iso_W"] for r in near]) if near else np.nan, | |
| np.median([r["P_10m_W"] for r in near]) if near else np.nan)) | |
| def limits_left(which, d): | |
| """Candidates a search's result file leaves after every test.""" | |
| if which in ("starcat", "stis"): | |
| return [p for p in d["persistent"] if "forced_rest" in p and len(p["forced_rest"]) < 2 | |
| and len(p["forced_obs"]) < 2] | |
| if which == "iue": | |
| return [p for p in d["persistent"] if "aperture_fixed" in p and not p["aperture_fixed"] | |
| and len(p["forced_rest"]) < 2 and len(p["forced_obs"]) < 2] | |
| if which == "cos": | |
| return [p for p in d["persistent"] if cos_left(p)] | |
| return d["left"] | |
| limits_SEARCHES = ( | |
| ("uv_laser_starcat", "starcat", "uv_laser.json", "Ultraviolet stellar spectra, STIS", "MAST HLSP StarCAT", | |
| "single exposures (Ayres 2010)", "every StarCAT exposure in E140M, E140H, E230M and E230H"), | |
| ("cos_laser_lines", "cos", "cos_laser.json", "Ultraviolet stellar spectra, COS", "MAST HST COS FUV corrtag", | |
| "CalCOS photon events", "COS FUV exposures with a SIMBAD star within 10 pc in the aperture"), | |
| ("iue_laser_lines", "iue", "iue_laser_6sig.json", "Ultraviolet stellar spectra, IUE", "MAST IUE NEWSIPS MXHI", | |
| "high dispersion", "images whose target is a SIMBAD star within 10 pc, by name or by its proper-motion track"), | |
| ("fuse_laser_lines", "fuse", "fuse_laser.json", "Ultraviolet stellar spectra, FUSE", | |
| "MAST FUSE CalFUSE combined channel spectra", "all4ttagfcal and all4histfcal", | |
| "observations with a SIMBAD star within 20 pc in the aperture, reference-point pointings left out"), | |
| ("uv_laser_stis", "stis", "stis_laser.json", "Ultraviolet stellar spectra, STIS after 2004", "MAST HST STIS x1d", | |
| "pipeline x1d, 2004 July on", "E140M, E140H, E230M and E230H exposures within 3 arcsec of a SIMBAD star's " | |
| "proper-motion track")) | |
| limits_VETTED = { | |
| "starcat": "steady features of Arcturus, in every exposure covering them from 1998 to 2003, emission of the Mira R " | |
| "Leo (the strongest within 4 km/s of Fe II 2626.27) and C III] 1908.73 of the symbiotic EG And", | |
| "cos": "chromospheric lines of eps Eri within 2 km/s of Cr II 1330.93, C I 1468.41 and Si I 1769.79, steady from " | |
| "2015 to 2025", | |
| "iue": "the last were fixed to one aperture's camera position or lay 440 km/s redward of Si II 1808 and 1817 A in " | |
| "the brightest G and K dwarfs, absent from STIS", | |
| "fuse": "of 9 single-channel peaks 4 lie on Ly beta, C III and O VI and 5 reach 5.0-5.5 sigma with nothing in the " | |
| "overlapping channel", | |
| "stis": "all lie in twelve stars whose ultraviolet spectra are crowded with natural lines: cool supergiants and giants " | |
| "(alpha Ori, alpha Her, gamma Cru, beta Gem), T Tauri stars (TW Hya, RU Lup), eta Car, the Ap star GY And, " | |
| "beta Pic, two stars catalogued as [KT2007] 1181 and 1184, and Procyon, whose features are resolved peaks " | |
| "of its spectrum steady in every exposure"} | |
| def limits_record(): | |
| """Record each search's row and per-star limits.""" | |
| t = json.load(open(os.path.join(limits_HERE, "uv_limits_table.json"))) | |
| for sid, which, fn, approach, archive, release, selection in limits_SEARCHES: | |
| d = json.load(open(os.path.join(limits_HERE, fn))) | |
| s = json.load(open(os.path.join(limits_HERE, "uv_limits_%s.json" % which))) | |
| rows = [r for r in t["rows"] if r["set"] == which] | |
| near = [r for r in rows if r["plx"] and r["plx"] > 100] | |
| left = limits_left(which, d) | |
| if which == "fuse": | |
| null = "%d single-channel peaks, %d confirmed in a second channel" % ( | |
| len(d["found"]), sum(1 for x in d["found"] if x["confirmed"])) | |
| else: | |
| null = "%d peaks, %d persistent, %d left after the cross-star, aperture and forced tests" % ( | |
| d["peaks"], len(d["persistent"]), len(left)) | |
| core.record_search( | |
| sid, approach, archive=archive, release=release, selection=selection, code=os.path.abspath(__file__), | |
| entry=which, n_targets=len(rows), null_summary=null, ledger="Ultraviolet stellar spectra", | |
| params=dict(plateau=s["plateau"], m50=s["m50"], m90=s["m90"], injected=s["n_injected"]), | |
| sensitivity="median per-star limit %.1e erg cm-2 s-1; %d stars within 10 pc, median %.1e W isotropic and " | |
| "%.1e W through a 10 m aperture" % (np.median([r["flux"] for r in rows]), len(near), | |
| np.median([r["L_iso_W"] for r in near]) if near else np.nan, | |
| np.median([r["P_10m_W"] for r in near]) if near else np.nan), | |
| result="%d features pass every test; %s" % (len(left), limits_VETTED[which])) | |
| core.clear_targets(sid) | |
| core.record_targets(sid, [ | |
| dict(target="simbad:%s" % (r["sp"].split(";")[0] if which == "starcat" and r.get("sp") else r["star"]), | |
| upper_limit=r["flux"], unit="erg cm-2 s-1, unresolved line at 90 per cent of the recovery plateau", | |
| note="%s, %.0f A; isotropic %.1e W; 10 m beam %.1e W" % (r["setting"], r["lam"], r["L_iso_W"] or np.nan, | |
| r["P_10m_W"] or np.nan)) | |
| for r in rows]) | |
| print("%s: %d stars, %d left" % (sid, len(rows), len(left))) | |
| def limits_main(): | |
| p = argparse.ArgumentParser() | |
| p.add_argument("which", choices=("starcat", "iue", "fuse", "cos", "stis", "table", "record")) | |
| p.add_argument("--n", type=int, default=300) | |
| p.add_argument("--procs", type=int, default=4) | |
| a = p.parse_args() | |
| if a.which == "table": | |
| limits_table() | |
| elif a.which == "record": | |
| limits_record() | |
| else: | |
| limits_run(a.which, a.n, a.procs) | |
| def limits_cli(): | |
| quiet_warnings() | |
| limits_main() | |
| # -------------------------------------------------------------------------------------------------------------- | |
| hst_pulses_DOC = r"""Ultraviolet laser pulses in STIS TIME-TAG and COS corrtag events of stars within 10 pc: same-bin photon clusters in 1 ms, 32 ms or 0.25 s beyond the local rate, Poisson FAP with trials. | |
| python -I seti.py ultraviolet hst_pulses inventory | |
| python -I seti.py ultraviolet hst_pulses fetch | |
| python -I seti.py ultraviolet hst_pulses search""" | |
| hst_pulses_ROOT = os.environ.get("STIS_ROOT", core.data("stis_tag")) | |
| hst_pulses_HERE = core.RESULTS | |
| hst_pulses_MAST = "https://mast.stsci.edu/api/v0/invoke" | |
| hst_pulses_DOWNLOAD = "https://mast.stsci.edu/api/v0.1/Download/file?uri=" | |
| hst_pulses_INSTRUMENTS = ["STIS/FUV-MAMA", "STIS/NUV-MAMA", "COS/FUV", "COS/NUV"] | |
| hst_pulses_MIN_SPAN = 10.0 | |
| hst_pulses_LOCAL = 5.0 | |
| hst_pulses_SPOT = 30.0 | |
| def hst_pulses_invoke(req): | |
| data = urllib.parse.urlencode({"request": json.dumps(req)}).encode() | |
| for attempt in range(3): | |
| try: | |
| return json.loads(urllib.request.urlopen(urllib.request.Request( | |
| hst_pulses_MAST, data=data, headers=dict(core.UA, **{"Content-Type": "application/x-www-form-urlencoded"})), | |
| timeout=240).read()) | |
| except Exception: | |
| time.sleep(5 * (attempt + 1)) | |
| return {"data": []} | |
| def hst_pulses_inventory(): | |
| os.makedirs(hst_pulses_ROOT, exist_ok=True) | |
| stars = uv_nearby(100) | |
| # one filtered MAST query, matched to the stars' tracks locally | |
| every, page = [], 1 | |
| while True: | |
| d = hst_pulses_invoke({"service": "Mast.Caom.Filtered", "format": "json", "pagesize": 50000, "page": page, | |
| "params": {"columns": "obsid,obs_id,instrument_name,t_exptime,target_name,s_ra,s_dec,t_min", | |
| "filters": [{"paramName": "obs_collection", "values": ["HST"]}, | |
| {"paramName": "instrument_name", "values": hst_pulses_INSTRUMENTS}]}}) | |
| rows = d.get("data", []) | |
| every += rows | |
| if len(rows) < 50000: | |
| break | |
| page += 1 | |
| print("%d HST observations with %s" % (len(every), ", ".join(hst_pulses_INSTRUMENTS)), flush=True) | |
| ora = np.array([float(x["s_ra"]) if x.get("s_ra") is not None else np.nan for x in every]) | |
| ode = np.array([float(x["s_dec"]) if x.get("s_dec") is not None else np.nan for x in every]) | |
| tmin = np.array([float(x["t_min"]) if x.get("t_min") is not None else 55000.0 for x in every]) | |
| allobs = {} | |
| res = [] | |
| for s in stars: | |
| dist = uv_track_dist(s, ora, ode, tmin) | |
| near = np.where(dist < 108.0)[0] | |
| res.append((s, near)) | |
| for j in near: | |
| x = every[j] | |
| o = allobs.setdefault(str(x["obsid"]), dict(x, stars=[], dist={})) | |
| o["stars"].append(s["main_id"]) | |
| o["dist"][" ".join(s["main_id"].split())] = round(float(dist[j]), 2) | |
| ids = list(allobs) | |
| tags = [] | |
| for k in range(0, len(ids), 50): | |
| p = hst_pulses_invoke({"service": "Mast.Caom.Products", "format": "json", "pagesize": 20000, | |
| "params": {"obsid": ",".join(ids[k:k + 50])}}) | |
| for x in p.get("data", []): | |
| fn = x["productFilename"] | |
| if fn.endswith(("_tag.fits", "_corrtag_a.fits", "_corrtag_b.fits", "_corrtag.fits")): | |
| tags.append(dict(file=fn, uri=x["dataURI"], size=int(x.get("size") or 0), obsid=str(x.get("obsID")), | |
| parent=str(x.get("parent_obsid")))) | |
| uniq = {t["file"]: t for t in tags} | |
| json.dump(dict(obs=allobs, tags=list(uniq.values())), open(os.path.join(hst_pulses_ROOT, "inventory.json"), "w"), indent=1) | |
| print("%d HST STIS and COS MAMA observations near %d objects; %d event files, %.1f GB" | |
| % (len(allobs), sum(1 for s, near in res if len(near)), len(uniq), sum(t["size"] for t in uniq.values()) / 1e9)) | |
| def hst_pulses_fetch(): | |
| inv = json.load(open(os.path.join(hst_pulses_ROOT, "inventory.json"))) | |
| os.makedirs(os.path.join(hst_pulses_ROOT, "events"), exist_ok=True) | |
| def one(t): | |
| p = os.path.join(hst_pulses_ROOT, "events", t["file"]) | |
| if os.path.exists(p): | |
| return 1 | |
| b = core.get(hst_pulses_DOWNLOAD + t["uri"], timeout=600, retries=3, soft=True, pause=5) | |
| if not b or not b.startswith(b"SIMPLE"): | |
| return 0 | |
| open(p + ".tmp", "wb").write(b) | |
| os.replace(p + ".tmp", p) | |
| return 1 | |
| with ThreadPoolExecutor(4) as ex: | |
| n = sum(ex.map(one, inv["tags"])) | |
| print("%d of %d event files on disk" % (n, len(inv["tags"]))) | |
| hst_pulses_CAL_TARGETS = ("DARK", "BIAS", "DEUTERIUM", "WAVE", "WAVEHITM", "WAVELINE", "FLAT", "CCDFLAT", "NONE") | |
| def hst_pulses_science(hd): | |
| """False for calibration exposures (darks, flats, lamps) filed under a science visit.""" | |
| et = str(hd.get("EXPTYPE", "EXTERNAL/SCI")).strip().upper() | |
| tn = str(hd.get("TARGNAME", "")).strip().upper() | |
| return et in ("EXTERNAL/SCI", "") and not any(tn.startswith(c) for c in hst_pulses_CAL_TARGETS) | |
| def hst_pulses_read_events(path): | |
| """Times (s), detector bin indices and test windows from a STIS _tag or COS _corrtag file.""" | |
| from astropy.io import fits | |
| out = [] | |
| with fits.open(path) as h: | |
| hd = h[0].header | |
| inst = str(hd.get("INSTRUME", "")).strip() | |
| if not hst_pulses_science(hd): | |
| return out | |
| for ext in h: | |
| if ext.name != "EVENTS" or ext.data is None or len(ext.data) == 0: | |
| continue | |
| d = ext.data | |
| cols = [c.upper() for c in d.columns.names] | |
| t = np.asarray(d["TIME"], float) | |
| if inst == "STIS": | |
| x, y = np.asarray(d["AXIS1"], float), np.asarray(d["AXIS2"], float) | |
| bx, by = 8, 8 | |
| windows = (0.001, 0.032, 0.25) | |
| # merge MAMA double counts: events within 2 pixels and 250 us of the previous | |
| o = np.argsort(t, kind="stable") | |
| t, x, y = t[o], x[o], y[o] | |
| dup = np.zeros(len(t), bool) | |
| for lag in range(1, 7): | |
| dup[lag:] |= (t[lag:] - t[:-lag] < 250e-6) & (np.abs(x[lag:] - x[:-lag]) <= 2) & \ | |
| (np.abs(y[lag:] - y[:-lag]) <= 2) | |
| t, x, y = t[~dup], x[~dup], y[~dup] | |
| else: | |
| x = np.asarray(d["XCORR"] if "XCORR" in cols else d["RAWX"], float) | |
| y = np.asarray(d["YCORR"] if "YCORR" in cols else d["RAWY"], float) | |
| fuv = str(hd.get("DETECTOR", "")).strip() == "FUV" | |
| bx, by = (16, 4) if fuv else (8, 8) | |
| good = np.ones(len(t), bool) | |
| if "DQ" in cols: | |
| # COS DQ bits (Data Handbook table 2.19): 2 hot spot, 8 calibration, 16/1024 low response, 32 background, 64 burst, 128 bounds, 512 PHA, 2048 bad time, 4096 low PHA, 8192 gain sag, 16384 dark edge | |
| good &= (np.asarray(d["DQ"], int) & (2 | 8 | 16 | 32 | 64 | 128 | 512 | 1024 | 2048 | 4096 | 8192 | 16384)) == 0 | |
| if fuv and "PHA" in cols: | |
| pha = np.asarray(d["PHA"], int) | |
| good &= (pha >= int(ext.header.get("PHALOWRA", 2))) & (pha <= int(ext.header.get("PHAUPPRA", 23))) | |
| if "WAVELENGTH" in cols: | |
| # no wavelength: digitizer-edge rows outside the calibrated area | |
| good &= np.asarray(d["WAVELENGTH"], float) > 0 | |
| # aperture light only: events within the SP_LOC, SP_HGT extraction band in YFULL | |
| if "YFULL" in cols: | |
| yf = np.asarray(d["YFULL"], float) | |
| xf = np.asarray(d["XFULL"], float) | |
| band = np.zeros(len(t), bool) | |
| for s in "ABC": | |
| loc, hgt = ext.header.get("SP_LOC_" + s), ext.header.get("SP_HGT_" + s) | |
| if loc is None or hgt is None or float(loc) < 0: | |
| continue | |
| if fuv and str(hd.get("SEGMENT", "")).strip()[-1:] != s: | |
| continue | |
| slp = float(ext.header.get("SP_SLP_" + s, 0.0) or 0.0) | |
| half = float(hgt) / 2 + 2 | |
| # slope reference column unrecorded: the trace is whichever of edge and centre puts more events in the band | |
| best = None | |
| for xr in (0.0, 8192.0 if fuv else 512.0): | |
| inb = np.abs(yf - (float(loc) + slp * (xf - xr))) <= half | |
| if best is None or inb.sum() > best.sum(): | |
| best = inb | |
| band |= best | |
| good &= band | |
| t, x, y = t[good], x[good], y[good] | |
| # TAGFLASH lamp intervals (LMP_ONn, LMPOFFn) cut with 2 s margins | |
| eh = ext.header | |
| flashes = [(float(eh["LMP_ON%d" % i]), float(eh.get("LMPOFF%d" % i, eh["LMP_ON%d" % i] + 15.0))) | |
| for i in range(1, 40) if "LMP_ON%d" % i in eh] | |
| if flashes: | |
| keep = np.ones(len(t), bool) | |
| for on, off in flashes: | |
| keep &= ~((t >= on - 2.0) & (t <= off + 2.0)) | |
| t, x, y = t[keep], x[keep], y[keep] | |
| windows = (0.032, 0.25) | |
| ok = np.isfinite(t) & np.isfinite(x) & np.isfinite(y) | |
| t, x, y = t[ok], x[ok], y[ok] | |
| b = (np.floor(x / bx).astype(np.int64) << 20) + np.floor(y / by).astype(np.int64) | |
| out.append(dict(inst=inst, target=str(hd.get("TARGNAME", "")).strip(), t=t, b=b, windows=windows, | |
| det=str(hd.get("DETECTOR", "")).strip() + str(hd.get("SEGMENT", "")).strip(), bx=bx, by=by, | |
| exptime=float(hd.get("TEXPTIME", 0) or (t.max() - t.min() if len(t) else 0)))) | |
| return out | |
| def hst_pulses_scan(ev): | |
| """Largest same-bin cluster per window, its bin's expectation and the false-alarm probability.""" | |
| from scipy.stats import poisson | |
| t, b = ev["t"], ev["b"] | |
| if len(t) < 50: | |
| return None | |
| t0 = float(t.min()) | |
| span = float(t.max()) - t0 | |
| if span < hst_pulses_MIN_SPAN: | |
| return None | |
| # one sort key by bin then time, bins over 2 x SPOT seconds apart | |
| key_scale = 10.0 ** math.ceil(math.log10(span + 2 * hst_pulses_SPOT + 10.0)) | |
| ub, inv, cnt = np.unique(b, return_inverse=True, return_counts=True) | |
| key = inv.astype(np.float64) * key_scale + (t - t0) | |
| o = np.argsort(key) | |
| key, inv_s, ts = key[o], inv[o], t[o] | |
| tall = np.sort(t) | |
| # expectation: the larger of the mean rate scaled by the detector's LOCAL rate (flares, bursts) and the spot's own SPOT-second rate (airglow, hot pixels) | |
| near = np.searchsorted(tall, ts + hst_pulses_LOCAL, side="right") - np.searchsorted(tall, ts - hst_pulses_LOCAL, side="left") | |
| scale = near / (2 * hst_pulses_LOCAL * len(t) / span) | |
| rate = cnt[inv_s] / span * np.maximum(scale, 1e-3) | |
| own = np.searchsorted(key, key + hst_pulses_SPOT, side="right") - np.searchsorted(key, key - hst_pulses_SPOT, side="left") | |
| dur = np.minimum(ts + hst_pulses_SPOT, t0 + span) - np.maximum(ts - hst_pulses_SPOT, t0) | |
| res = {} | |
| for w in ev["windows"]: | |
| # COS 32 ms ticks: a one-tick window holds one tick | |
| k = np.searchsorted(key, key + w * (1 - 1e-6), side="right") - np.arange(len(key)) | |
| mu = np.maximum(rate * w, (own - k) / np.maximum(dur - w, 1.0) * w) | |
| cand = np.where(k >= 2)[0] | |
| if len(cand) == 0: | |
| res[str(w)] = dict(k=1, mu=0.0, p=1.0, fap=1.0, t=None, detector=None, mean_detector=len(t) / span * w) | |
| continue | |
| # probability of the other k - 1 events at this bin's rate | |
| p = poisson.sf(k[cand] - 2, mu[cand]) | |
| j = cand[int(np.argmin(p))] | |
| pmin = float(p.min()) | |
| det = int(np.searchsorted(tall, ts[j] + w, side="right") - np.searchsorted(tall, ts[j], side="left")) | |
| fap = -math.expm1(len(t) * math.log1p(-min(pmin, 1 - 1e-16))) | |
| bj = int(ub[inv_s[j]]) | |
| res[str(w)] = dict(k=int(k[j]), mu=float(mu[j]), p=pmin, fap=fap, t=float(ts[j]), detector=det, | |
| mean_detector=len(t) / span * w, site=[ev["det"], (bj >> 20) * ev["bx"], (bj & 0xFFFFF) * ev["by"]]) | |
| return dict(n=int(len(t)), span=span, windows=res) | |
| def hst_pulses_scan_file(job): | |
| tg, stars = job | |
| p = os.path.join(hst_pulses_ROOT, "events", tg["file"]) | |
| if not os.path.exists(p): | |
| return [] | |
| try: | |
| evs = hst_pulses_read_events(p) | |
| except Exception as e: | |
| print(tg["file"], "unreadable:", str(e)[:60], flush=True) | |
| return [] | |
| out = [] | |
| for k, ev in enumerate(evs): | |
| r = hst_pulses_scan(ev) | |
| if r: | |
| out.append(dict(file=tg["file"], ext=k, inst=ev["inst"], target=ev["target"], stars=stars, **r)) | |
| return out | |
| def hst_pulses_search(): | |
| from concurrent.futures import ProcessPoolExecutor | |
| inv = json.load(open(os.path.join(hst_pulses_ROOT, "inventory.json"))) | |
| obs = inv["obs"] | |
| jobs = [(tg, obs.get(tg["parent"], obs.get(tg["obsid"], {})).get("stars", [])) for tg in inv["tags"]] | |
| with ProcessPoolExecutor(int(os.environ.get("STIS_PROCS", "2"))) as ex: | |
| rows = [r for rs in ex.map(hst_pulses_scan_file, jobs, chunksize=4) for r in rs] | |
| faps = [min(w["fap"] for w in r["windows"].values()) for r in rows] | |
| print("%d event lists (%d STIS, %d COS) of %d HST targets; %.1f million photons over %.1f days" | |
| % (len(rows), sum(r["inst"] == "STIS" for r in rows), sum(r["inst"] == "COS" for r in rows), | |
| len({r["target"] for r in rows}), sum(r["n"] for r in rows) / 1e6, sum(r["span"] for r in rows) / 86400)) | |
| for thr in (1e-2, 1e-3, 1e-5): | |
| print(" lists with a same-spot cluster at false-alarm probability below %.0e: %d" % (thr, sum(f < thr for f in faps))) | |
| # explanations: a flare raises the whole detector; a site hosting two targets' strongest clusters is a hot pixel | |
| top = [(r, f, min(r["windows"].items(), key=lambda kv: kv[1]["fap"])) for r, f in zip(rows, faps) if f < 1e-2] | |
| sites = {} | |
| for r, f, w in top: | |
| sites.setdefault(tuple(w[1]["site"]), set()).add(r["target"]) | |
| for r, f, w in top: | |
| x = w[1] | |
| rest, m = (x["detector"] or 0) - x["k"], x["mean_detector"] | |
| if rest > 3 * m + 5 * math.sqrt(m): | |
| r["why"] = "flare: %d other events detector-wide against a mean of %.1f" % (rest, m) | |
| elif len(sites[tuple(x["site"])]) > 1: | |
| r["why"] = "hot pixel: the same detector site (%s x %d y %d) hosts the strongest cluster of %s" % ( | |
| x["site"][0], x["site"][1], x["site"][2], ", ".join(sorted(sites[tuple(x["site"])]))) | |
| else: | |
| r["why"] = "" | |
| for r, f, w in sorted(top, key=lambda z: z[1])[:20]: | |
| print(" %s [%d] %s %-14s %s: %d photons over %.0f s; %s s window: %d in one spot (expected %.1e), FAP %.1e, at " | |
| "t %.4f with %d events detector-wide (mean %.2f)%s" | |
| % (r["file"], r["ext"], r["inst"], r["target"][:14], ",".join(s.strip() for s in r["stars"])[:30], r["n"], | |
| r["span"], w[0], w[1]["k"], w[1]["mu"], w[1]["fap"], w[1]["t"] or 0, w[1]["detector"] or 0, | |
| w[1]["mean_detector"], "; " + r["why"] if r["why"] else "")) | |
| left = [z for z in top if not z[0]["why"]] | |
| # smallest FAPs against the lists and windows tried | |
| trials = sum(len(r["windows"]) for r in rows) | |
| print("unexplained lists below 1e-3: %d (about %.1f expected from %d windows); below 1e-4: %d" | |
| % (sum(f < 1e-3 for r, f, w in left), trials * 1e-3, trials, sum(f < 1e-4 for r, f, w in left))) | |
| json.dump(rows, open(os.path.join(hst_pulses_HERE, "stis_pulses.json"), "w"), indent=1, default=str) | |
| def hst_pulses_record(): | |
| """Record per SIMBAD object from stis_pulses.json: lists, photons, hours and the smallest FAP left after flare and hot-pixel explanations.""" | |
| rows = json.load(open(os.path.join(hst_pulses_HERE, "stis_pulses.json"))) | |
| per = {} | |
| for r in rows: | |
| f = min(w["fap"] for w in r["windows"].values()) | |
| for s in r["stars"] or [r["target"]]: | |
| s = " ".join(s.split()) | |
| x = per.setdefault(s, dict(lists=0, n=0, span=0.0, fap=1.0, inst=set(), why=None)) | |
| x["lists"] += 1 | |
| x["n"] += r["n"] | |
| x["span"] += r["span"] | |
| x["inst"].add(r["inst"]) | |
| if f < x["fap"]: | |
| x["fap"], x["why"] = f, r.get("why") or None | |
| out = [dict(target="simbad:" + s, stat=x["n"], p=x["fap"], unit="photons; p is the smallest list false-alarm probability", | |
| vet=x["why"], note="%d %s event lists over %.1f h" % (x["lists"], "+".join(sorted(x["inst"])), x["span"] / 3600)) | |
| for s, x in per.items()] | |
| core.record_search("hst_photon_pulses", "HST photon-event pulses", archive="MAST HST STIS TIME-TAG and COS corrtag", | |
| release="1997-2024", selection="SIMBAD objects with parallax above 100 mas within 0.03 deg", | |
| code=os.path.abspath(__file__), entry="hst_pulses", params=dict(windows=[0.001, 0.032, 0.25], local=hst_pulses_LOCAL, spot=hst_pulses_SPOT, | |
| min_span=hst_pulses_MIN_SPAN), | |
| n_targets=len(out), result="%d unexplained lists below FAP 1e-3 against %.1f expected; %d below 1e-4" | |
| % (sum(1 for r in rows if not r.get("why") and min(w["fap"] for w in r["windows"].values()) < 1e-3), | |
| 1e-3 * sum(len(r["windows"]) for r in rows), | |
| sum(1 for r in rows if not r.get("why") and min(w["fap"] for w in r["windows"].values()) < 1e-4)), | |
| ledger="GALEX time-tagged photons") | |
| core.record_targets("hst_photon_pulses", out) | |
| print("recorded %d objects" % len(out)) | |
| def hst_pulses_main(): | |
| p = argparse.ArgumentParser() | |
| p.add_argument("mode", choices=("inventory", "fetch", "search", "record")) | |
| a = p.parse_args() | |
| {"inventory": hst_pulses_inventory, "fetch": hst_pulses_fetch, "search": hst_pulses_search, "record": hst_pulses_record}[a.mode]() | |
| def hst_pulses_cli(): | |
| quiet_warnings() | |
| hst_pulses_main() | |
| # -------------------------------------------------------------------------------------------------------------- | |
| galex_pulses_DOC = r"""Ultraviolet pulses from stars within 10 pc in GALEX time-tagged photons: largest counts in 5, 20 and 100 ms against the local rate, with control apertures and detector-wide bursts. | |
| python -I seti.py ultraviolet galex_pulses fetch [--band fuv|nuv] | |
| python -I seti.py ultraviolet galex_pulses search [--band fuv|nuv] | |
| python -I seti.py ultraviolet galex_pulses record [--band fuv|nuv]""" | |
| galex_pulses_ROOT = os.environ.get("GALEX_ROOT", core.data("galex")) | |
| galex_pulses_HERE = core.RESULTS | |
| galex_pulses_R_SRC = 8.0 | |
| galex_pulses_R_CTRL = 60.0 | |
| galex_pulses_N_CTRL = 8 | |
| galex_pulses_WINDOWS = (0.005, 0.02, 0.1) | |
| galex_pulses_LOCAL = 10.0 | |
| # band: photon-list suffix, directory of the cut apertures, results file | |
| galex_pulses_BANDS = {"fuv": ("fd", "cut", "galex_pulses.json"), "nuv": ("nd", "cut_nuv", "galex_pulses_nuv.json")} | |
| def galex_pulses_year_of(t): | |
| """GALEX time (seconds since 1980 January 6, the GPS epoch) as a decimal year.""" | |
| return 1980.0 + (t + 5 * 86400.0) / 3.15576e7 | |
| def galex_pulses_targets(): | |
| """Single-pointing eclipses and stars within 0.55 deg of each boresight, cached in ROOT/eclipse_stars.json.""" | |
| cache = os.path.join(galex_pulses_ROOT, "eclipse_stars.json") | |
| if os.path.exists(cache): | |
| return {int(e): v for e, v in json.load(open(cache)).items()} | |
| import pyarrow.parquet as pq | |
| b = pq.read_table(os.path.join(galex_pulses_ROOT, "boresight.parquet")).to_pandas() | |
| m = pq.read_table(os.path.join(galex_pulses_ROOT, "metadata.parquet")).to_pandas().set_index("eclipse") | |
| stars = core.simbad("SELECT main_id, ra, dec, pmra, pmdec, plx_value, otype, sp_type FROM basic WHERE plx_value > " | |
| "100 AND otype NOT IN ('Pl', 'Pl?') AND ra IS NOT NULL") | |
| ra0, de0 = np.radians(b["ra0"].values), np.radians(b["dec0"].values) | |
| by = {} | |
| for s in stars: | |
| a, d = math.radians(float(s["ra"])), math.radians(float(s["dec"])) | |
| cs = np.sin(d) * np.sin(de0) + np.cos(d) * np.cos(de0) * np.cos(ra0 - a) | |
| for e in set(b["eclipse"].values[np.degrees(np.arccos(np.clip(cs, -1, 1))) < 0.55]): | |
| e = int(e) | |
| if e in m.index and m.loc[e]["obstype"] != "AIS": | |
| by.setdefault(e, []).append(s) | |
| json.dump({str(e): v for e, v in by.items()}, open(cache, "w")) | |
| return by | |
| def galex_pulses_column(key, name): | |
| """One photon-list column by byte range from the public bucket; None when absent.""" | |
| import pyarrow.fs | |
| import pyarrow.parquet as pq | |
| fs = pyarrow.fs.S3FileSystem(anonymous=True, region="us-east-1", connect_timeout=60, request_timeout=600) | |
| for attempt in range(3): | |
| try: | |
| return pq.read_table("stpubdata/" + key, filesystem=fs, columns=[name]).column(name).to_numpy() | |
| except FileNotFoundError: | |
| return None | |
| except OSError as ex: | |
| if "NoSuchKey" in str(ex) or "does not exist" in str(ex): | |
| return None | |
| if attempt == 2: | |
| raise | |
| import time | |
| time.sleep(10) | |
| def galex_pulses_extract(e, stars, band="fuv"): | |
| suffix, cut, _ = galex_pulses_BANDS[band] | |
| out = os.path.join(galex_pulses_ROOT, cut, "e%05d.npz" % e) | |
| if os.path.exists(out): | |
| return 1 | |
| key = "galex/gap/galex_photon_list/e%05d/e%05d-%s.parquet" % (e, e, suffix) | |
| try: | |
| t = galex_pulses_column(key, "t") | |
| if t is None: | |
| return 0 | |
| good = (galex_pulses_column(key, "flags") == 0) & ~galex_pulses_column(key, "mask").astype(bool) | |
| de = galex_pulses_column(key, "dec") | |
| good &= np.isfinite(de) | |
| tt_all = t[good] | |
| if not len(tt_all): | |
| return 0 | |
| # keep photons in the stars' and controls' declination bands | |
| dy = galex_pulses_year_of(np.median(tt_all)) - 2000.0 | |
| band_sel = np.zeros(len(de), bool) | |
| for s in stars: | |
| sde = float(s["dec"]) + float(s.get("pmdec") or 0) / 3.6e6 * dy | |
| band_sel |= np.abs(de - sde) < (galex_pulses_R_CTRL + 2 * galex_pulses_R_SRC) / 3600 | |
| idx = np.nonzero(band_sel & good)[0] | |
| de = de[idx] | |
| ra = galex_pulses_column(key, "ra")[idx] | |
| tt = t[idx] | |
| del t, band_sel, good | |
| except Exception as ex: | |
| print("e%05d: %s" % (e, str(ex)[:120]), flush=True) | |
| return 0 | |
| res = dict(eclipse=e, band=band, t0=float(tt_all.min()), t1=float(tt_all.max()), n_all=int(len(tt_all))) | |
| # detector-wide photons per 5 ms tick, for field-wide bursts | |
| ticks, counts = np.unique(np.round(tt_all / 0.005).astype(np.int64), return_counts=True) | |
| del tt_all | |
| res["tick_hist"] = np.bincount(counts) | |
| arrays = {} | |
| for i, s in enumerate(stars): | |
| sra = float(s["ra"]) + float(s.get("pmra") or 0) / 3.6e6 * dy / math.cos(math.radians(float(s["dec"]))) | |
| sde = float(s["dec"]) + float(s.get("pmdec") or 0) / 3.6e6 * dy | |
| cd = math.cos(math.radians(sde)) | |
| for j in range(galex_pulses_N_CTRL + 1): | |
| if j == 0: | |
| cra, cde = sra, sde | |
| else: | |
| ang = 2 * math.pi * (j - 1) / galex_pulses_N_CTRL | |
| cra, cde = sra + galex_pulses_R_CTRL / 3600 * math.cos(ang) / cd, sde + galex_pulses_R_CTRL / 3600 * math.sin(ang) | |
| sel = (np.abs(de - cde) < galex_pulses_R_SRC / 3600) & (np.hypot(((ra - cra + 180) % 360 - 180) * cd, de - cde) * 3600 < galex_pulses_R_SRC) | |
| arrays["s%d_a%d" % (i, j)] = tt[sel] | |
| if j == 0: | |
| arrays["tick_s%d" % i] = counts[np.searchsorted(ticks, np.round(tt[sel] / 0.005).astype(np.int64))] | |
| os.makedirs(os.path.dirname(out), exist_ok=True) | |
| np.savez_compressed(out, meta=json.dumps(dict(res, tick_hist=res["tick_hist"].tolist(), | |
| stars=[s["main_id"] for s in stars])), **arrays) | |
| return 1 | |
| def galex_pulses_fetch(band): | |
| by = galex_pulses_targets() | |
| print("%d single-pointing eclipses cover %d objects within 10 pc" % (len(by), len({s["main_id"] for v in by.values() for s in v})), | |
| flush=True) | |
| done = [0] | |
| def one(kv): | |
| r = galex_pulses_extract(kv[0], kv[1], band) | |
| done[0] += 1 | |
| if done[0] % 10 == 0: | |
| print("%d of %d eclipses read" % (done[0], len(by)), flush=True) | |
| return r | |
| with ThreadPoolExecutor(int(os.environ.get("GALEX_THREADS", "3"))) as ex: | |
| n = sum(ex.map(one, sorted(by.items()))) | |
| print("%d eclipses with %s photon lists extracted" % (n, band)) | |
| def galex_pulses_clusters(t, span): | |
| """Per window length: largest photon-started count, its local expectation and the FAP over the exposure.""" | |
| from scipy.stats import poisson | |
| out = {} | |
| t = np.sort(t) | |
| for w in galex_pulses_WINDOWS: | |
| if len(t) < 2: | |
| out[w] = (len(t), 0.0, 1.0) | |
| continue | |
| k = np.searchsorted(t, t + w - 1e-9) - np.arange(len(t)) | |
| i = int(np.argmax(k)) | |
| n_local = np.searchsorted(t, t[i] + galex_pulses_LOCAL) - np.searchsorted(t, t[i] - galex_pulses_LOCAL) | |
| lam = max(n_local - k[i], 1) / (2 * galex_pulses_LOCAL) * w | |
| p = poisson.sf(k[i] - 1, lam) | |
| fap = -math.expm1(max(span / w, 1) * math.log1p(-min(p, 1 - 1e-16))) | |
| out[w] = (int(k[i]), float(lam), float(fap)) | |
| return out | |
| def galex_pulses_search(band): | |
| _, cut, result = galex_pulses_BANDS[band] | |
| files = sorted(os.listdir(os.path.join(galex_pulses_ROOT, cut))) | |
| rows, ctrl = [], [] | |
| for fn in files: | |
| z = np.load(os.path.join(galex_pulses_ROOT, cut, fn)) | |
| meta = json.loads(str(z["meta"])) | |
| span = meta["t1"] - meta["t0"] | |
| for i, name in enumerate(meta["stars"]): | |
| t = z["s%d_a0" % i] | |
| c = galex_pulses_clusters(t, span) | |
| best = min(c.values(), key=lambda x: x[2]) | |
| rows.append(dict(star=name, eclipse=meta["eclipse"], n=int(len(t)), span=span, clusters={str(k): v for k, v in c.items()}, | |
| fap=best[2], tick_max=int(z["tick_s%d" % i].max()) if len(t) else 0, | |
| tick_hist=meta["tick_hist"])) | |
| for j in range(1, galex_pulses_N_CTRL + 1): | |
| cc = galex_pulses_clusters(z["s%d_a%d" % (i, j)], span) | |
| ctrl.append(min(v[2] for v in cc.values())) | |
| ctrl = np.array(ctrl) | |
| print("%d star-eclipses (%d stars), median %d source photons over %.0f s; %d control apertures" | |
| % (len(rows), len({r["star"] for r in rows}), np.median([r["n"] for r in rows]), np.median([r["span"] for r in rows]), | |
| len(ctrl))) | |
| for thr in (1e-2, 1e-3, 1e-4): | |
| print(" false-alarm probability below %.0e: targets %d (%.1f expected from the controls' rate)" | |
| % (thr, sum(r["fap"] < thr for r in rows), len(rows) * np.mean(ctrl < thr))) | |
| for r in sorted(rows, key=lambda r: r["fap"])[:15]: | |
| c = r["clusters"] | |
| print(" %-24s e%05d: %d photons over %.0f s; 5 ms max %d (exp %.1e), 20 ms %d (exp %.1e), 100 ms %d (exp %.1e); " | |
| "FAP %.1e; busiest detector tick holding a source photon %d photons" | |
| % (r["star"], r["eclipse"], r["n"], r["span"], c["0.005"][0], c["0.005"][1], c["0.02"][0], c["0.02"][1], | |
| c["0.1"][0], c["0.1"][1], r["fap"], r["tick_max"])) | |
| json.dump(dict(rows=rows, ctrl=ctrl.tolist()), open(os.path.join(galex_pulses_HERE, result), "w"), indent=1) | |
| def galex_pulses_record(band): | |
| """Record one row per star with its lowest FAP over its eclipses.""" | |
| _, _, result = galex_pulses_BANDS[band] | |
| r = json.load(open(os.path.join(galex_pulses_HERE, result))) | |
| rows, ctrl = r["rows"], np.array(r["ctrl"]) | |
| best = {} | |
| for x in rows: | |
| if x["star"] not in best or x["fap"] < best[x["star"]]["fap"]: | |
| best[x["star"]] = dict(x, n_ecl=sum(1 for y in rows if y["star"] == x["star"])) | |
| run = "galex_%s_pulses" % band | |
| core.record_search( | |
| run, "GALEX time-tagged photons", archive="STScI S3 galex/gap/galex_photon_list (single-pointing eclipses)", | |
| release="GALEX %s photon lists" % band.upper(), | |
| selection="SIMBAD objects with parallax above 100 mas inside 0.55 deg of a single-pointing eclipse boresight", | |
| code="seti.py ultraviolet galex_pulses", params=dict(r_src=galex_pulses_R_SRC, n_ctrl=galex_pulses_N_CTRL, windows=list(galex_pulses_WINDOWS), local_s=galex_pulses_LOCAL), | |
| n_targets=len(best), | |
| null_summary=dict(below_1e2=sum(1 for x in rows if x["fap"] < 1e-2), | |
| below_1e3=sum(1 for x in rows if x["fap"] < 1e-3), | |
| expected_1e2=float(len(rows) * np.mean(ctrl < 1e-2)), controls=int(len(ctrl))), | |
| sensitivity="several photons within 5, 20 or 100 ms in an 8 arcsec aperture against the local rate", | |
| result="%d star-eclipses of %d stars: %d clusters at false-alarm probability below 0.01 against %.1f expected " | |
| "from %d control apertures" % (len(rows), len(best), sum(1 for x in rows if x["fap"] < 1e-2), | |
| len(rows) * np.mean(ctrl < 1e-2), len(ctrl)), | |
| ledger="GALEX time-tagged photons") | |
| core.clear_targets(run) | |
| core.record_targets(run, [ | |
| dict(target="simbad:%s" % s, p=float(x["fap"]), | |
| note="best of %d eclipses: e%05d, %d photons over %.0f s; 5 ms max %d" | |
| % (x["n_ecl"], x["eclipse"], x["n"], x["span"], x["clusters"]["0.005"][0])) for s, x in best.items()]) | |
| print("recorded") | |
| def galex_pulses_main(): | |
| p = argparse.ArgumentParser() | |
| p.add_argument("mode", choices=("fetch", "search", "record")) | |
| p.add_argument("--band", choices=tuple(galex_pulses_BANDS), default="fuv") | |
| a = p.parse_args() | |
| {"fetch": galex_pulses_fetch, "search": galex_pulses_search, "record": galex_pulses_record}[a.mode](a.band) | |
| def galex_pulses_cli(): | |
| quiet_warnings() | |
| galex_pulses_main() | |
| # -------------------------------------------------------------------------------------------------------------- | |
| galex_census_DOC = r"""Bursts across the GALEX NUV detector as the false-positive population: 100 ms clusters per 16 arcsec cell, judged off streaks and local rises, with a detector-position test. | |
| python -I seti.py ultraviolet galex_census census [--eclipses FILE] [--part k/n] | |
| python -I seti.py ultraviolet galex_census judge | |
| python -I seti.py ultraviolet galex_census detector ECLIPSE RA DEC T [--fixed] | |
| python -I seti.py ultraviolet galex_census burstpos ECLIPSE | |
| python -I seti.py ultraviolet galex_census record""" | |
| galex_census_ROOT = os.environ.get("GALEX_CENSUS_ROOT", core.data("galex_census")) | |
| galex_census_KEY = "stpubdata/galex/gap/galex_photon_list/e%05d/e%05d-nd.parquet" | |
| galex_census_W, galex_census_R, galex_census_FAP = 0.1, 16.0, 1e-3 | |
| def galex_census_photons(ecl, columns=("t", "ra", "dec")): | |
| """Good photons of one NUV eclipse from the public bucket (flags 0, unmasked, finite position).""" | |
| import pyarrow.fs | |
| import pyarrow.parquet as pq | |
| fs = pyarrow.fs.S3FileSystem(anonymous=True, region="us-east-1", connect_timeout=60, request_timeout=900) | |
| cols = list(dict.fromkeys(list(columns) + ["flags", "mask", "dec"])) | |
| tab = pq.read_table(galex_census_KEY % (ecl, ecl), filesystem=fs, columns=cols) | |
| good = ((tab.column("flags").to_numpy() == 0) & ~tab.column("mask").to_numpy().astype(bool) | |
| & np.isfinite(tab.column("dec").to_numpy())) | |
| return {c: tab.column(c).to_numpy()[good] for c in columns} | |
| def galex_census_eclipse(ecl): | |
| """Census of one eclipse into ROOT/e<eclipse>.json: every cluster below the FAP threshold.""" | |
| from scipy.stats import poisson | |
| W, R = galex_census_W, galex_census_R | |
| out_p = os.path.join(galex_census_ROOT, "e%05d.json" % ecl) | |
| if os.path.exists(out_p): | |
| return | |
| try: | |
| ph = galex_census_photons(ecl) | |
| except Exception as e: | |
| json.dump(dict(eclipse=ecl, error=str(e)[:200]), open(out_p, "w")) | |
| return | |
| t, ra, de = ph["t"], ph["ra"], ph["dec"] | |
| if len(t) < 1000: | |
| json.dump(dict(eclipse=ecl, error="too few photons"), open(out_p, "w")) | |
| return | |
| t0 = float(t.min()) | |
| tr = t - t0 | |
| span = float(tr.max()) | |
| ra0, de0 = float(np.median(ra)), float(np.median(de)) | |
| x = ((ra - ra0 + 180) % 360 - 180) * math.cos(math.radians(de0)) * 3600.0 | |
| y = (de - de0) * 3600.0 | |
| nwin = span / W | |
| hits, cells = [], {} | |
| for ox, oy in ((0, 0), (8, 0), (0, 8), (8, 8)): | |
| ix = np.floor((x + ox) / R).astype(np.int64) | |
| iy = np.floor((y + oy) / R).astype(np.int64) | |
| keyc = (ix - ix.min()) * 100000 + (iy - iy.min()) | |
| order = np.lexsort((tr, keyc)) | |
| kc, ts = keyc[order], tr[order] | |
| v = kc.astype(np.float64) * 4096.0 + ts | |
| cnt = np.searchsorted(v, v + W, side="left") - np.arange(len(v)) | |
| uniq, inv, ncell = np.unique(kc, return_inverse=True, return_counts=True) | |
| rate = ncell / span | |
| mu = rate * W | |
| if (ox, oy) == (0, 0): | |
| h, _ = np.histogram(rate, bins=[0, 0.1, 0.3, 1, 3, 10, 1e9]) | |
| cells = dict(zip(["<0.1", "0.1-0.3", "0.3-1", "1-3", "3-10", ">10"], h.tolist())) | |
| kthr = np.full(len(uniq), 10 ** 6, np.int64) | |
| for k in range(2, 600): | |
| ok = (kthr == 10 ** 6) & (poisson.sf(k - 1, mu) * nwin < galex_census_FAP) | |
| kthr[ok] = k | |
| if not (kthr == 10 ** 6).any(): | |
| break | |
| for h_ in np.nonzero(cnt >= kthr[inv])[0]: | |
| j = order[h_] | |
| fap = float(poisson.sf(cnt[h_] - 1, mu[inv[h_]]) * nwin) | |
| # the cell's own rate within 10 s either side, the window left out | |
| lo = np.searchsorted(v, kc[h_] * 4096.0 + max(ts[h_] - 10.0, 0.0)) | |
| hi = np.searchsorted(v, kc[h_] * 4096.0 + ts[h_] + 10.0) | |
| rate_loc = max(int(hi - lo - cnt[h_]), 1) / 20.0 | |
| fap_loc = float(poisson.sf(cnt[h_] - 1, max(rate_loc, rate[inv[h_]] * 0.5) * W) * nwin) | |
| hits.append(dict(t=float(ts[h_]), n=int(cnt[h_]), x=float(x[j]), y=float(y[j]), ra=float(ra[j]), | |
| dec=float(de[j]), rate=float(rate[inv[h_]]), rate_local=float(rate_loc), fap=fap, | |
| fap_local=fap_loc)) | |
| cl = [] | |
| for h_ in sorted(hits, key=lambda d: d["fap"]): | |
| if not any(abs(c["t"] - h_["t"]) < 0.3 and math.hypot(c["x"] - h_["x"], c["y"] - h_["y"]) < 24 for c in cl): | |
| cl.append(h_) | |
| for c in cl: | |
| c["neighbours"] = sum(1 for o in cl if o is not c and abs(o["t"] - c["t"]) < 2.0 | |
| and math.hypot(o["x"] - c["x"], o["y"] - c["y"]) < 60.0) | |
| json.dump(dict(eclipse=ecl, t0=t0, span=span, n_photons=int(len(t)), cells=cells, clusters=cl), open(out_p, "w")) | |
| def galex_census_eclipses(path=None): | |
| if path: | |
| return [int(e) for e in json.load(open(path))] | |
| rows = json.load(open(core.results("galex_pulses_nuv.json")))["rows"] | |
| return sorted({int(r["eclipse"]) for r in rows}) | |
| def galex_census_run(path=None, part=None): | |
| os.makedirs(galex_census_ROOT, exist_ok=True) | |
| ecls = galex_census_eclipses(path) | |
| k, n = (int(x) for x in part.split("/")) if part else (0, 1) | |
| for e in ecls[k::n]: | |
| t_ = time.time() | |
| galex_census_eclipse(e) | |
| print("e%05d done in %.0f s" % (e, time.time() - t_), flush=True) | |
| def galex_census_judge(): | |
| """Bursts of 7 or more photons in 100 ms on cells of 0.5-5 counts/s, off streaks and outside local rises.""" | |
| n_ecl = n_err = streaks = flares = 0 | |
| src_seconds = 0.0 | |
| bursts = [] | |
| for f in sorted(glob.glob(os.path.join(galex_census_ROOT, "e*.json"))): | |
| d = json.load(open(f)) | |
| if "error" in d: | |
| n_err += 1 | |
| continue | |
| n_ecl += 1 | |
| src_seconds += (d["cells"].get("1-3", 0) + d["cells"].get("3-10", 0)) * d["span"] | |
| for c in d["clusters"]: | |
| if c["n"] < 7 or not 0.5 <= c["rate"] <= 5.0: | |
| continue | |
| if c.get("neighbours", 0) > 0: | |
| streaks += 1 | |
| elif c.get("fap_local", 1.0) >= 1e-4: | |
| flares += 1 | |
| else: | |
| bursts.append(dict(c, eclipse=d["eclipse"])) | |
| # one burst per cell and moment across the four grids | |
| uniq = [] | |
| for b in sorted(bursts, key=lambda b: b["fap_local"]): | |
| if not any(b["eclipse"] == u["eclipse"] and abs(b["t"] - u["t"]) < 0.2 and abs(b["ra"] - u["ra"]) < 0.01 | |
| and abs(b["dec"] - u["dec"]) < 0.01 for u in uniq): | |
| uniq.append(b) | |
| print("%d eclipses judged (%d failed); %.3g source-seconds at 1-10 counts/s" % (n_ecl, n_err, src_seconds)) | |
| print("clusters of 7+ photons in 100 ms on 0.5-5 counts/s cells: %d on streaks, %d within a local rise, %d isolated" | |
| % (streaks, flares, len(uniq))) | |
| print("isolated bursts per source-second: %.2g" % (len(uniq) / max(src_seconds, 1))) | |
| for b in uniq[:30]: | |
| print(" e%05d t %.2f n %d at RA %.5f Dec %+.5f, cell %.2f c/s, local %.2f c/s, fap %.1e, local fap %.1e" | |
| % (b["eclipse"], b["t"], b["n"], b["ra"], b["dec"], b["rate"], b["rate_local"], b["fap"], b["fap_local"])) | |
| json.dump(dict(n_ecl=n_ecl, src_seconds=src_seconds, streaks=streaks, flares=flares, bursts=uniq), | |
| open(os.path.join(galex_census_ROOT, "judged.json"), "w")) | |
| def galex_census_boresight(tr, ra, de): | |
| """Boresight per photon from each second's median position (over 100 photons), 5 s running median, linear within the second; with cos(dec).""" | |
| from scipy.ndimage import median_filter | |
| sec = np.floor(tr).astype(int) | |
| ns = sec.max() + 1 | |
| order = np.argsort(sec) | |
| st = np.searchsorted(sec[order], np.arange(ns)) | |
| en = np.searchsorted(sec[order], np.arange(ns), side="right") | |
| bra, bde = np.full(ns, np.nan), np.full(ns, np.nan) | |
| for s in range(ns): | |
| idx = order[st[s]:en[s]] | |
| if len(idx) > 100: | |
| bra[s], bde[s] = np.median(ra[idx]), np.median(de[idx]) | |
| ok = np.isfinite(bra) | |
| sb_ra = np.interp(np.arange(ns), np.nonzero(ok)[0], median_filter(bra[ok], 5)) | |
| sb_de = np.interp(np.arange(ns), np.nonzero(ok)[0], median_filter(bde[ok], 5)) | |
| fr = tr - sec | |
| bore_ra = sb_ra[sec] + (np.r_[sb_ra[1:], sb_ra[-1]][sec] - sb_ra[sec]) * fr | |
| bore_de = sb_de[sec] + (np.r_[sb_de[1:], sb_de[-1]][sec] - sb_de[sec]) * fr | |
| return bore_ra, bore_de, math.cos(math.radians(np.nanmedian(bde))), (sb_ra, sb_de) | |
| def galex_census_detector(ecl, star_ra, star_de, t_burst, fixed=False): | |
| """A star's burst in detector coordinates, and that detector spot over the whole visit against the star.""" | |
| ph = galex_census_photons(ecl) | |
| t, ra, de = ph["t"], ph["ra"], ph["dec"] | |
| tr = t - t.min() | |
| bore_ra, bore_de, cd, (sb_ra, sb_de) = galex_census_boresight(tr, ra, de) | |
| dx = ((ra - bore_ra + 180) % 360 - 180) * cd * 3600.0 | |
| dy = (de - bore_de) * 3600.0 | |
| print("visit %.0f s, %d photons; boresight dither range RA %.1f arcsec, Dec %.1f arcsec" | |
| % (tr.max(), len(t), (sb_ra.max() - sb_ra.min()) * cd * 3600, (sb_de.max() - sb_de.min()) * 3600)) | |
| sp = np.hypot(np.diff(sb_ra) * cd * 3600, np.diff(sb_de) * 3600) | |
| print("boresight speed: median %.2f arcsec/s, 95th percentile %.2f" % (np.median(sp), np.percentile(sp, 95))) | |
| # star centroid: densest 2 arcsec cell within 60 arcsec, then a 6 arcsec mean | |
| x60 = ((ra - star_ra + 180) % 360 - 180) * cd * 3600 | |
| y60 = (de - star_de) * 3600 | |
| w60 = np.hypot(x60, y60) < 60 | |
| H, xe, ye = np.histogram2d(x60[w60], y60[w60], bins=60, range=[[-60, 60], [-60, 60]]) | |
| ix, iy = np.unravel_index(np.argmax(H), H.shape) | |
| cx, cy = (xe[ix] + xe[ix + 1]) / 2, (ye[iy] + ye[iy + 1]) / 2 | |
| m = w60 & (np.hypot(x60 - cx, y60 - cy) < 6) | |
| if not fixed: | |
| star_ra += np.mean(x60[m]) / 3600 / cd | |
| star_de += np.mean(y60[m]) / 3600 | |
| print("star centroid RA %.5f Dec %.5f from %d photons within 6 arcsec" % (star_ra, star_de, m.sum())) | |
| r8 = np.hypot(((ra - star_ra + 180) % 360 - 180) * cd * 3600, (de - star_de) * 3600) < 8 | |
| cand = np.nonzero(r8 & (tr > t_burst - 5) & (tr < t_burst + 5))[0] | |
| tt = tr[cand] | |
| best = None | |
| for x in tt: | |
| k = int(np.sum((tt >= x) & (tt < x + 0.1))) | |
| if best is None or k > best[0]: | |
| best = (k, x) | |
| t_best = best[1] | |
| sel = cand[(tt >= t_best) & (tt < t_best + 0.1)] | |
| px, py = dx[sel].mean(), dy[sel].mean() | |
| print("burst: %d photons from %.3f s; sky centre RA %.5f Dec %.5f; detector centre %.1f %.1f arcsec, spread %.1f arcsec" | |
| % (len(sel), t_best, ra[sel].mean(), de[sel].mean(), px, py, np.hypot(dx[sel] - px, dy[sel] - py).mean())) | |
| on_star = np.hypot(((ra - star_ra + 180) % 360 - 180) * cd * 3600, (de - star_de) * 3600) < 6 | |
| on_spot = np.hypot(dx - px, dy - py) < 6 | |
| star_det_x = ((star_ra - bore_ra + 180) % 360 - 180) * cd * 3600 | |
| star_det_y = (star_de - bore_de) * 3600 | |
| sep_t = np.hypot(star_det_x - px, star_det_y - py) | |
| print("star (sky, 6 arcsec): %d photons; detector spot (6 arcsec): %d photons; spot while the star is 20+ arcsec " | |
| "away: %d photons" % (on_star.sum(), on_spot.sum(), (on_spot & (sep_t > 20)).sum())) | |
| print("star track on the detector: x %.0f..%.0f, y %.0f..%.0f arcsec; burst spot %.1f %.1f; nearest approach of the " | |
| "track to the spot %.1f arcsec" % (star_det_x.min(), star_det_x.max(), star_det_y.min(), star_det_y.max(), px, py, | |
| sep_t.min())) | |
| def tops(mask, label): | |
| ts = np.sort(tr[mask]) | |
| idx = np.argsort(tr[mask]) | |
| cnt = np.searchsorted(ts, ts + 0.1) - np.arange(len(ts)) | |
| seen = [] | |
| for i in np.argsort(-cnt): | |
| if cnt[i] < 4: | |
| break | |
| if any(abs(ts[i] - s) < 0.2 for s in seen): | |
| continue | |
| seen.append(ts[i]) | |
| j = np.nonzero(mask)[0][idx[i]] | |
| print(" %s: %d photons in 100 ms at %.3f s; star-spot separation on the detector %.1f arcsec" | |
| % (label, cnt[i], ts[i], sep_t[j])) | |
| tops(on_spot, "detector spot") | |
| tops(on_star, "star") | |
| away = on_spot & (sep_t > 20) | |
| tops(away, "spot, star away") | |
| frac = np.mean(sep_t > 20) | |
| print("fraction of the visit the star is 20+ arcsec from the spot: %.2f; spot rate then %.3f c/s; background in " | |
| "6 arcsec %.3f c/s" % (frac, away.sum() / (frac * tr.max()), np.sum(np.hypot(dx - px - 120, dy - py) < 6) / tr.max())) | |
| def galex_census_burstpos(ecl): | |
| """Detector position and radius of every judged burst of one eclipse.""" | |
| d = json.load(open(os.path.join(galex_census_ROOT, "judged.json"))) | |
| ph = galex_census_photons(ecl, ("t", "ra", "dec", "detrad")) | |
| t, ra, de, dr = ph["t"], ph["ra"], ph["dec"], ph["detrad"] | |
| tr = t - t.min() | |
| bore_ra, bore_de, cd, _ = galex_census_boresight(tr, ra, de) | |
| for b in sorted((b for b in d["bursts"] if b["eclipse"] == ecl), key=lambda b: b["t"]): | |
| w = ((tr >= b["t"] - 0.02) & (tr < b["t"] + 0.12) | |
| & (np.hypot(((ra - b["ra"] + 180) % 360 - 180) * cd * 3600, (de - b["dec"]) * 3600) < 12)) | |
| dx = ((ra[w] - bore_ra[w] + 180) % 360 - 180) * cd * 3600 | |
| dy = (de[w] - bore_de[w]) * 3600 | |
| print("t %7.2f n %2d sky %.5f %+.5f -> detector %7.1f %7.1f arcsec (spread %.1f), detrad %.4f deg" | |
| % (b["t"], w.sum(), b["ra"], b["dec"], dx.mean(), dy.mean(), | |
| np.hypot(dx - dx.mean(), dy - dy.mean()).mean(), np.median(dr[w]))) | |
| def galex_census_record(): | |
| """Record the judged census: one row per isolated burst.""" | |
| d = json.load(open(os.path.join(galex_census_ROOT, "judged.json"))) | |
| b = d["bursts"] | |
| core.record_search( | |
| "galex_burst_census", "GALEX NUV burst census", archive="STScI S3 galex/gap/galex_photon_list (single-pointing eclipses)", | |
| release="%d NUV eclipses" % d["n_ecl"], selection="every 16 arcsec cell of the eclipses of the NUV pulse search", | |
| code=os.path.abspath(__file__), entry="galex_census", n_targets=len(b), | |
| params=dict(window_s=galex_census_W, cell_arcsec=galex_census_R, fap=galex_census_FAP, min_photons=7, | |
| cell_rate=[0.5, 5.0], local_fap=1e-4), | |
| null_summary=dict(streaks=d["streaks"], local_rises=d["flares"], source_seconds=d["src_seconds"]), | |
| sensitivity="7 or more photons in 100 ms on cells of 0.5 to 5 counts/s", | |
| result="%d eclipses: %d isolated bursts of 7 or more photons in 100 ms (%.2g per source-second), %d clusters on " | |
| "satellite streaks and %d within local rises" % (d["n_ecl"], len(b), len(b) / max(d["src_seconds"], 1), | |
| d["streaks"], d["flares"]), | |
| ledger="Ultraviolet pulses") | |
| core.clear_targets("galex_burst_census") | |
| core.record_targets("galex_burst_census", [dict( | |
| target="galex:e%05d t %.2f" % (x["eclipse"], x["t"]), stat=x["n"], p=x["fap_local"], unit="photons in 100 ms", | |
| note="RA %.5f Dec %+.5f, cell %.2f counts/s, local %.2f counts/s" % (x["ra"], x["dec"], x["rate"], x["rate_local"])) | |
| for x in b]) | |
| print("recorded %d bursts" % len(b)) | |
| def galex_census_cli(): | |
| quiet_warnings() | |
| p = argparse.ArgumentParser() | |
| p.add_argument("mode", choices=("census", "judge", "detector", "burstpos", "record")) | |
| p.add_argument("args", nargs="*") | |
| p.add_argument("--eclipses", default=None) | |
| p.add_argument("--part", default=None) | |
| p.add_argument("--fixed", action="store_true") | |
| a = p.parse_args() | |
| if a.mode == "census": | |
| galex_census_run(a.eclipses, a.part) | |
| elif a.mode == "judge": | |
| galex_census_judge() | |
| elif a.mode == "detector": | |
| e, ra_, de_, tb = a.args | |
| galex_census_detector(int(e), float(ra_), float(de_), float(tb), a.fixed) | |
| elif a.mode == "burstpos": | |
| galex_census_burstpos(int(a.args[0])) | |
| else: | |
| galex_census_record() | |
| # -------------------------------------------------------------------------------------------------------------- | |
| # ultraviolet from Earth's transit zone | |
| etzuv_DOC = r"""Ultraviolet from Earth's transit zone: Gaia Catalogue of Nearby Stars objects within 0.264 deg of the ecliptic, which see Earth cross the Sun, carried to 2007.0, against GALEX GUVcat AIS (II/335) within 5 arcsec by CDS XMatch, a detection in either band; the share with an ultraviolet source in the zone against the share 1-5 deg from the ecliptic and against the zone moved 2 arcmin in eight directions. Matches read in SIMBAD. | |
| python -I seti.py ultraviolet etzuv [record] [--radius-arcsec 5] [--epoch 2007] [--catalogue II/335/galex_ais] | |
| Another CDS catalogue of ultraviolet sources, with the epoch of its positions, is recorded under its own run.""" | |
| def etzuv_main(record=False, radius_arcsec=5.0, epoch=2007.0, catalogue="II/335/galex_ais"): | |
| from setitools.neighbourhood import etz_share, etz_vets | |
| r = etz_share(catalogue, radius_arcsec, epoch) | |
| vets = etz_vets(r["got"]) | |
| mean = float(np.mean(r["null"])) | |
| print("%d zone stars and %d stars 1-5 deg away; %s within %g arcsec: zone %d, away %d (%.2f expected in the zone, p %.3g); zone moved 2 arcmin %s (mean %.1f); " | |
| "zone matches by SIMBAD type %s" % (r["zone"], r["near"], catalogue, radius_arcsec, len(r["got"]), r["far"], r["exp"], r["p"], r["null"], mean, | |
| sorted({(v[0] or (None, "none"))[1] for v in vets.values()}))) | |
| if record: | |
| galex = catalogue == "II/335/galex_ais" | |
| core.record_run("etz_galex_stars" if galex else "etz_%s_stars" % catalogue.replace("/", "_"), "Ultraviolet from Earth's transit zone", | |
| "Gaia Catalogue of Nearby Stars (external.gaiaedr3_gcns_main_1); %s by CDS XMatch; SIMBAD" % ("GALEX GUVcat AIS (II/335)" if galex else catalogue), | |
| "stars within 100 pc and 0.264 deg of the ecliptic: %d" % r["zone"], os.path.abspath(__file__), | |
| "etzuv", r["zone"], "stars", dict(zone_deg=0.264, control_deg=[1, 5], radius_arcsec=radius_arcsec, epoch=epoch, catalogue=catalogue), | |
| "the share 1-5 deg away (%d of %d); the zone moved 2 arcmin: %s" % (r["far"], r["near"], r["null"]), | |
| "ultraviolet from a star that sees Earth transit, beyond the rate of stars beside the zone", | |
| "%d against %.2f from the share beside the zone (p %.3g) and a mean of %.1f moved" % (len(r["got"]), r["exp"], r["p"], mean), | |
| [dict(target="gaia_dr3:%s" % n[1:], vet=vets[n][1] or ("chance-rate" if r["p"] > 0.01 else None), note="SIMBAD %s; NUV %s, FUV %s at %.1f arcsec" % ( | |
| vets[n][0], m.get("NUVmag"), m.get("FUVmag"), float(m["angDist"]))) for n, m in sorted(r["got"].items())]) | |
| print("recorded") | |
| bluest_DOC = r"""Nearby light bluer than any star: Gaia Catalogue of Nearby Stars objects (probability above 0.9) whose BP-RP lies below -0.65, bluer than the Rayleigh-Jeans tail of any photosphere gives in Gaia's bands, a spectrum rising toward the blue faster than heat allows, each read for its BP/RP flux excess against the single-star relation, its white-dwarf probability and its SIMBAD type; the hottest white dwarfs and subdwarfs reach -0.6, and a blend or a faint source's BP noise makes a bluer colour without a bluer star. | |
| python -I seti.py ultraviolet bluest [record] [--colour-max -0.65] [--excess-max 0.2]""" | |
| def bluest_main(record=False, colour_max=-0.65, excess_max=0.2): | |
| from setitools import catalogues | |
| from setitools.core import num | |
| rows = catalogues.gcns("source_id, ra, dec, parallax, phot_g_mean_mag, phot_bp_mean_mag, phot_rp_mean_mag, phot_bp_rp_excess_factor, ruwe, wd_prob, ipd_frac_multi_peak", | |
| "gcns_prob > 0.9 AND phot_bp_mean_mag - phot_rp_mean_mag < %g" % colour_max, ranges=12) | |
| sim = {} | |
| ids = [str(r["source_id"]) for r in rows] | |
| for k in range(0, len(ids), 200): | |
| for s in core.simbad("SELECT i.id, b.main_id, b.otype, b.sp_type FROM ident AS i JOIN basic AS b ON i.oidref = b.oid WHERE i.id IN (%s)" % ",".join( | |
| "'Gaia DR3 %s'" % x for x in ids[k:k + 200])): | |
| sim[str(s["id"]).split()[-1]] = (" ".join(str(s["main_id"]).split()), str(s["otype"]).strip(), (s["sp_type"] or "").strip()) | |
| out = [] | |
| for r in rows: | |
| c = num(r["phot_bp_mean_mag"]) - num(r["phot_rp_mean_mag"]) | |
| # the single-star BP/RP excess (Riello et al. 2021) at the colour | |
| ex = (num(r["phot_bp_rp_excess_factor"]) or 0) - (1.154360 + 0.033772 * c + 0.032277 * c * c) | |
| o = dict(id=str(r["source_id"]), c=c, g=num(r["phot_g_mean_mag"]), plx=num(r["parallax"]), ex=ex, ruwe=num(r["ruwe"]), wd=num(r["wd_prob"]), | |
| multi=num(r["ipd_frac_multi_peak"]), simbad=sim.get(str(r["source_id"]))) | |
| typ = (o["simbad"] or (None, "", ""))[1] | |
| o["vet"] = ("artefact" if abs(ex) > excess_max or (o["multi"] or 0) > 2 or (o["ruwe"] or 0) > 1.4 else | |
| "white-dwarf" if (o["wd"] or 0) > 0.5 or typ in ("WD*", "WD?", "HS*", "HS?", "sdB", "sdO") or "D" in (o["simbad"] or (None, "", ""))[2][:2] else | |
| "natural-type" if typ not in ("", "*", "PM*", "HV*") else None) | |
| out.append(o) | |
| left = [o for o in out if o["vet"] is None] | |
| print("%d stars within 100 pc with BP-RP below %g: %d with a BP/RP flux excess beyond %g, several peaks or RUWE above 1.4, %d white dwarfs or hot subdwarfs, %d of other " | |
| "known types; unexplained %d" % (len(out), colour_max, sum(o["vet"] == "artefact" for o in out), excess_max, sum(o["vet"] == "white-dwarf" for o in out), | |
| sum(o["vet"] == "natural-type" for o in out), len(left))) | |
| for o in sorted(out, key=lambda o: o["c"]): | |
| print(" Gaia DR3 %s: BP-RP %.3f, G %.2f, parallax %.1f mas, excess %+.3f, RUWE %s, WD probability %s; SIMBAD %s; %s" % ( | |
| o["id"], o["c"], o["g"], o["plx"], o["ex"], o["ruwe"], o["wd"], o["simbad"], o["vet"] or "unexplained")) | |
| json.dump(out, open(core.results("bluest_nearby.json"), "w"), indent=1) | |
| if record: | |
| core.record_run( | |
| "gcns_bluer_than_stars", "Nearby light bluer than any star", "Gaia Catalogue of Nearby Stars (external.gaiaedr3_gcns_main_1); SIMBAD", | |
| "stars within 100 pc with BP-RP below %g: %d" % (colour_max, len(out)), os.path.abspath(__file__), "bluest", len(out), "sources", | |
| dict(colour_max=colour_max, excess_max=excess_max), "the BP/RP flux excess, the image-peak and RUWE flags, the white-dwarf probability and SIMBAD's types", | |
| "a source bluer than the Rayleigh-Jeans limit of any photosphere in Gaia's bands", | |
| "%d: %d flux-excess or blended artefacts, %d white dwarfs or hot subdwarfs, %d other known types; %d unexplained" % ( | |
| len(out), sum(o["vet"] == "artefact" for o in out), sum(o["vet"] == "white-dwarf" for o in out), sum(o["vet"] == "natural-type" for o in out), len(left)), | |
| [dict(target="gaia_dr3:%s" % o["id"], stat=o["c"], unit="BP-RP", vet=o["vet"], | |
| note="G %.2f, parallax %.1f mas, BP/RP excess %+.3f, RUWE %s, WD probability %s; SIMBAD %s" % (o["g"], o["plx"], o["ex"], o["ruwe"], o["wd"], o["simbad"])) | |
| for o in out]) | |
| print("recorded") | |
| APPROACHES = { | |
| "bluest": (lambda: core.run_cli(bluest_main), bluest_DOC), | |
| "etzuv": (lambda: core.run_cli(etzuv_main), etzuv_DOC), | |
| "starcat": (starcat_cli, starcat_DOC), | |
| "cos": (cos_cli, cos_DOC), | |
| "iue": (iue_cli, iue_DOC), | |
| "fuse": (fuse_cli, fuse_DOC), | |
| "stis": (stis_cli, stis_DOC), | |
| "limits": (limits_cli, limits_DOC), | |
| "hst_pulses": (hst_pulses_cli, hst_pulses_DOC), | |
| "galex_pulses": (galex_pulses_cli, galex_pulses_DOC), | |
| "galex_census": (galex_census_cli, galex_census_DOC), | |
| } | |