"""Download and parse the public data used by the impact_window study. Raw files go to cfg.RAW (outside the shared folder); small derived tables go to data/. 1. Neumann et al. 2015 (Sci. Adv. 1, e1500852), Table 1: 74 lunar basins >= 200 km from GRAIL and LOLA. Source: Europe PMC full-text XML of PMC4646831. -> data/neumann2015_basins.csv 2. Fassett et al. 2012 (JGR 117, E00H06), Table 1: N(20) crater densities superposed on 30 basins. Source: NASA NTRS copy of the paper (PDF). The table was transcribed by hand (the PDF text layer drops the +/- glyph) and every transcribed value is cross-checked against the downloaded text. -> data/fassett2012_table1.csv 3. Orgel et al. 2018 (JGR Planets 123, 748): absolute model ages of the same 30 basins with the buffered non-sparseness correction. The journal table is paywalled; the identical basin list with model ages is in the open LPSC abstract (LPSC 49, #1395), parsed here. -> data/orgel2018_lpsc_ages.csv 4. JPL Small-Body Database: every asteroid with a measured diameter >= 10 km. -> data/sbdb_asteroids_d10.csv If a download fails and the derived CSV already exists, the existing CSV is kept and a warning is printed, so the analysis can still be rerun offline. """ from __future__ import annotations import html import json import os import re import subprocess import urllib.parse import urllib.request import pandas as pd import config as cfg UA = {"User-Agent": "Mozilla/5.0 (research script; impact_window study)"} def _get(url, dest, timeout=180): if os.path.exists(dest) and os.path.getsize(dest) > 1000: return dest os.makedirs(os.path.dirname(dest), exist_ok=True) req = urllib.request.Request(url, headers=UA) with urllib.request.urlopen(req, timeout=timeout) as r, open(dest + ".part", "wb") as f: f.write(r.read()) os.replace(dest + ".part", dest) return dest def _pdftotext(pdf, layout=True): txt = pdf[:-4] + (".layout.txt" if layout else ".txt") try: subprocess.run(["pdftotext"] + (["-layout"] if layout else []) + [pdf, txt], check=True) return open(txt, encoding="utf8", errors="replace").read() except Exception: from pdfminer.high_level import extract_text # fallback, slower return extract_text(pdf) def _clean(cell): cell = re.sub(r".*?", "", cell, flags=re.S) # footnote markers a, b, c, dagger return html.unescape(re.sub(r"<[^>]+>", "", cell)).replace("−", "-").strip() # --------------------------------------------------------------------------------------------- def fetch_neumann(): out = os.path.join(cfg.DATA, "neumann2015_basins.csv") xml = open(_get(cfg.URL_NEUMANN, os.path.join(cfg.RAW, "neumann", "neumann2015.xml")), encoding="utf8").read() table = re.search(r"]*>.*?", xml, flags=re.S).group(0) rows = [] for tr in re.findall(r"]*>(.*?)", table, flags=re.S): raw = re.findall(r"]*?(?:/>|>(.*?))", tr, flags=re.S) cells = [_clean(c) for c in raw] if len(cells) != 8 or not re.match(r"^-?\d", cells[1] or ""): continue # header rows main = cells[3] conf = {"(": "probable", "[": "possible", "{": "suggested by scaling"}.get(main[:1], "certain") num = lambda s: float(re.search(r"\d+(\.\d+)?", s).group(0)) if re.search(r"\d", s) else float("nan") rows.append(dict(name=cells[0].replace("’", "'").replace("–", "-"), lat_N=float(cells[1]), lon_E=float(cells[2]), D_main_km=num(main), main_confidence=conf, D_inner_km=num(cells[4]), notes=cells[5], bouguer_diam_km=cells[6], bouguer_contrast_mgal=cells[7])) df = pd.DataFrame(rows) assert len(df) == 74, f"expected 74 basins in Neumann et al. Table 1, parsed {len(df)}" assert abs(df.loc[df.name == "Imbrium", "D_main_km"].iloc[0] - 1321) < 1 df.to_csv(out, index=False) return df # --------------------------------------------------------------------------------------------- # Fassett et al. 2012, Table 1. Columns: basin, bracketed (uncertain), period, N(20), err, flag L # ("value may be low"), N(64), err. N per 10^6 km^2. Hand transcription of the NTRS PDF. FASSETT = [ ("South Pole-Aitken", 0, "PN", 156, 7, 1, 33, 3), ("Coulomb-Sarton", 0, "PN", 271, 54, 0, 32, 14), ("Dirichlet-Jackson", 0, "PN", 266, 36, 0, 33, 12), ("Cruger-Sirsalis", 0, "PN", 262, 46, 0, 39, 15), ("Smythii", 0, "PN", 225, 19, 0, 28, 6), ("Schiller-Zucchius", 1, "PN", 211, 47, 0, 41, 17), ("Amundsen-Ganswindt", 1, "PN", 202, 37, 1, 47, 17), ("Nubium", 0, "PN", 195, 18, 0, 33, 7), ("Poincare", 1, "PN", 194, 44, 0, 44, 16), ("Lorentz", 0, "PN", 179, 31, 0, 32, 12), ("Fitzgerald-Jackson", 1, "PN", 175, 34, 1, 43, 15), ("Birkhoff", 1, "PN", 170, 33, 1, 46, 17), ("Ingenii", 1, "PN", 167, 33, 1, 40, 14), ("Serenitatis", 1, "PN?", 298, 60, 0, 28, 20), ("Apollo", 0, "PN/N", 151, 23, 0, 12, 6), ("Freundlich-Sharonov", 0, "PN/N", 140, 18, 0, 16, 6), ("Nectaris", 0, "N", 135, 14, 0, 17, 5), ("Korolev", 0, "N/PN", 127, 22, 0, 9, 5), ("Mendeleev", 1, "N/PN", 129, 36, 0, 16, 10), ("Hertzsprung", 0, "N/PN", 129, 22, 0, 16, 6), ("Grimaldi", 1, "N/PN", 126, 28, 1, 22, 10), ("Mendel-Rydberg", 0, "N/PN", 125, 17, 0, 12, 5), ("Planck", 1, "N/PN", 118, 36, 0, 22, 14), ("Moscoviense", 0, "N", 120, 17, 0, 9, 4), ("Crisium", 0, "N", 113, 11, 0, 8, 3), ("Humorum", 0, "N", 108, 21, 0, 12, 6), ("Humboldtianum", 0, "N", 93, 14, 0, 11, 4), ("Imbrium", 0, "I", 30, 5, 0, 4, 2), ("Schrodinger", 0, "I", 19, 7, 0, float("nan"), float("nan")), ("Orientale", 0, "I", 21, 4, 0, 1, 1), ] def fetch_fassett(): out = os.path.join(cfg.DATA, "fassett2012_table1.csv") df = pd.DataFrame(FASSETT, columns=["name", "bracketed", "period", "N20", "N20_err", "may_be_low", "N64", "N64_err"]) checked = 0 try: txt = _pdftotext(_get(cfg.URL_FASSETT, os.path.join(cfg.RAW, "fassett", "fassett2012_ntrs.pdf"))) i = txt.find("Derived Age Sequence of Lunar Basins") block = txt[i:i + 6000].replace("é", "e").replace("ö", "o").replace("ü", "u") # In the PDF text layer the +/- glyph is the control character \x06, so N(20) +/- err # (and the "L" flag) can be parsed automatically and compared with the transcription. pat = re.compile(r"^\[?([A-Za-z\- ]+?)\]?(?: \(no buff\.\))?\s{2,}(?:Beginning of )?" r"(PN\?|PN/N|N/PN|PN|N|I)\s+(\d+)\s*\x06\s*(\d+)(L?)") parsed = {} for line in block.splitlines(): m = pat.match(line) if m: parsed[m.group(1).strip()] = (m.group(2), int(m.group(3)), int(m.group(4)), int(bool(m.group(5)))) for _, r in df.iterrows(): got = parsed.get(r["name"]) want = (r.period, int(r.N20), int(r.N20_err), int(r.may_be_low)) if got != want: raise AssertionError(f"Fassett Table 1 cross-check failed for {r['name']}: " f"parsed {got}, transcribed {want}") checked += 1 assert len(parsed) == 30, f"parsed {len(parsed)} rows from the downloaded Fassett table" except AssertionError: raise except Exception as e: # network or tool failure only print(f" WARNING: could not download/parse the Fassett et al. PDF ({e}); " f"using the transcription without cross-check") df["crosschecked_rows"] = checked df.to_csv(out, index=False) return df # --------------------------------------------------------------------------------------------- def fetch_orgel(): out = os.path.join(cfg.DATA, "orgel2018_lpsc_ages.csv") try: txt = _pdftotext(_get(cfg.URL_ORGEL, os.path.join(cfg.RAW, "orgel", "orgel_lpsc2018_1395.pdf"))) except Exception as e: if os.path.exists(out): print(f" WARNING: Orgel et al. abstract not downloaded ({e}); using existing {out}") return pd.read_csv(out) raise rows = [] pat = re.compile(r"^\s{4,}([A-Z][A-Za-zöüé\- ]+?)\s+(?:(PN|N|I)\s+)?(\d\.\d\d)\s*±\s*(0\.\d+),\s*-(0\.\d+)") for line in txt.splitlines(): m = pat.match(line) if m: name = (m.group(1).strip().replace("ö", "o").replace("ü", "u").replace("é", "e") .replace("Sharanov", "Sharonov")) rows.append(dict(name=name, period_marker=m.group(2) or "", age_neukum_Ga=float(m.group(3)), err_plus=float(m.group(4)), err_minus=float(m.group(5)))) df = pd.DataFrame(rows) assert len(df) == 30, f"expected 30 basins in the Orgel et al. LPSC table, parsed {len(df)}" assert df.age_neukum_Ga.iloc[0] == 4.31 and df.name.iloc[0] == "South Pole-Aitken" df.to_csv(out, index=False) return df # --------------------------------------------------------------------------------------------- def fetch_sbdb(): out = os.path.join(cfg.DATA, "sbdb_asteroids_d10.csv") dest = os.path.join(cfg.RAW, "sbdb", "sbdb_d10.json") try: if not (os.path.exists(dest) and os.path.getsize(dest) > 1000): q = urllib.parse.urlencode({ "fields": "spkid,full_name,class,diameter,diameter_sigma,albedo,H,a,e,i", "sb-kind": "a", "sb-cdata": json.dumps({"AND": ["diameter|GE|10"]})}) _get(cfg.URL_SBDB + "?" + q, dest) d = json.load(open(dest)) except Exception as e: if os.path.exists(out): print(f" WARNING: SBDB query failed ({e}); using existing {out}") return pd.read_csv(out) raise df = pd.DataFrame(d["data"], columns=d["fields"]) df["full_name"] = df["full_name"].str.strip() for c in ("diameter", "diameter_sigma", "albedo", "H", "a", "e", "i"): df[c] = pd.to_numeric(df[c], errors="coerce") df = df.sort_values("diameter", ascending=False).reset_index(drop=True) df.to_csv(out, index=False) return df def run(): n = fetch_neumann() f = fetch_fassett() o = fetch_orgel() s = fetch_sbdb() mb = s[s["class"].isin(["MBA", "IMB", "OMB"])] return dict(neumann_rows=len(n), fassett_rows=len(f), fassett_crosschecked=int(f.crosschecked_rows.iloc[0]), orgel_rows=len(o), sbdb_rows=len(s), sbdb_mainbelt_rows=len(mb), mainbelt_counts={str(d): int((mb.diameter >= d).sum()) for d in (10, 20, 50, 100, 200, 300, 440, 500)}) if __name__ == "__main__": print(json.dumps(run(), indent=1))