skymath 0.3.2

Planning-grade astronomy math for astrophotography tooling: angles, equatorial coordinates, sexagesimal parsing and formatting, angular separation and offsets, precession, MJD/JD/calendar conversions, sidereal time, and observer-local quantities (alt-az, airmass, transit).
Documentation
#!/usr/bin/env python3
"""Generate src/constellation_data.rs from the Roman (1987) boundary table.

Data source: ADC/CDS catalogue VI/42 (Roman 1987, PASP 99, 695) — fetched from
CDS when reachable, otherwise the byte-identical copy bundled with AstroPy
(astropy/coordinates/data/constellation_data_roman87.dat). The 88 official
names come from AstroPy's constellation_names.dat (IAU list via VizieR).

Run from the repo root (same environment as gen_astropy_vectors.py):

    uv run --with astropy scripts/gen_constellation_table.py

The generated file is committed; rerunning must be byte-stable for a given
catalogue revision. Records keep catalogue order and the original decimal
literals (first matching record wins in the Roman walk; see research R19/R22).
"""

from pathlib import Path
from urllib.request import urlopen

CDS_URL = "https://cdsarc.cds.unistra.fr/ftp/VI/42/data.dat"
OUT = Path("src/constellation_data.rs")


# AstroPy's constellation_names.dat carries three misspellings; skymath ships
# the official IAU forms (the AstroPy vectors compare abbreviations, and the
# full-name check special-cases these — see tests/astropy_vectors.rs).
IAU_SPELLING = {
    "Chamaleon": "Chamaeleon",
    "Ophiucus": "Ophiuchus",
    "Pisces Austrinus": "Piscis Austrinus",
}


def names_table():
    """[(abbr, full_name)] in IAU list order (alphabetical by full name)."""
    import astropy.coordinates as ac

    path = Path(str(ac.__file__)).parent / "data" / "constellation_names.dat"
    rows = []
    for line in path.read_text(encoding="utf-8").splitlines():
        line = line.strip()
        if not line or line.startswith("#"):
            continue
        abbr, full = line.split(maxsplit=1)
        rows.append((abbr, IAU_SPELLING.get(full, full)))
    if len(rows) != 88:
        raise SystemExit(f"expected 88 constellation names, got {len(rows)}")
    return rows


def zone_lines():
    """Raw data lines of VI/42: CDS first, AstroPy bundled copy as fallback."""
    try:
        text = urlopen(CDS_URL, timeout=10).read().decode("ascii")
        source = CDS_URL
    except Exception as exc:  # noqa: BLE001 — any failure means use fallback
        import astropy.coordinates as ac

        path = Path(str(ac.__file__)).parent / "data" / "constellation_data_roman87.dat"
        text = path.read_text(encoding="ascii")
        source = f"astropy bundled copy ({exc.__class__.__name__} from CDS)"
    lines = [
        ln for ln in text.splitlines() if ln.strip() and not ln.lstrip().startswith("#")
    ]
    if len(lines) != 357:
        raise SystemExit(f"expected 357 zone records, got {len(lines)} ({source})")
    print(f"source: {source}")
    return lines


def main():
    names = names_table()
    by_lower = {abbr.lower(): abbr for abbr, _ in names}

    records = []
    for ln in zone_lines():
        ra_lo, ra_hi, dec_lo, code = ln.split()
        # Serpens is split into two catalogue regions (Ser1/Ser2) — one variant.
        key = code.rstrip("0123456789").lower()
        if key not in by_lower:
            raise SystemExit(f"unknown constellation code {code!r}")
        for tok, span in ((ra_lo, (0.0, 24.0)), (ra_hi, (0.0, 24.0)), (dec_lo, (-90.0, 90.0))):
            v = float(tok)
            if not span[0] <= v <= span[1]:
                raise SystemExit(f"value {tok} out of range in record {ln!r}")
        records.append((ra_lo, ra_hi, dec_lo, by_lower[key]))

    last = records[-1]
    if (float(last[0]), float(last[1]), float(last[2]), last[3]) != (0.0, 24.0, -90.0, "Oct"):
        raise SystemExit(f"unexpected final (south-cap) record: {last}")

    body = "\n".join(
        f"    ({float(lo)}, {float(hi)}, {float(dl)}, C::{abbr})," for lo, hi, dl, abbr in records
    )
    OUT.write_text(
        f'''//! Roman (1987) precomputed constellation boundary zones — GENERATED FILE.
//!
//! Generated by `scripts/gen_constellation_table.py`; DO NOT EDIT by hand.
//! Source: ADC/CDS catalogue VI/42 (Roman 1987, PASP 99, 695), the IAU
//! (Delporte 1930) constellation boundaries precomputed as declination-zone
//! records at epoch B1875.0. Record order is the catalogue's: the first
//! matching record wins in the lookup walk (see `crate::constellation`).

use crate::constellation::Constellation as C;

/// `(ra_lo, ra_hi, dec_lo, constellation)` — RA in B1875 hours, Dec in B1875
/// degrees; a record matches when `dec >= dec_lo && ra_lo <= ra < ra_hi`.
pub(crate) const ZONES: [(f64, f64, f64, C); 357] = [
{body}
];
''',
        encoding="utf-8",
    )
    print(f"wrote {OUT} ({len(records)} records)")

    # Companion blocks for src/constellation.rs (pasted once, kept in sync by
    # the AstroPy full-name vectors): variants, ALL, abbreviations, names.
    scratch = Path("target/constellation_enum_blocks.rs")
    scratch.parent.mkdir(exist_ok=True)
    variants = "\n".join(
        f"    /// {full} ({abbr}).\n    {abbr}," for abbr, full in names
    )
    all_arr = "\n".join(f"        Constellation::{abbr}," for abbr, _ in names)
    abbrs = "\n".join(f'    "{abbr}",' for abbr, _ in names)
    fulls = "\n".join(f'    "{full}",' for _, full in names)
    scratch.write_text(
        f"// variants\n{variants}\n// ALL\n{all_arr}\n// ABBR\n{abbrs}\n// NAMES\n{fulls}\n",
        encoding="utf-8",
    )
    print(f"wrote {scratch}")


if __name__ == "__main__":
    main()