"""Evidence on RECOGNITION (as opposed to detection) of artificial objects. (a) Minor-planet designations that were later deleted, from the texts of 18 MPC editorial MPECs issued 2010-2026 (downloaded in s00; not a complete census of artificial objects that ever received a minor-planet designation): which were artificial objects, and what gave them away. The classification of each notice (column `clue`) is my reading of the MPEC prose; the quoted key sentence is extracted by the code so the reader can check it. (b) Project Pluto (B. Gray) index of artificial objects picked up by asteroid surveys: number of unidentified and identified geocentric objects and of heliocentric artsats. (c) Fraction of catalogued asteroids with any physical characterisation that could expose an artificial object (non-gravitational acceleration, rotation period, spectral type, albedo), from JPL SBDB counts. Outputs: data/recognition_cases.csv, data/recognition_stats.json """ import html import json import os import re import numpy as np import pandas as pd from common import DATA, RAW from s00_download import MPECS, mpec_packed # my reading of each notice: (object, artificial?, identity, the clue that exposed it) READING = { "2010-N23": ("2010 KQ", True, "unidentified rocket stage (RK252A5)", "Earth-like orbit, then non-gravitational (radiation-pressure) residuals after 7 weeks"), "2013-Q25": ("2013 QW1", True, "Chang'e 2 third stage (2010-050B)", "matched to a tracked launch object (geocentric orbit)"), "2015-H125": ("2015 HP116", True, "Gaia spacecraft (Sun-Earth L2)", "position matched to a known spacecraft"), "2020-G97": ("2020 GL2", True, "BepiColombo spacecraft (Earth flyby)", "position matched to a known spacecraft"), "2020-X56": ("2020 XA1", True, "Spektr-RG spacecraft (Sun-Earth L2)", "position matched to a known spacecraft"), "2021-D62": ("2020 SO", True, "Surveyor 2 Centaur upper stage (1966)", "orbit traced back to Earth at a 1966 launch; then large area-to-mass ratio"), "2022-U79": ("2022 UQ1", True, "Lucy Centaur upper stage", "orbit matched to a known launch (Earth flyby date)"), "2024-J297": ("2024 JV8", True, "unidentified, likely artificial", "noticed by an expert on artificial-object orbits (B. Gray)"), "2024-N07": ("2023 JW34", True, "unidentified high area-to-mass debris", "geocentric orbit"), "2025-A49": ("2018 CN41", True, "Falcon Heavy upper stage with Tesla roadster (2018-017A)", "orbit matched to a known launch"), "2025-P132": ("2024 BY15", True, "possibly Helios B upper stage (1976)", "strong non-gravitational acceleration over a months-long arc; Earth-like orbit"), "2025-U223": ("2025 US6", True, "DRO-B spacecraft (distant retrograde lunar orbit)", "Earth-like orbit with geocentric solution; matched to a known spacecraft"), "2012-B08": ("2011 YH74", False, "", "erroneous linkage"), "2012-D99": ("2012 DU32", False, "", "erroneous linkage"), "2014-X55": ("2014 XU3", False, "", "duplicate designation"), "2023-P34": ("2015 LA65", False, "", "synthetic detections submitted in error"), "2026-J119": ("2026 JG2", False, "", "bad follow-up astrometry"), "2021-H90": ("(policy notice)", None, "", "MPC flags NEOCP tracklets on strongly geocentric orbits as suspected artificial and removes them early"), } def mpec_prose(mpec): _, p = mpec_packed(mpec) s = open(os.path.join(RAW, "mpec", p + ".html"), encoding="latin-1").read() s = re.sub(r"", "", s, flags=re.S) s = html.unescape(re.sub(r"<[^>]+>", "", s)) i = s.find("ISSN 1523-6714") j = s.find("Copyright", i) return re.sub(r"\s+", " ", s[i + 15:j]).strip() def main(): rows = [] for m in MPECS: t = mpec_prose(m) obj, art, ident, clue = READING[m] # sanity: the designation we attribute must actually occur in the MPEC text ok = obj.startswith("(") or obj in t sent = [x for x in re.split(r"(?<=[.!?])\s+", t) if re.search(r"(?i)artific|spacecraft|booster|geocentric|area|linkage|synthetic|incorrect|unnecessary", x)] rows.append(dict(mpec=m, object=obj, artificial=art, identity=ident, clue=clue, designation_found_in_text=ok, key_sentence=(sent[0][:300] if sent else t[:300]))) cases = pd.DataFrame(rows) cases.to_csv(os.path.join(DATA, "recognition_cases.csv"), index=False) art = cases[cases.artificial == True] # noqa: E712 n_art = len(art) by_clue = dict( matched_to_known_human_object=int(art.clue.str.contains("matched|traced back").sum()), geocentric_orbit=int(art.clue.str.contains("geocentric").sum()), radiation_pressure=int(art.clue.str.contains("non-gravitational|area-to-mass").sum()), expert_orbit_judgement_only=int(art.clue.str.contains("noticed by an expert").sum()), ) print(f"[recog] {len(cases)} MPEC notices read; {n_art} deleted designations were artificial objects; " f"all attributed designations present in text: {bool(cases.designation_found_in_text.all())}") print("[recog] clues (not exclusive):", by_clue) pd.set_option("display.width", 250) pd.set_option("display.max_colwidth", 90) print(art[["mpec", "object", "identity", "clue"]].to_string(index=False)) # (b) Project Pluto index s = open(os.path.join(RAW, "pseudo.htm"), encoding="latin-1").read() i_un = s.find("Unidentified, in geocentric orbit") i_id = s.find('') i_he = s.find('') i_end = s.find("", i_he) def entries(a, b): return [html.unescape(x).strip() for x in re.findall(r'\s*([^<]*?)\s*', s[a:b])] un = [e for e in entries(i_un, i_id) if e != "ETBO"] idn = entries(i_id, i_he) he = entries(i_he, i_end) n_etbo = len([e for e in entries(i_un, i_id) if e == "ETBO"]) pp = dict(unidentified_geocentric=len(un), of_which_flagged_ETBO=n_etbo, identified_geocentric=len(idn), heliocentric=len(he)) # (revision) orbital periods of the unidentified geocentric objects, parsed from the index lines # ("P=630m", "P=1.01d", "P=3700m/2.57d"; 'Molniya' = about 12 h) per_h, unparsed = [], [] for line in s[i_un:i_id].split("\n"): if "]+>", " ", line)) m = re.search(r"[Pp]\s*=\s*(?:about\s+)?([\d.]+)\s*(m|d)(?:ays?)?\b", t) if m: per_h.append(float(m.group(1)) / 60.0 if m.group(2) == "m" else float(m.group(1)) * 24.0) elif "four days" in t: per_h.append(96.0) elif "Molniya" in t: per_h.append(12.0) else: unparsed.append(t.strip()[:80]) per_h = np.array(per_h) pp.update(unidentified_lines=int(len(per_h) + len(unparsed)), unidentified_with_period=int(len(per_h)), unidentified_period_lt_2d=int((per_h < 48).sum()), unidentified_period_10_50h=int(((per_h >= 10) & (per_h <= 50)).sum()), unidentified_period_gt_3p5d=int((per_h > 84).sum()), unidentified_period_gt_10d=int((per_h > 240).sum()), unidentified_median_period_h=float(np.median(per_h)), unidentified_without_period=unparsed) print("[recog] Project Pluto index:", pp) # (c) SBDB characterisation counts c = json.load(open(os.path.join(RAW, "sbdb_charact_counts.json"))) phys = json.load(open(os.path.join(RAW, "sbdb_neo_phys.json"))) f = phys["fields"] d = pd.DataFrame(phys["data"], columns=f) d["H"] = pd.to_numeric(d.H, errors="coerce") n = len(d) neo = dict(n_neas=n) for col in ["A1", "A2", "rot_per", "spec_B", "spec_T", "albedo", "diameter"]: neo["n_" + col] = int(d[col].notna().sum()) small = d[d.H > 25] neo["n_H_gt_25"] = int(len(small)) neo["n_A1_H_gt_25"] = int(small.A1.notna().sum()) neo["n_A1_or_A2_H_gt_25"] = int((small.A1.notna() | small.A2.notna()).sum()) neo["n_rot_per_H_gt_25"] = int(small.rot_per.notna().sum()) neo["frac_A1"] = neo["n_A1"] / n neo["frac_A1_H_gt_25"] = neo["n_A1_H_gt_25"] / max(1, len(small)) neo["frac_any_physical"] = float((d.rot_per.notna() | d.spec_B.notna() | d.spec_T.notna() | d.albedo.notna() | d.A1.notna()).mean()) neo["frac_any_physical_H_gt_25"] = float((small.rot_per.notna() | small.spec_B.notna() | small.spec_T.notna() | small.albedo.notna() | small.A1.notna()).mean()) allc = dict(n_asteroids=c["all"], frac_albedo=c["albedo"] / c["all"], frac_rot_per=c["rot_per"] / c["all"], frac_spectral_type=c["spec_B"] / c["all"], n_A1=c["A1"], n_A2=c["A2"]) for cls in ["MBA", "TJN", "TNO"]: allc[f"{cls}_n"] = c["class_" + cls] allc[f"{cls}_frac_albedo"] = c[f"class_{cls}_albedo"] / c["class_" + cls] allc[f"{cls}_frac_rot_per"] = c[f"class_{cls}_rot"] / c["class_" + cls] # (revision) characterisation rate as a function of absolute magnitude for the belts byH = json.load(open(os.path.join(RAW, "sbdb_charact_by_H.json"))) for g_, rows_ in byH["counts"].items(): print(f"[recog] {g_}: fraction with albedo / any physical characterisation by H bin: " + "; ".join(f"H {r['H_lo']}-{r['H_hi']}: {r['n_albedo'] / max(1, r['n']):.3f} / {r['n_any_physical'] / max(1, r['n']):.3f} (n={r['n']})" for r in rows_)) print("[recog] NEA characterisation:", json.dumps(neo, indent=1)) print("[recog] all asteroids:", json.dumps(allc, indent=1)) json.dump(dict(n_mpec_notices=len(cases), mpec_notices_cover="2010-2026 (not a complete census of artificial objects that ever received a designation)", n_artificial_deleted=n_art, clues=by_clue, project_pluto=pp, nea_characterisation=neo, all_asteroid_characterisation=allc, characterisation_by_H=byH["counts"]), open(os.path.join(DATA, "recognition_stats.json"), "w"), indent=1) if __name__ == "__main__": main()