This Python script generates the Solar Almanac Data (2026–2030) used by the nautical navigation documents (Docs #10, #11, #13, #15, #27). It computes daily sun position data — Greenwich Hour Angle, Declination, Equation of Time, and Semi-diameter — using the Skyfield astronomy library with NASA’s JPL DE421 ephemeris.
Requirements: Python 3.6+, Skyfield library
(pip install skyfield). The DE421 ephemeris (~17 MB)
is downloaded automatically on first run.
Usage:
python scripts/generate_almanac.py [start_year] [end_year] [output_file]
# Default: 2026 2030 tables-almanac.md
Output: tables-almanac.md — daily
sun position data for 5 years, plus solstice/equinox dates.
Source Code
#!/usr/bin/env python3
"""
Generate solar almanac data for the Recovery Library using Skyfield.
Produces tables-almanac.md with daily sun position data:
- GHA (Greenwich Hour Angle) at 00:00 UTC
- Declination at 00:00 UTC
- Equation of Time
- Semi-diameter
Uses the Skyfield library with JPL DE421 ephemeris for validated
astronomical accuracy (sub-arcsecond precision).
Usage:
python generate_almanac.py [start_year] [end_year] [output_file]
Default: 2026 2030 ../tables-almanac.md
Requirements:
pip install skyfield
(DE421 ephemeris will be downloaded automatically on first run, ~17MB)
"""
import sys
import os
import math
from datetime import datetime
def generate_almanac(start_year=2026, end_year=2030, output_file=None):
# Import skyfield here so the script gives a clear error if not installed
try:
from skyfield.api import load
from skyfield.almanac import find_discrete, seasons
except ImportError:
print("ERROR: skyfield not installed. Run: pip install skyfield")
sys.exit(1)
if output_file is None:
output_file = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "..", "tables-almanac.md"
)
# Load ephemeris
print("Loading JPL DE421 ephemeris...")
eph_loader = load
ts = load.timescale()
eph = load('de421.bsp')
sun = eph['sun']
earth = eph['earth']
num_years = end_year - start_year + 1
total_days = num_years * 366 # approximate
content = f"""---
title: "Solar Almanac Data: {start_year}–{end_year}"
subtitle: "Recovery Library — Computed Reference Data"
date: "{datetime.now().strftime('%Y-%m-%d')}"
---
# Solar Almanac Data: {start_year}–{end_year}
*Recovery Library — Computed Reference Data*
**Computation method:** All values computed using the Skyfield astronomy
library with the JPL DE421 planetary ephemeris.
**Reproduction:** Any computer with Python and the Skyfield library can
reproduce and extend these tables by running `scripts/generate_almanac.py`.
---
"""
for year in range(start_year, end_year + 1):
print(f"Computing {year}...")
content += f"## {year}\n\n"
content += "| Date | GHA Sun 00h | Dec | EoT | SD |\n"
content += "|------|-------------|-----|-----|----|\n"
import calendar
days_in_year = 366 if calendar.isleap(year) else 365
for doy in range(1, days_in_year + 1):
from datetime import date as dt_date
d = dt_date.fromordinal(dt_date(year, 1, 1).toordinal() + doy - 1)
t = ts.utc(d.year, d.month, d.day, 0, 0, 0)
astrometric = earth.at(t).observe(sun)
ra, dec, distance = astrometric.apparent().radec()
gast = t.gast
gha_hours = gast - ra.hours
if gha_hours < 0:
gha_hours += 24
gha_deg = gha_hours * 15
if gha_deg >= 360:
gha_deg -= 360
gha_d = int(gha_deg)
gha_m = (gha_deg - gha_d) * 60
dec_deg = dec.degrees
dec_sign = "N" if dec_deg >= 0 else "S"
dec_abs = abs(dec_deg)
dec_d = int(dec_abs)
dec_m = (dec_abs - dec_d) * 60
eot_deg = gha_deg - 180.0
if eot_deg > 180:
eot_deg -= 360
if eot_deg < -180:
eot_deg += 360
eot_minutes = -eot_deg * 4
eot_sign = "+" if eot_minutes >= 0 else "−"
eot_abs = abs(eot_minutes)
eot_m = int(eot_abs)
eot_s = (eot_abs - eot_m) * 60
sun_radius_km = 695700.0
distance_km = distance.km
sd_rad = math.atan(sun_radius_km / distance_km)
sd_arcmin = math.degrees(sd_rad) * 60
date_str = d.strftime("%b %d")
content += (
f"| {date_str} | {gha_d:3d}° {gha_m:04.1f}' | "
f"{dec_sign} {dec_d:2d}° {dec_m:04.1f}' | "
f"{eot_sign}{eot_m:02d}m {eot_s:04.1f}s | "
f"{sd_arcmin:.1f}' |\n"
)
content += "\n---\n\n"
# Annual summary with solstice/equinox dates
content += "## Solstice, Equinox, and Perihelion Dates\n\n"
content += "| Year | Perihelion | March Equinox | June Solstice "
content += "| September Equinox | December Solstice |\n"
content += "|------|------------|---------------|---------------"
content += "|-------------------|-------------------|\n"
for year in range(start_year, end_year + 1):
t0 = ts.utc(year, 1, 1)
t1 = ts.utc(year + 1, 1, 1)
season_times, season_types = find_discrete(t0, t1, seasons(eph))
season_names = {
0: "mar_eq", 1: "jun_sol", 2: "sep_eq", 3: "dec_sol"
}
events = {}
for st, stype in zip(season_times, season_types):
name = season_names.get(stype, "unknown")
events[name] = st.utc_strftime('%b %d %H:%M UTC')
min_dist = float('inf')
peri_date = ""
for day in range(1, 15):
t = ts.utc(year, 1, day, 12)
_, _, dist = earth.at(t).observe(sun).apparent().radec()
if dist.km < min_dist:
min_dist = dist.km
peri_date = f"Jan {day:02d} ~12h"
content += (
f"| {year} | {peri_date} | "
f"{events.get('mar_eq', '—')} | "
f"{events.get('jun_sol', '—')} | "
f"{events.get('sep_eq', '—')} | "
f"{events.get('dec_sol', '—')} |\n"
)
content += "\n---\n\n"
content += (
f"*Generated by `scripts/generate_almanac.py` using Skyfield "
f"{_get_skyfield_version()} with JPL DE421 ephemeris.*\n"
)
with open(output_file, 'w') as f:
f.write(content)
print(f"Written to {output_file}")
print(f"Total lines: {content.count(chr(10))}")
def _get_skyfield_version():
try:
import skyfield
return skyfield.__version__
except Exception:
return "unknown"
if __name__ == "__main__":
args = sys.argv[1:]
start = int(args[0]) if len(args) > 0 else 2026
end = int(args[1]) if len(args) > 1 else 2030
out = args[2] if len(args) > 2 else None
generate_almanac(start, end, out)