File size: 7,727 Bytes
da0ce7b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
#!/usr/bin/env python3
"""Fetch real, open NASA sky data — the NASA Exoplanet Archive host-star table.



"Same song, different singer": this pulls *real* astronomical numbers from NASA

and caches them in the repo so the sky adapter can scan light-from-space data

that actually came off a NASA archive — not anything fabricated.



Source (open, **no API key required**):

    NASA Exoplanet Archive TAP service — the ``pscomppars`` composite-parameters

    table (one row per confirmed planet). We pull four public columns:

        pl_name    planet name

        hostname   host-star name

        st_teff    stellar effective temperature (K)   -> starlight colour

        pl_orbper  orbital period (days)               -> planetary rhythm

    https://exoplanetarchive.ipac.caltech.edu/docs/TAP/usingTAP.html



The TAP endpoint is keyless. An optional ``NASA_API_KEY`` environment variable

is honoured for any ``api.nasa.gov`` call (none is required here); it is never

invented or hard-coded. The query is deterministic (fixed columns, fixed row

cap, fixed ordering) so the cached snapshot is reproducible.



The cached CSV (``data/sky/nasa_exoplanet_hosts.csv``) carries a provenance

header comment recording the source URL, the exact query, and the row count.

Downstream, ``scripts/validation/benchmark_nasa_sky.py`` reads the cache offline

and scans it through the unchanged phenolic engine.



Pure ``requests`` + stdlib. Network access is confined to :func:`fetch_rows`.

"""

from __future__ import annotations

import argparse
import csv
import io
import os
import sys
import time
from pathlib import Path
from typing import Final

REPO_ROOT: Final[Path] = Path(__file__).resolve().parents[2]
DEFAULT_CACHE: Final[Path] = REPO_ROOT / "data" / "sky" / "nasa_exoplanet_hosts.csv"

TAP_URL: Final[str] = "https://exoplanetarchive.ipac.caltech.edu/TAP/sync"
#: Deterministic ADQL: bounded, non-null fields, stable ordering -> reproducible.
ROW_CAP: Final[int] = 1000
ADQL_QUERY: Final[str] = (
    f"select top {ROW_CAP} pl_name,hostname,ra,dec,st_teff,pl_orbper "
    "from pscomppars "
    "where st_teff is not null and pl_orbper is not null "
    "and ra is not null and dec is not null "
    "order by pl_name"
)
FIELDS: Final[tuple[str, ...]] = ("pl_name", "hostname", "ra", "dec", "st_teff", "pl_orbper")

SOURCE_CITATION: Final[str] = (
    "NASA Exoplanet Archive, pscomppars composite-parameters table, "
    "TAP service (keyless). https://exoplanetarchive.ipac.caltech.edu/"
)

_USER_AGENT: Final[str] = (
    "AureonSkyFetcher/1.0 (open astronomical data research; contact via repo)"
)


class FetchError(Exception):
    """Raised on unrecoverable network or parse failures."""


def _ca_bundle() -> str | bool:
    """Resolve a CA bundle for TLS through the agent proxy (env, then fallback)."""
    for var in ("SSL_CERT_FILE", "REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE"):
        val = os.environ.get(var)
        if val and Path(val).exists():
            return val
    fallback = "/root/.ccr/ca-bundle.crt"
    return fallback if Path(fallback).exists() else True


def fetch_rows(

    *,

    query: str = ADQL_QUERY,

    timeout: float = 60.0,

    retries: int = 4,

    backoff: float = 2.0,

) -> list[dict[str, str]]:
    """Fetch host-star rows from the NASA Exoplanet Archive TAP service.



    Returns a list of ``{pl_name, hostname, st_teff, pl_orbper}`` dicts. Retries

    with exponential backoff on rate-limit (429) / server (5xx) errors. Raises

    :class:`FetchError` if retries are exhausted or the response is unparseable.

    This is the only function that touches the network.

    """
    import requests  # local import: keeps the module importable offline

    params = {"query": query, "format": "csv"}
    headers = {"User-Agent": _USER_AGENT}
    verify = _ca_bundle()
    last_error: Exception | None = None
    for attempt in range(retries):
        try:
            resp = requests.get(
                TAP_URL, params=params, headers=headers, timeout=timeout, verify=verify
            )
            if resp.status_code in (429, 500, 502, 503, 504):
                last_error = FetchError(f"HTTP {resp.status_code}")
                time.sleep(backoff * (2**attempt))
                continue
            resp.raise_for_status()
            return _parse_csv(resp.text)
        except FetchError:
            raise
        except Exception as exc:  # requests.RequestException + parse issues
            last_error = exc
            time.sleep(backoff * (2**attempt))
    raise FetchError(f"Exhausted {retries} retries fetching NASA TAP: {last_error}")


def _parse_csv(text: str) -> list[dict[str, str]]:
    """Parse the TAP CSV response into non-null host-star rows."""
    reader = csv.DictReader(io.StringIO(text))
    rows: list[dict[str, str]] = []
    for row in reader:
        try:
            rec = {k: (row.get(k) or "").strip() for k in FIELDS}
        except AttributeError:
            continue
        if not rec["pl_name"] or not rec["st_teff"] or not rec["pl_orbper"]:
            continue
        if not rec.get("ra") or not rec.get("dec"):
            continue
        # sanity: numeric fields must parse
        try:
            float(rec["st_teff"])
            float(rec["pl_orbper"])
            float(rec["ra"])
            float(rec["dec"])
        except ValueError:
            continue
        rows.append(rec)
    if not rows:
        raise FetchError("TAP response contained no parseable rows")
    return rows


def write_cache(rows: list[dict[str, str]], path: str | Path = DEFAULT_CACHE) -> Path:
    """Write rows to the repo cache with a provenance header. Returns the path."""
    out = Path(path)
    out.parent.mkdir(parents=True, exist_ok=True)
    with out.open("w", newline="", encoding="utf-8") as fh:
        fh.write(f"# source: {SOURCE_CITATION}\n")
        fh.write(f"# query: {ADQL_QUERY}\n")
        fh.write(f"# rows: {len(rows)}\n")
        writer = csv.DictWriter(fh, fieldnames=list(FIELDS))
        writer.writeheader()
        writer.writerows(rows)
    return out


def read_cache(path: str | Path = DEFAULT_CACHE) -> list[dict[str, str]]:
    """Read the cached snapshot (skipping ``#`` provenance comment lines)."""
    p = Path(path)
    if not p.exists():
        raise FileNotFoundError(f"no NASA sky cache at {p}")
    lines = [ln for ln in p.read_text(encoding="utf-8").splitlines() if not ln.startswith("#")]
    reader = csv.DictReader(io.StringIO("\n".join(lines)))
    return [{k: (row.get(k) or "").strip() for k in FIELDS} for row in reader]


def main(argv: list[str] | None = None) -> int:
    """CLI: fetch the real NASA snapshot and write it to the repo cache."""
    parser = argparse.ArgumentParser(
        description="Fetch real NASA Exoplanet Archive host-star data (keyless TAP)."
    )
    parser.add_argument("--out", default=str(DEFAULT_CACHE), help="output cache CSV path")
    parser.add_argument("--timeout", type=float, default=60.0)
    args = parser.parse_args(argv)

    if os.environ.get("NASA_API_KEY"):
        print("NASA_API_KEY present (optional; TAP needs none).", file=sys.stderr)
    print(f"Fetching real NASA data: {SOURCE_CITATION}", file=sys.stderr)
    try:
        rows = fetch_rows(timeout=args.timeout)
    except FetchError as exc:
        print(f"error: {exc}", file=sys.stderr)
        return 1
    out = write_cache(rows, args.out)
    print(f"Wrote {len(rows)} real NASA host-star rows to {out}", file=sys.stderr)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())