"""Part 2a: the published search record, and the one public alert stream that reports fadings. A. Gaia Photometric Science Alerts index (alerts.csv): how many alerts are fadings, how deep, and of what class; which of the deep ones are not attributed to a known class of variable. B. A table of the published searches for dimming / vanishing stars with, for each, the selection function that matters here: would a star that vanishes completely, once, achromatically, have been recorded and kept? Numbers are transcribed from the cited papers (checked against the paper texts; see the report for the passages). C. Natural mimics and what distinguishes them. D. Gaia DR3 cross-match of the OGLE-II objects that drift monotonically or end in a faint state (are they high-proper-motion stars? is the star there today?). Needs the network once; the result is cached in RAW. E. SIMBAD identification of the OGLE-II near misses (objects passing C1-C5, the deepest objects passing C1-C4, the gap steps and the survivors of the looser variant). Cached in RAW. Outputs: data/gaia_alert_faders.csv, data/gaia_alerts_summary.json, data/published_searches.csv, data/natural_mimics.csv, data/ogle2_survivor_gaia.csv, data/ogle2_nearmiss_simbad.csv """ import io import json import os import re import numpy as np import pandas as pd from common import DATA, RAW, GAIA_TAP, tap_sync, http_get # words in the alert comment / class that attribute a fading to a known kind of variable source KNOWN = [("R CrB / DY Per / carbon star", r"r ?crb|dy ?per|carbon"), ("cataclysmic variable / nova", r"\bcv\b|vy scl|nova|polar|z cam|dwarf nov"), ("young stellar object", r"yso|t tau|uxor|ori\b"), ("AGN / supernova", r"bl ?lac|qso|agn|sn\b|slsn|supernova"), ("long-period or erratic variable", r"long[- ]period|lpv|erratic|mira|previous event"), ("slow decline (months to years)", r"long[- ]term|over \d|across|slow|years|months")] def gaia_alerts(): a = pd.read_csv(os.path.join(RAW, "gaia_alerts.csv"), skipinitialspace=True) a.columns = [c.strip().lstrip("#") for c in a.columns] a["dmag"] = a.AlertMag - a.HistoricMag txt = (a.Comment.fillna("") + " " + a.Class.fillna("")).str.lower() def attribute(s): for name, pat in KNOWN: if re.search(pat, s): return name return "unattributed" a["attribution"] = [attribute(s) for s in txt] fad = a[a.dmag > 2.0].sort_values("dmag", ascending=False) fad[["Name", "Date", "RaDeg", "DecDeg", "AlertMag", "HistoricMag", "dmag", "Class", "attribution", "Comment"]].to_csv( os.path.join(DATA, "gaia_alert_faders.csv"), index=False, float_format="%.5g") bpp = a[a.Name == "Gaia17bpp"] deep = fad[fad.dmag > 3.0] summary = { "n_alerts": int(len(a)), "date_range": [str(a.Date.min()), str(a.Date.max())], "n_with_historic_magnitude": int(a.HistoricMag.notna().sum()), "n_fainter_than_historic_by_gt_1mag": int((a.dmag > 1).sum()), "n_fainter_by_gt_2mag": int((a.dmag > 2).sum()), "n_fainter_by_gt_3mag": int((a.dmag > 3).sum()), "frac_of_alerts_with_historic_magnitude_fainter_by_gt_2mag": float((a.dmag > 2).sum() / a.HistoricMag.notna().sum()), "denominator_note": "only alerts that carry a historic magnitude can be classed as fadings; the fractions are of those", "n_alerts_without_a_measured_alert_magnitude": int(a.AlertMag.isna().sum()), "attribution_of_gt_2mag_faders": fad.attribution.value_counts().to_dict(), "attribution_of_gt_3mag_faders": deep.attribution.value_counts().to_dict(), "unattributed_gt_3mag": deep[deep.attribution == "unattributed"][["Name", "dmag", "Comment"]].to_dict("records"), "Gaia17bpp": bpp[["Date", "AlertMag", "HistoricMag", "Class", "Comment"]].to_dict("records"), "note": "Every alert carries a measured alert magnitude: the stream is built from on-board detections, so a " "star that drops below G ~ 20.7 produces no transit and cannot be the alerting datum.", } with open(os.path.join(DATA, "gaia_alerts_summary.json"), "w") as f: json.dump(summary, f, indent=1) print("[catalogues] Gaia alerts:", json.dumps({k: v for k, v in summary.items() if k != "note"}, indent=1)) return summary def published_searches(): summ_path = os.path.join(DATA, "ogle2_search_summary.json") o2 = json.load(open(summ_path)) if os.path.exists(summ_path) else {} o2_result = (f"{o2.get('n_candidates', '?')} candidates (drop >= 50%); {o2.get('variant_n_pass_all', '?')} leftovers of the looser " "variant (drop >= 30%), see data/ogle2_variant_survivors.csv") rows = [ dict(survey="(proposal, no search) EROS-2, OGLE-III/IV", reference="Bramante, Diamond & Kim 2025, PRL (arXiv:2409.08322)", stars="4.9e7 (OGLE-IV bulge), 7.9e7 (OGLE-III+IV LMC)", exposure_star_yr="forecast only", photometry="-", records_vanished_star="-", selection="dimming by more than 34%; microlensing rate formalism with the physical radius in place of the Einstein radius", result="forecast constraints on dark-matter clumps 'assuming null observations'; no light curves searched", total_achromatic_single="not searched", efficiency_calibrated="no: efficiency set to 1, stated to be out of scope"), dict(survey="MACHO bulge + LMC + SMC", reference="Drake & Cook 2003 (astro-ph/0301013)", stars="3.32e7 + 1.29e7 + 2.48e6", exposure_star_yr="1.71e8 + 9.38e7 + 1.83e7", photometry="fixed-position PSF (SoDoPHOT)", records_vanished_star="probably (not stated for zero flux)", selection="dip > 0.2 mag, > 10 d, >= 10 points; no return to baseline required; then colour, variable-star, " "proper-motion cuts; final cut demands dust-like reddening", result="83 / 133 / 36 survive to the reddening test, 10 / 2 / 0 pass it; 'most, if not all' variable stars; one bulge " "light curve, MACHO 120.21786.958, drops by > 3.5 mag and is called a good obscuration candidate (it passed " "the reddening test, so it is chromatic)", total_achromatic_single="<= 83 bulge (unvetted; would fail the final reddening cut by construction)", efficiency_calibrated="yes, injected dusty clouds up to A_V = 12, for the Kerins-Binney-Silk cloud model"), dict(survey="OGLE-II bulge DIA", reference="this work, on Wozniak et al. 2002 catalogue", stars="6.37e6 (I < 18) monitored; 221,801 catalogued light curves searched", exposure_star_yr="about 1.2e7 effective (duration dependent)", photometry="forced difference-image flux", records_vanished_star="yes (verified: R CrB star Terz V 2637 at zero flux)", selection="see data/ogle2_cutflow.csv", result=o2_result, total_achromatic_single="0", efficiency_calibrated="by injection into catalogue light curves; catalogue inclusion " "measured on eclipsing binaries, not by image-level injection"), dict(survey="VVV / VIRAC2 bulge + disc", reference="Smith et al. 2021; Lucas et al. 2024", stars="about 3e8 with Ks < 17 in 200 deg2 of bulge (Smith et al. 2021); >= 10 detections required", exposure_star_yr="9.5 yr baseline", photometry="detection-based PSF (DoPhot) catalogue", records_vanished_star="no (non-detections are not light-curve points)", selection="Delta Ks >= 4 mag among detections", result="222 sources; 21 aperiodic dipping giants; VVV-WIT-08, -10, -11", total_achromatic_single="1 by luck: VVV-WIT-11, 'undetected for a year', found only through mismatches with a neighbour", efficiency_calibrated="no"), dict(survey="ASAS-SN Sky Patrol v2", reference="JoHantgen et al. 2025 (arXiv:2507.19594)", stars="5.1e6 (13 < g < 14)", exposure_star_yr="about 5e7 (10 yr assumed)", photometry="forced aperture photometry", records_vanished_star="yes", selection="Delta g >= 0.3 mag, quiet light curve, visual inspection", result="19 new + 12 known: 13 single-eclipse and 7 multi-eclipse long-period binaries, 11 dippers", total_achromatic_single="0 total disappearances reported", efficiency_calibrated="no"), dict(survey="ZTF + Gaia eDR3", reference="Tzanidakis et al. 2025 (arXiv:2508.03964)", stars="6.3e7 FGK main-sequence stars", exposure_star_yr="about 3e8 (5 yr assumed)", photometry="detection-based PSF catalogue", records_vanished_star="no (gap)", selection="light-curve dip score", result="81 dipper candidates", total_achromatic_single="none reported", efficiency_calibrated="no"), dict(survey="2MASS calibration fields", reference="Quillen et al. 2014 (arXiv:1402.1198)", stars="over 4e4", exposure_star_yr="about 1.6e5 (4 yr)", photometry="repeated J, H, Ks scans", records_vanished_star="not stated", selection="significant day-long dimming events; red dimming events", result="46 eclipsing binaries, 21 young stars, 11 unclassified dimmers; among quiescent stars no dimming deeper " "than 0.2 mag lasting longer than a day", total_achromatic_single="0", efficiency_calibrated="no"), dict(survey="NSVS + ASAS-SN", reference="Schmidt 2019, 2022", stars="subset of 1.4e7 NSVS objects", exposure_star_yr="-", photometry="aperture", records_vanished_star="partly", selection="Boyajian-star analogues (shallow, repeated dips)", result="21 + 15 'slow dippers'", total_achromatic_single="none reported", efficiency_calibrated="no"), dict(survey="Gaia Photometric Science Alerts", reference="Hodgkin et al. 2021; alerts.csv", stars="all sky to G = 20.7", exposure_star_yr="2014-2025", photometry="on-board detection", records_vanished_star="no (no transit is generated)", selection="delta-magnitude and rms detectors, human vetting", result="see data/gaia_alerts_summary.json", total_achromatic_single="none identifiable", efficiency_calibrated="no"), dict(survey="OGLE-III / OGLE-IV bulge", reference="Udalski et al. 2015; Mroz et al. 2019", stars="5.4e7 (I < 18), 7.2e8 (I < 21) in OGLE-IV", exposure_star_yr=">= 4e8 (I < 18, 2010-2017)", photometry="forced difference-image flux", records_vanished_star="yes", selection="published searches target brightenings (microlensing, novae) and periodic variables", result="no published search for disappearances", total_achromatic_single="not searched", efficiency_calibrated="-"), dict(survey="Kepler / K2 / TESS", reference="e.g. Boyajian et al. 2016", stars="about 2e5 (Kepler)", exposure_star_yr="about 8e5 (Kepler)", photometry="aperture", records_vanished_star="yes", selection="transit and dip searches", result="shallow dips (<= 22% in KIC 8462852)", total_achromatic_single="0", efficiency_calibrated="for planets only"), dict(survey="VASCO (POSS-I vs Pan-STARRS)", reference="Villarroel et al. 2020; Solano et al. 2022", stars="6e8 USNO-B objects", exposure_star_yr="two epochs, about 60 yr apart", photometry="catalogue cross-match", records_vanished_star="only if no other source lies within 30 arcsec (2020) / 5 arcsec (2022)", selection="present in the 1950s, absent now", result="about 100 point-source candidates (2020); 5,399 unidentified (2022)", total_achromatic_single="none confirmed", efficiency_calibrated="no"), ] t = pd.DataFrame(rows) t.to_csv(os.path.join(DATA, "published_searches.csv"), index=False) return t def mimics(): rows = [ ("epsilon Aur", "27.1 yr binary, disc around companion", "about 0.8 mag", "about 2 yr", "repeats; partial; mid-eclipse brightening; IR excess of the disc"), ("EE Cep", "5.6 yr binary, inclined disc", "0.5-2 mag, varies", "30-90 d", "repeats; depth changes; blueing"), ("VVV-WIT-08", "giant + unseen elliptical occulter (Smith et al. 2021)", "97% in V, I, Ks", "about 200 d", "smooth, symmetric, 3% residual; ingress lasts months, not hours"), ("ASASSN-21co", "11.9 yr M-giant binary (Rowan et al. 2021)", "about 0.6 mag", "about 66 d", "repeats; partial; secondary eclipse"), ("Gaia17bpp", "M giant + disc-bearing companion (Tzanidakis et al. 2023)", "about 4.5 mag", "about 6.5 yr", "ingress lasts months; bound companion"), ("R CrB / DY Per stars", "carbon-dust ejection", "up to 8 mag", "weeks to years", "repeat irregularly; redden; hydrogen-deficient spectrum; slow recovery (Terz V 2637 in OGLE-II: positive control of this work)"), ("long-period variables at deep minimum", "pulsation and dust", "up to 90%", "months", "continuous variation outside the minimum; V- or U-shaped, not flat; found in this work (V3811 Sgr, OGLE BLG-LPV-196232)"), ("young 'dipper' stars", "inner-disc occultation", "<= 1-2 mag", "hours to days", "quasi-periodic; redden; IR excess; star-forming regions"), ("high-proper-motion star (fixed-position photometry)", "star leaves its template position", "to zero", "permanent", "smooth decline over years; found in this work"), ("bright-star artefact (OGLE-II)", "spike or ghost changing between seasons", "to zero", "permanent step", "step falls in a seasonal gap; within 1 arcmin of a G < 10.5 star; found in this work"), ] t = pd.DataFrame(rows, columns=["object_or_class", "nature", "depth", "duration", "what_separates_it_from_an_opaque_sphere"]) t.to_csv(os.path.join(DATA, "natural_mimics.csv"), index=False) return t def survivor_gaia(): """Gaia DR3 sources within 20 arcsec of selected OGLE-II survivors.""" c4p, vp = os.path.join(DATA, "ogle2_c4_survivors.csv"), os.path.join(DATA, "ogle2_variant_survivors.csv") if not os.path.exists(c4p): print("[catalogues] run 04_ogle2_search.py first; skipping the Gaia cross-match") return None c4 = pd.read_csv(c4p) sel = c4[(c4["class"] == "monotonic drift through the whole light curve") | ((c4.r_run_med < 0.3) & (c4["class"] == "smooth variation"))] tg = pd.concat([sel.assign(sample="baseline C1-C4"), pd.read_csv(vp).assign(sample="variant survivor")]) cache = os.path.join(RAW, "gaia_near_survivors.csv") have = pd.read_csv(cache) if os.path.exists(cache) else pd.DataFrame(columns=["field", "idx"]) new = [] for r in tg.itertuples(): if ((have.field == r.field) & (have.idx == r.idx)).any(): continue q = ("SELECT source_id, ra, dec, phot_g_mean_mag, phot_rp_mean_mag, pmra, pmdec, parallax, astrometric_params_solved, " f"DISTANCE(POINT('ICRS', ra, dec), POINT('ICRS', {r.ra}, {r.dec}))*3600 AS sep_arcsec FROM gaiadr3.gaia_source " f"WHERE 1=CONTAINS(POINT('ICRS', ra, dec), CIRCLE('ICRS', {r.ra}, {r.dec}, {20.0 / 3600})) AND phot_g_mean_mag < 19.5") try: d = pd.read_csv(io.BytesIO(tap_sync(GAIA_TAP, q))) except Exception as e: # noqa: BLE001 print(f"[catalogues] Gaia query failed for sc{r.field} #{r.idx}: {e!r}") continue d["field"], d["idx"] = r.field, r.idx new.append(d) print(f"[catalogues] Gaia cone sc{r.field} #{r.idx}: {len(d)} sources", flush=True) if new: have = pd.concat([have] + new, ignore_index=True) have.to_csv(cache, index=False) rows = [] for r in tg.itertuples(): d = have[(have.field == r.field) & (have.idx == r.idx)].copy() if not len(d): continue d["pm"] = np.hypot(d.pmra, d.pmdec) hpm = d[d.pm > 50].sort_values("sep_arcsec") two = d[(d.astrometric_params_solved == 3) & (d.phot_g_mean_mag < 18) & (d.sep_arcsec < 10)].sort_values("sep_arcsec") near = d[d.sep_arcsec < 1.0].sort_values("sep_arcsec") rows.append({"field": r.field, "idx": r.idx, "sample": r.sample, "I_bright": r.I_bright, "r_run_med": r.r_run_med, "t1": r.t1, "dur_d": r.dur, "hpm_star_within_20arcsec_pm_masyr": float(hpm.pm.iloc[0]) if len(hpm) else np.nan, "hpm_star_sep_arcsec": float(hpm.sep_arcsec.iloc[0]) if len(hpm) else np.nan, "bright_2param_source_within_10arcsec_G": float(two.phot_g_mean_mag.iloc[0]) if len(two) else np.nan, "bright_2param_source_sep_arcsec": float(two.sep_arcsec.iloc[0]) if len(two) else np.nan, "gaia_source_within_1arcsec_G": float(near.phot_g_mean_mag.iloc[0]) if len(near) else np.nan, "gaia_source_within_1arcsec_RP": float(near.phot_rp_mean_mag.iloc[0]) if len(near) else np.nan}) out = pd.DataFrame(rows) out.to_csv(os.path.join(DATA, "ogle2_survivor_gaia.csv"), index=False, float_format="%.5g") print(out.to_string(index=False, float_format=lambda x: f"{x:.2f}")) return out def simbad_near_misses(): """SIMBAD objects within 3 arcsec of the OGLE-II near misses (cached).""" import urllib.parse c4p, vp, gp = (os.path.join(DATA, f) for f in ("ogle2_c4_survivors.csv", "ogle2_variant_survivors.csv", "ogle2_gap_steps.csv")) if not os.path.exists(c4p): return None c4 = pd.read_csv(c4p) tg = pd.concat([c4[c4.c5].assign(sample="passes C1-C5, fails C6"), c4[~c4.c5 & (c4.r_run_med < 0.4)].assign(sample="passes C1-C4, flux in the low state below 0.4"), pd.read_csv(gp).assign(sample="sustained faint state with edge in a seasonal gap"), pd.read_csv(vp).assign(sample="looser-variant survivor")]).drop_duplicates(subset=["field", "idx"]) cache = os.path.join(RAW, "simbad_near_misses.csv") have = pd.read_csv(cache) if os.path.exists(cache) else pd.DataFrame(columns=["field", "idx", "main_id", "otype", "sep_arcsec"]) new = [] for r in tg.itertuples(): if ((have.field == r.field) & (have.idx == r.idx)).any(): continue q = (f"SELECT main_id, otype, DISTANCE(POINT('ICRS', ra, dec), POINT('ICRS', {r.ra}, {r.dec}))*3600 AS sep_arcsec " f"FROM basic WHERE 1=CONTAINS(POINT('ICRS', ra, dec), CIRCLE('ICRS', {r.ra}, {r.dec}, {3.0 / 3600})) ORDER BY sep_arcsec") data = urllib.parse.urlencode({"REQUEST": "doQuery", "LANG": "ADQL", "FORMAT": "csv", "QUERY": q}).encode() try: d = pd.read_csv(io.BytesIO(http_get("https://simbad.cds.unistra.fr/simbad/sim-tap/sync", data=data, timeout=120))) except Exception as e: # noqa: BLE001 print(f"[catalogues] SIMBAD query failed for sc{r.field} #{r.idx}: {e!r}") continue d = d.head(1) if len(d) else pd.DataFrame([{"main_id": "", "otype": "", "sep_arcsec": np.nan}]) d["field"], d["idx"] = r.field, r.idx new.append(d) if new: have = pd.concat([have] + new, ignore_index=True) have.to_csv(cache, index=False) out = tg[["field", "idx", "ra", "dec", "I_bright", "nrun", "dur", "t1", "r_run_med", "pre_med", "post_med", "mad_out", "sample"]].merge( have, on=["field", "idx"], how="left") out.to_csv(os.path.join(DATA, "ogle2_nearmiss_simbad.csv"), index=False, float_format="%.5g") print(out[["field", "idx", "I_bright", "dur", "r_run_med", "sample", "main_id", "otype", "sep_arcsec"]].to_string(index=False, float_format=lambda x: f"{x:.2f}")) return out def main(): gaia_alerts() t = published_searches() pd.set_option("display.width", 250) pd.set_option("display.max_colwidth", 60) print(t[["survey", "stars", "photometry", "records_vanished_star", "total_achromatic_single"]].to_string(index=False)) mimics() survivor_gaia() simbad_near_misses() if __name__ == "__main__": main()