This Python script generates the Navigational Star Catalog and Charts used by Doc #015 (Star Atlas). It produces a full SHA/Declination catalog of the 57 Nautical Almanac navigational stars computed for epoch 2026.0, monthly visibility tables for Wellington (41.3°S), rise/transit/set times for the 10 brightest navigational stars, and four seasonal all-sky chart images (January, April, July, October) with constellation lines for key Southern Hemisphere asterisms. Each chart image includes an attribution footer citing the Hipparcos catalogue, Skyfield, and the JPL DE421 ephemeris. All astronomical calculations use the Skyfield library with the JPL DE421 planetary ephemeris and the Hipparcos star catalog.
Requirements: Python 3.6+ with skyfield, matplotlib, and pandas (skyfield and matplotlib required; pandas used internally by skyfield’s Hipparcos loader).
Usage:
python scripts/generate_star_catalog.py
# Default output: tables-stars.md + site/images/stars-*.png
Output: - tables-stars.md —
approximately 700+ lines of markdown tables covering the 57-star
SHA/Dec catalog, 12 monthly visibility tables, and
rise/transit/set tables for the 10 brightest stars -
site/images/stars-january.png — evening sky chart, 15
Jan 2026, 21:00 NZDT - site/images/stars-april.png —
evening sky chart, 15 Apr 2026, 21:00 NZST -
site/images/stars-july.png — evening sky chart, 15
Jul 2026, 21:00 NZST - site/images/stars-october.png
— evening sky chart, 15 Oct 2026, 21:00 NZDT
Source Code
#!/usr/bin/env python3
"""
Generate navigational star catalog and seasonal star charts for the Recovery Library.
Produces:
../tables-stars.md — Star catalog, monthly visibility, rise/transit/set tables
../site/images/stars-january.png — Evening sky chart, Jan 15 21:00 NZDT
../site/images/stars-april.png — Evening sky chart, Apr 15 21:00 NZST
../site/images/stars-july.png — Evening sky chart, Jul 15 21:00 NZST
../site/images/stars-october.png — Evening sky chart, Oct 15 21:00 NZDT
Observer location: Wellington, New Zealand (41.3°S, 174.8°E)
Epoch: 2026.0 for SHA/Dec values
Usage:
scripts/.venv/bin/python generate_star_catalog.py
Requirements:
skyfield, matplotlib (both available in project venv)
Hipparcos star catalog will be downloaded automatically on first run (~8 MB)
"""
import os
import sys
import math
from datetime import datetime, timezone, timedelta
# ---------------------------------------------------------------------------
# The 57 Nautical Almanac navigational stars
# Format: (common_name, bayer_designation, constellation, hip_id_or_None)
# HIP IDs from the Hipparcos catalog — used to look up precise positions.
# ---------------------------------------------------------------------------
NAV_STARS = [
("Acamar", "θ Eri", "Eridanus", 13847),
("Achernar", "α Eri", "Eridanus", 7588),
("Acrux", "α Cru", "Crux", 60718),
("Adhara", "ε CMa", "Canis Major", 33579),
("Aldebaran", "α Tau", "Taurus", 21421),
("Alioth", "ε UMa", "Ursa Major", 62956),
("Alkaid", "η UMa", "Ursa Major", 67301),
("Al Na'ir", "α Gru", "Grus", 109268),
("Alnilam", "ε Ori", "Orion", 26311),
("Alphard", "α Hya", "Hydra", 46390),
("Alphecca", "α CrB", "Corona Borealis", 76267),
("Alpheratz", "α And", "Andromeda", 677),
("Altair", "α Aql", "Aquila", 97649),
("Ankaa", "α Phe", "Phoenix", 2081),
("Antares", "α Sco", "Scorpius", 80763),
("Arcturus", "α Boo", "Bootes", 69673),
("Atria", "α TrA", "Triangulum Australe", 82273),
("Avior", "ε Car", "Carina", 41037),
("Bellatrix", "γ Ori", "Orion", 25336),
("Betelgeuse", "α Ori", "Orion", 27989),
("Canopus", "α Car", "Carina", 30438),
("Capella", "α Aur", "Auriga", 24608),
("Deneb", "α Cyg", "Cygnus", 102098),
("Denebola", "β Leo", "Leo", 57632),
("Diphda", "β Cet", "Cetus", 3419),
("Dubhe", "α UMa", "Ursa Major", 54061),
("Elnath", "β Tau", "Taurus", 25428),
("Eltanin", "γ Dra", "Draco", 87833),
("Enif", "ε Peg", "Pegasus", 107315),
("Fomalhaut", "α PsA", "Piscis Austrinus", 113368),
("Gacrux", "γ Cru", "Crux", 61084),
("Gienah", "γ Crv", "Corvus", 59803),
("Hadar", "β Cen", "Centaurus", 68702),
("Hamal", "α Ari", "Aries", 9884),
("Kaus Australis", "ε Sgr", "Sagittarius", 90185),
("Kochab", "β UMi", "Ursa Minor", 72607),
("Markab", "α Peg", "Pegasus", 113963),
("Menkar", "α Cet", "Cetus", 14135),
("Menkent", "θ Cen", "Centaurus", 68933),
("Miaplacidus", "β Car", "Carina", 45238),
("Mirfak", "α Per", "Perseus", 15863),
("Nunki", "σ Sgr", "Sagittarius", 92855),
("Peacock", "α Pav", "Pavo", 100751),
("Pollux", "β Gem", "Gemini", 37826),
("Procyon", "α CMi", "Canis Minor", 37279),
("Rasalhague", "α Oph", "Ophiuchus", 86032),
("Regulus", "α Leo", "Leo", 49669),
("Rigel", "β Ori", "Orion", 24436),
("Rigil Kentaurus", "α Cen", "Centaurus", 71683),
("Sabik", "η Oph", "Ophiuchus", 84012),
("Schedar", "α Cas", "Cassiopeia", 3179),
("Shaula", "λ Sco", "Scorpius", 85927),
("Sirius", "α CMa", "Canis Major", 32349),
("Spica", "α Vir", "Virgo", 65474),
("Suhail", "λ Vel", "Vela", 44816),
("Vega", "α Lyr", "Lyra", 91262),
("Zubenelgenubi", "α Lib", "Libra", 72622),
]
# Wellington, NZ coordinates
WELLINGTON_LAT = -41.3 # degrees (negative = south)
WELLINGTON_LON = 174.8 # degrees east
WELLINGTON_ELEV = 25 # metres
# New Zealand timezone offsets
NZST_OFFSET = +12 # hours, standard time (April–September)
NZDT_OFFSET = +13 # hours, daylight time (October–March)
def get_nz_offset(month):
"""Return NZ UTC offset in hours for a given month."""
# NZDT: last Sun in September to first Sun in April
# Approximate by month: Oct–Mar = NZDT (+13), Apr–Sep = NZST (+12)
if month in (10, 11, 12, 1, 2, 3):
return NZDT_OFFSET
else:
return NZST_OFFSET
def load_skyfield():
"""Import skyfield modules and load ephemeris + Hipparcos catalog."""
try:
from skyfield.api import load, Star, wgs84
from skyfield.data import hipparcos
except ImportError:
print("ERROR: skyfield not installed.")
sys.exit(1)
ts = load.timescale()
eph = load('de421.bsp')
earth = eph['earth']
# Load Hipparcos catalog
print("Loading Hipparcos catalog...")
with load.open(hipparcos.URL) as f:
hip_df = hipparcos.load_dataframe(f)
return ts, earth, hip_df, Star, wgs84
def build_star_objects(hip_df, Star):
"""Build a list of (name, bayer, constellation, magnitude, Star object) for all 57 stars."""
stars_out = []
for (name, bayer, const, hip_id) in NAV_STARS:
if hip_id not in hip_df.index:
print(f" WARNING: HIP {hip_id} ({name}) not found in catalog")
continue
row = hip_df.loc[hip_id]
mag = row['magnitude']
star_obj = Star.from_dataframe(hip_df.loc[hip_id:hip_id])
stars_out.append((name, bayer, const, mag, star_obj))
return stars_out
def compute_sha_dec(ts, earth, star_obj, epoch_year=2026):
"""
Compute SHA and Declination for a star at a given epoch.
SHA = 360 - RA (in degrees).
Returns (sha_deg, dec_deg).
"""
t = ts.tt_jd(ts.utc(epoch_year, 1, 1, 12).tt)
astrometric = earth.at(t).observe(star_obj)
ra, dec, _ = astrometric.radec(epoch='date')
ra_h = ra.hours
dec_d = dec.degrees
# Skyfield may return numpy arrays when Star is from a dataframe
if hasattr(ra_h, '__len__'):
ra_h = ra_h[0] if len(ra_h) == 1 else ra_h
if hasattr(dec_d, '__len__'):
dec_d = dec_d[0] if len(dec_d) == 1 else dec_d
ra_deg = float(ra_h) * 15.0
sha_deg = 360.0 - ra_deg
if sha_deg >= 360.0:
sha_deg -= 360.0
return sha_deg, float(dec_d)
def _scalar(val):
"""Convert a Skyfield angle attribute (possibly a numpy array) to a Python float."""
if hasattr(val, '__len__'):
return float(val[0]) if len(val) == 1 else float(val)
return float(val)
def fmt_angle(deg, signed=True):
"""Format a decimal degree angle as degrees and arcminutes."""
if signed:
sign = "N" if deg >= 0 else "S"
d = int(abs(deg))
m = (abs(deg) - d) * 60.0
return f"{sign}{d:02d}° {m:04.1f}'"
else:
d = int(deg)
m = (deg - d) * 60.0
return f"{d:3d}° {m:04.1f}'"
def build_catalog_table(ts, earth, star_list):
"""Build the 57-star catalog table as a markdown string."""
rows = []
for (name, bayer, const, mag, star_obj) in star_list:
sha, dec = compute_sha_dec(ts, earth, star_obj)
sha_str = fmt_angle(sha, signed=False)
dec_str = fmt_angle(dec, signed=True)
rows.append((sha, name, bayer, sha_str, dec_str, mag, const))
# Sort by SHA
rows.sort(key=lambda r: r[0])
lines = []
lines.append("| # | Name | Bayer | SHA | Dec | Mag | Constellation |")
lines.append("|---|------|-------|-----|-----|-----|---------------|")
for i, (sha_raw, name, bayer, sha_str, dec_str, mag, const) in enumerate(rows, 1):
lines.append(f"| {i:2d} | {name} | {bayer} | {sha_str} | {dec_str} | {mag:.1f} | {const} |")
return "\n".join(lines), rows
def compute_monthly_visibility(ts, earth, star_list, lat=WELLINGTON_LAT, lon=WELLINGTON_LON):
"""
For each month, find which of the 57 stars are above 10° altitude
at 21:00 local time on the 15th, as seen from Wellington.
Returns dict: month_num -> list of (name, alt_deg, az_deg)
"""
from skyfield.api import wgs84
observer = wgs84.latlon(lat, lon, elevation_m=WELLINGTON_ELEV)
monthly = {}
for month in range(1, 13):
utc_offset = get_nz_offset(month)
# 21:00 local = 21:00 - offset in UTC
utc_hour = 21 - utc_offset
utc_day = 15
utc_month = month
if utc_hour < 0:
utc_hour += 24
utc_day = 14 # previous day in UTC
t = ts.utc(2026, utc_month, utc_day, utc_hour, 0, 0)
visible = []
for (name, bayer, const, mag, star_obj) in star_list:
astrometric = (earth + observer).at(t).observe(star_obj)
alt, az, _ = astrometric.apparent().altaz()
if _scalar(alt.degrees) > 10.0:
visible.append((name, _scalar(alt.degrees), _scalar(az.degrees), mag))
# Sort by altitude descending
visible.sort(key=lambda x: -x[1])
monthly[month] = visible
return monthly
def build_visibility_table(monthly_visibility):
"""Build markdown for monthly visibility section."""
month_names = [
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December"
]
lines = []
for month in range(1, 13):
tz_name = "NZDT" if get_nz_offset(month) == NZDT_OFFSET else "NZST"
visible = monthly_visibility[month]
lines.append(f"### {month_names[month-1]} (21:00 {tz_name}, 15th)")
lines.append(f"*{len(visible)} stars above 10° altitude*\n")
lines.append("| Star | Altitude | Azimuth | Mag |")
lines.append("|------|----------|---------|-----|")
for (name, alt, az, mag) in visible:
lines.append(f"| {name} | {alt:.1f}° | {az:.1f}° | {mag:.1f} |")
lines.append("")
return "\n".join(lines)
def compute_rise_transit_set(ts, earth, star_list, lat=WELLINGTON_LAT, lon=WELLINGTON_LON):
"""
Compute rise, transit, set times for the 10 brightest navigational stars
for the 15th of each month from Wellington.
Returns dict: star_name -> list of (month, rise_str, transit_str, set_str)
"""
from skyfield.api import wgs84
from skyfield.almanac import find_discrete, risings_and_settings
observer_loc = wgs84.latlon(lat, lon, elevation_m=WELLINGTON_ELEV)
observer = earth + observer_loc
# Pick 10 brightest
by_mag = sorted(star_list, key=lambda x: x[3])[:10]
by_mag_names = [s[0] for s in by_mag]
month_names = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
results = {}
for (name, bayer, const, mag, star_obj) in by_mag:
results[name] = []
for month in range(1, 13):
utc_offset = get_nz_offset(month)
# Search window: 00:00 to 24:00 local time on the 15th
t0 = ts.utc(2026, month, 15, -utc_offset, 0, 0)
t1 = ts.utc(2026, month, 16, -utc_offset, 0, 0)
try:
f = risings_and_settings(earth, star_obj, observer_loc,
horizon_degrees=-0.5667)
times, events = find_discrete(t0, t1, f)
rise_str = "—"
set_str = "—"
for ti, ev in zip(times, events):
local_dt = ti.utc_datetime().replace(tzinfo=timezone.utc)
local_hour = local_dt.hour + utc_offset
local_min = local_dt.minute
if local_hour >= 24:
local_hour -= 24
time_str = f"{local_hour:02d}:{local_min:02d}"
if ev == 1: # rising
rise_str = time_str
else: # setting
set_str = time_str
except Exception:
rise_str = "circ"
set_str = "circ"
# Compute transit: when star reaches highest altitude
# Sample every 10 minutes through the day to find max
try:
best_alt = -90.0
best_t = None
for h in range(0, 144): # 0 to 24h in 10-min steps
frac_h = h * 10.0 / 60.0
t_sample = ts.utc(2026, month, 15,
int(frac_h) - utc_offset,
int((frac_h % 1) * 60), 0)
alt, _, _ = observer.at(t_sample).observe(star_obj).apparent().altaz()
if _scalar(alt.degrees) > best_alt:
best_alt = _scalar(alt.degrees)
best_t = t_sample
if best_t is not None and best_alt > 0:
local_dt = best_t.utc_datetime().replace(tzinfo=timezone.utc)
local_hour = local_dt.hour + utc_offset
local_min = local_dt.minute
if local_hour >= 24:
local_hour -= 24
transit_str = f"{local_hour:02d}:{local_min:02d}"
else:
transit_str = "—"
except Exception:
transit_str = "—"
results[name].append((month_names[month-1], rise_str, transit_str, set_str))
return results, by_mag_names
def build_rise_transit_set_table(rts_data, star_names):
"""Build markdown for rise/transit/set section."""
lines = []
for name in star_names:
if name not in rts_data:
continue
lines.append(f"### {name}")
lines.append("| Month | Rise | Transit | Set |")
lines.append("|-------|------|---------|-----|")
for (month_str, rise, transit, setting) in rts_data[name]:
lines.append(f"| {month_str} | {rise} | {transit} | {setting} |")
lines.append("")
return "\n".join(lines)
# ---------------------------------------------------------------------------
# Star chart generation
# ---------------------------------------------------------------------------
CHART_DATES = [
("january", 2026, 1, 15, "January 2026", "NZDT"),
("april", 2026, 4, 15, "April 2026", "NZST"),
("july", 2026, 7, 15, "July 2026", "NZST"),
("october", 2026, 10, 15, "October 2026", "NZDT"),
]
def make_star_chart(ts, earth, star_list, month_tag, year, month, day,
title_month, tz_name, output_path, hip_df=None, StarClass=None):
"""
Generate a circular alt/az star chart for the evening sky from Wellington.
Center = zenith, outer ring = horizon. Uses altitude/azimuth projection.
"""
try:
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import numpy as np
except ImportError:
print("ERROR: matplotlib not installed.")
return
from skyfield.api import wgs84
utc_offset = get_nz_offset(month)
# 21:00 local time
utc_hour = 21 - utc_offset
utc_day_actual = day
if utc_hour < 0:
utc_hour += 24
utc_day_actual = day - 1
t = ts.utc(year, month, utc_day_actual, utc_hour, 0, 0)
observer = wgs84.latlon(WELLINGTON_LAT, WELLINGTON_LON, elevation_m=WELLINGTON_ELEV)
# Compute alt/az for each star
chart_stars = []
for (name, bayer, const, mag, star_obj) in star_list:
astrometric = (earth + observer).at(t).observe(star_obj)
alt, az, _ = astrometric.apparent().altaz()
if _scalar(alt.degrees) > -5.0: # include stars just below horizon for labeling
chart_stars.append((name, _scalar(alt.degrees), _scalar(az.degrees), mag))
# ---------- matplotlib figure ----------
fig, ax = plt.subplots(figsize=(9, 9), facecolor='#090920')
ax.set_facecolor('#090920')
ax.set_xlim(-1.15, 1.15)
ax.set_ylim(-1.15, 1.15)
ax.set_aspect('equal')
ax.axis('off')
import numpy as np
# Draw horizon circle
theta_ring = np.linspace(0, 2 * np.pi, 360)
ax.plot(np.cos(theta_ring), np.sin(theta_ring),
color='#4488cc', linewidth=1.5, zorder=2)
# Draw altitude grid circles (30°, 60°)
for alt_line in (30, 60):
r = 1.0 - alt_line / 90.0
ax.plot(r * np.cos(theta_ring), r * np.sin(theta_ring),
color='#223355', linewidth=0.6, linestyle='--', zorder=1)
ax.text(0, r + 0.02, f"{alt_line}°",
color='#334466', fontsize=6, ha='center', va='bottom')
# Draw azimuth lines every 45°
for az_line in range(0, 360, 45):
az_rad = math.radians(az_line)
# In our projection: N is up (az=0), E is left (az=90)
# We map: x = sin(az), y = cos(az) — standard compass-to-Cartesian
# But to preserve astronomical convention (E on left when facing south)
# we flip x: x = -sin(az), y = cos(az)
x_end = -math.sin(az_rad)
y_end = math.cos(az_rad)
ax.plot([0, x_end], [0, y_end],
color='#1a2a3a', linewidth=0.5, zorder=1)
# Cardinal direction labels
# N up, E left (southern hemisphere sky view facing up = south at top is
# conventional for SH star charts; but we use N-up for simplicity)
cardinals = [
(0, "N", 0, 1.10),
(90, "E", -1.10, 0),
(180, "S", 0, -1.10),
(270, "W", 1.10, 0),
]
for (az_c, label, x_c, y_c) in cardinals:
ax.text(x_c, y_c, label, color='#88aacc', fontsize=12,
fontweight='bold', ha='center', va='center', zorder=5)
# Plot stars
for (name, alt_deg, az_deg, mag) in chart_stars:
if alt_deg < 0:
continue
# r = 0 at zenith (alt=90°), r = 1 at horizon (alt=0°)
r = 1.0 - alt_deg / 90.0
az_rad = math.radians(az_deg)
# Flip x for astronomical convention
x = -r * math.sin(az_rad)
y = r * math.cos(az_rad)
# Star size: brighter = larger dot (reduced to ~50% of original)
# Magnitude scale: mag 1 -> size ~27, mag 5 -> size ~4
size = max(2, 45 - 9 * mag)
# Color by magnitude
if mag < 1.0:
color = '#ffffff'
elif mag < 2.0:
color = '#ffffee'
elif mag < 3.0:
color = '#eeeedd'
else:
color = '#ccccbb'
ax.scatter([x], [y], s=size, c=color, zorder=4, edgecolors='none')
# Labels: only above horizon, avoid crowding
if alt_deg > 5.0:
offset_x = 0.015 if x >= 0 else -0.015
ha = 'left' if x >= 0 else 'right'
ax.text(x + offset_x, y + 0.015, name,
color='#ffee88', fontsize=5.5,
ha=ha, va='bottom', zorder=5,
fontfamily='monospace')
# --- Constellation lines ---
# Build a lookup from star name to (x, y) chart position for visible stars
star_xy = {}
for (name, alt_deg, az_deg, mag) in chart_stars:
if alt_deg < 0:
continue
r = 1.0 - alt_deg / 90.0
az_rad = math.radians(az_deg)
sx = -r * math.sin(az_rad)
sy = r * math.cos(az_rad)
star_xy[name] = (sx, sy)
# Extra (non-navigational) stars needed for constellation lines
# These are not in the 57-star NAV_STARS list
extra_stars_hip = {
"Mimosa": 62434, # Beta Crucis (Crux short arm)
"Delta Crucis": 59747, # Delta Crucis (Crux short arm)
"Alnitak": 26727, # Zeta Orionis (belt)
"Mintaka": 25930, # Delta Orionis (belt)
"Saiph": 27366, # Kappa Orionis (foot)
"Sargas": 86228, # Theta Scorpii (curve)
}
# Compute alt/az for extra stars and add them to star_xy if above horizon
if hip_df is not None and StarClass is not None:
for extra_name, extra_hip in extra_stars_hip.items():
if extra_name in star_xy:
continue # Already present from nav star list
if extra_hip not in hip_df.index:
continue
extra_star_obj = StarClass.from_dataframe(hip_df.loc[extra_hip:extra_hip])
astrometric = (earth + observer).at(t).observe(extra_star_obj)
alt_e, az_e, _ = astrometric.apparent().altaz()
alt_val = _scalar(alt_e.degrees)
az_val = _scalar(az_e.degrees)
if alt_val > 0:
r = 1.0 - alt_val / 90.0
az_rad = math.radians(az_val)
star_xy[extra_name] = (-r * math.sin(az_rad), r * math.cos(az_rad))
# Define constellation line segments: (star_a, star_b)
constellation_lines = [
# Southern Cross (Crux)
("Acrux", "Gacrux"), # long arm
("Mimosa", "Delta Crucis"), # short arm
# Orion
("Betelgeuse", "Bellatrix"), # shoulders
("Rigel", "Saiph"), # feet
("Alnilam", "Alnitak"), # belt segment 1
("Alnilam", "Mintaka"), # belt segment 2
# Scorpius
("Antares", "Sargas"), # head through curve
("Sargas", "Shaula"), # curve to stinger
# Centaurus pointer line
("Rigil Kentaurus", "Hadar"), # Alpha Cen to Beta Cen
]
# Draw lines only when both endpoint stars are visible (above horizon)
for (star_a, star_b) in constellation_lines:
if star_a in star_xy and star_b in star_xy:
xa, ya = star_xy[star_a]
xb, yb = star_xy[star_b]
ax.plot([xa, xb], [ya, yb],
color='white', alpha=0.4, linewidth=0.8, zorder=3)
# Zenith marker
ax.scatter([0], [0], s=20, c='#8888ff', zorder=6, marker='+')
ax.text(0, 0.04, "Zenith", color='#8888ff', fontsize=6, ha='center', va='bottom')
# Title
ax.set_title(
f"Evening Sky from Wellington — {title_month}\n"
f"21:00 {tz_name} · Observer: 41.3°S, 174.8°E",
color='white', fontsize=11, pad=14,
fontweight='bold'
)
# Legend for altitude rings
legend_elements = [
mpatches.Patch(facecolor='#090920', edgecolor='#4488cc',
label='Horizon (0°)'),
mpatches.Patch(facecolor='#090920', edgecolor='#223355',
linestyle='--', label='30° / 60° altitude'),
]
ax.legend(handles=legend_elements, loc='lower right',
facecolor='#0d1530', edgecolor='#334466',
labelcolor='#aabbcc', fontsize=7, framealpha=0.8)
# Attribution footer
fig.text(0.5, 0.01,
"Star positions: Hipparcos catalogue via Skyfield. "
"Ephemeris: JPL DE421. Computed for epoch 2026.0.",
ha="center", fontsize=6, color="#7799bb", style="italic")
os.makedirs(os.path.dirname(output_path), exist_ok=True)
fig.savefig(output_path, dpi=200, bbox_inches='tight',
facecolor='#090920', edgecolor='none')
plt.close(fig)
print(f" Saved: {output_path}")
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
script_dir = os.path.dirname(os.path.abspath(__file__))
md_out = os.path.join(script_dir, "..", "tables-stars.md")
img_dir = os.path.join(script_dir, "..", "site", "images")
print("Loading Skyfield libraries and ephemeris...")
ts, earth, hip_df, Star, wgs84 = load_skyfield()
print("Building star objects from Hipparcos catalog...")
star_list = build_star_objects(hip_df, Star)
print(f" Loaded {len(star_list)} of {len(NAV_STARS)} stars")
# --- Section 1: SHA/Dec catalog ---
print("Computing SHA and Declination for epoch 2026.0...")
catalog_table, sorted_rows = build_catalog_table(ts, earth, star_list)
# --- Section 2: Monthly visibility ---
print("Computing monthly visibility (21:00 local, Wellington)...")
monthly_vis = compute_monthly_visibility(ts, earth, star_list)
visibility_md = build_visibility_table(monthly_vis)
# --- Section 3: Rise/transit/set for 10 brightest ---
print("Computing rise/transit/set times for 10 brightest stars...")
rts_data, bright_names = compute_rise_transit_set(ts, earth, star_list)
rts_md = build_rise_transit_set_table(rts_data, bright_names)
# --- Assemble markdown ---
now_str = datetime.now().strftime("%Y-%m-%d")
content = f"""---
title: "Navigational Star Catalog and Charts"
subtitle: "Recovery Library — Computed Reference Data"
date: "{now_str}"
---
# Navigational Star Catalog and Charts
*Recovery Library — Doc #015: Star Atlas*
**Computation method:** All values computed using the [Skyfield](https://rhodesmill.org/skyfield/) astronomy library with the JPL DE421 planetary ephemeris and the Hipparcos star catalog (ESA, 1997). SHA and Declination are computed for epoch 2026.0. Rise/transit/set times and visibility tables are computed for Wellington, New Zealand (41.3°S, 174.8°E).
**SHA definition:** Sidereal Hour Angle (SHA) = 360° − Right Ascension (in degrees). This is the standard convention used in the Nautical Almanac. SHA increases westward from the vernal equinox.
**Accuracy:** Star positions are accurate to better than 1 arcminute for 2026, well within sextant observation precision. Proper motion corrections are applied via the Hipparcos catalog data.
**Verification:** Canopus (α Carinae) should show approximately S52° 42' declination. Sirius (α CMa) should show approximately S16° 43' declination.
**Observer location for visibility and rise/set tables:** Wellington, New Zealand — latitude 41.3°S, longitude 174.8°E, elevation 25 m. Times use NZDT (UTC+13) for October–March and NZST (UTC+12) for April–September.
---
## Seasonal Sky Charts
These charts show the evening sky (21:00 local time) from Wellington for the 15th of a representative month in each season. N is up, E is left (the standard orientation when facing south in the Southern Hemisphere, or equivalently the orientation of the sky above you when lying on your back with head pointing north).
Altitude rings are shown at 30° and 60°. The outer circle is the horizon.
| Season | Chart |
|--------|-------|
| Summer (January) |  |
| Autumn (April) |  |
| Winter (July) |  |
| Spring (October) |  |
---
## Part 1: The 57 Navigational Stars — SHA and Declination, Epoch 2026.0
Stars are sorted by SHA (increasing westward from the vernal equinox). This matches the ordering used in the Nautical Almanac.
{catalog_table}
---
## Part 2: Monthly Evening Visibility from Wellington (41.3°S)
Stars visible above 10° altitude at 21:00 local time on the 15th of each month.
Stars below 10° altitude are subject to significant atmospheric refraction and difficult to
observe reliably with a sextant. Stars below the horizon are omitted.
{visibility_md}
---
## Part 3: Rise, Transit, and Set Times — 10 Brightest Navigational Stars
Times are local Wellington time (NZDT UTC+13 for Oct–Mar; NZST UTC+12 for Apr–Sep),
computed for the 15th of each month 2026. "—" indicates the star does not rise or set
on that date (circumpolar or below horizon all day). "circ" indicates a circumpolar star.
Transit time is the moment of maximum altitude (meridian passage), computed by sampling
altitude at 10-minute intervals and finding the peak. Times are accurate to approximately
±10 minutes by this method.
{rts_md}
---
## Notes on Use for Celestial Navigation
**Selecting stars for a fix:** Ideally choose 3 stars with azimuths separated by approximately
120°, all at altitudes between 15° and 70°. Stars below 15° suffer from uncertain atmospheric
refraction; stars above 70° are difficult to observe accurately with a marine sextant.
**SHA and GHA relationship:** GHA of a star = GHA of Aries + SHA of star (mod 360°).
GHA of Aries can be read from the nautical almanac or computed from sidereal time.
**Magnitude and naked-eye visibility:** Stars of magnitude 1.0 or brighter are easily visible
under urban skies. Stars to magnitude 3.0 are visible under suburban conditions. All 57
navigational stars are visible to the naked eye under reasonably dark skies.
**Southern Hemisphere considerations:** From Wellington at 41.3°S, stars with declination
south of about −48.7° are circumpolar (never set). Stars with declination north of about
+48.7° never rise. The north celestial pole (near Polaris) is permanently below the horizon;
there is no bright southern pole star — sigma Octantis (mag 5.4) is too faint for reliable
naked-eye observation.
---
*Generated by `scripts/generate_star_catalog.py` using Skyfield with JPL DE421 ephemeris
and the Hipparcos star catalog. Run date: {now_str}.*
"""
os.makedirs(os.path.dirname(md_out), exist_ok=True)
with open(md_out, 'w') as f:
f.write(content)
print(f"\nMarkdown written to: {md_out}")
print(f" Lines: {content.count(chr(10))}")
# --- Generate star chart PNGs ---
print("\nGenerating seasonal star charts...")
os.makedirs(img_dir, exist_ok=True)
for (tag, yr, mo, dy, title_mo, tz_name) in CHART_DATES:
print(f" Rendering {tag} chart...")
out_png = os.path.join(img_dir, f"stars-{tag}.png")
make_star_chart(ts, earth, star_list,
tag, yr, mo, dy, title_mo, tz_name, out_png,
hip_df=hip_df, StarClass=Star)
print("\nDone.")
print(f"Markdown: {os.path.abspath(md_out)}")
print(f"Images: {os.path.abspath(img_dir)}/stars-{{january,april,july,october}}.png")
if __name__ == "__main__":
main()Data Sources
- JPL DE421 planetary ephemeris — NASA Jet Propulsion Laboratory. Downloaded automatically by Skyfield on first run (~17 MB).
- Hipparcos star catalog — European Space Agency, 1997. ESA SP-1200. Downloaded automatically by Skyfield on first run (~8 MB). HIP IDs used to identify the 57 Nautical Almanac navigational stars.
- Nautical Almanac star list — The 57 selected navigational stars follow the standard list used in the Nautical Almanac (US Naval Observatory / UK Hydrographic Office). Star names and Bayer designations are per that standard.
- Observer coordinates — Wellington, New Zealand: 41.3°S, 174.8°E, elevation 25 m. New Zealand timezone rules: NZDT (UTC+13) October–March; NZST (UTC+12) April–September.