Skip to content
openwatersioPublic

About

Offline sun & moon engine — positions, rise/set, moon phase, eclipses. Twin Swift + TypeScript ports, one fixture corpus.

Topics

Resources

Contributing

Stars

3 stars

Watchers

1 watching

Forks

Repository files navigation

Almanac

Offline sky engine for the Salish Sea and everywhere else: Sun, Moon, and planet positions, apparent horizon dip, rise, set, twilight, Moon phase, planetary brightness, lunar and solar eclipses, and fixed-star altitude and azimuth from catalog positions, computed from pure geometry with zero network and zero runtime data files.

Twin implementations, one behavior:

  • swift/ — SwiftPM package Almanac
  • typescript/ — npm @openwaters/almanac
  • fixtures/ — the shared test corpus (JPL Horizons, USNO, Espenak) both suites must pass; the contract that keeps the ports identical

Supported interval: 1950-01-01T00:00Z ≤ t < 2101-01-01T00:00Z; results outside it raise a typed error. All instants are UT1-accurate, while unknown future DUT1 is outside the civil-UTC accuracy promise. See the public contract for the time model.

  • Contract: docs/CONTRACT.md, including coordinates, public behavior, accuracy, and fixture evidence.
  • Scope: docs/SCOPE.md, including supported behavior and deliberate boundaries.
  • Development and releases: CONTRIBUTING.md, including pinned tools and required checks.
  • Landing page: openwaters.io/sky, with the library running live in a browser.

Algorithms translated from Astronomy Engine (MIT, Don Cross) — see NOTICE. MIT licensed.

Performance

Almanac includes a shared performance harness for both ports: 30 workloads cover positions, a 228-hour sky track, short/year/polar event windows, full-range moon phases, next/previous/range lunar, solar, and global solar eclipse searches, including empty windows, solar obscuration over a 228-hour track, and the central line of the 2024-04-08 eclipse, five-planet sky tracks, and planetary rise/set.

Event searches find altitude extrema with Brent's method and altitude crossings with a cosine-seeded secant solver, each root proven inside a half-second bracket, and the TypeScript Sun series evaluates its terms as straight-line arithmetic. Median query time relative to v0.4.1:

Workload TypeScript: less time Swift: less time
Sun events / year 73.4% 62.8%
Moon events / year 57.6% 57.4%
Sun events / 228 hours 73.4% 61.9%
Moon events / 228 hours 57.1% 57.0%
Sun positions / 1,024 hours 35.5% —
Lunar eclipses / 1950–2100 24.6% —

Swift already evaluated the Sun series efficiently, so its gains are confined to the searches. Each comparison builds both revisions with the same harness and toolchain, then takes seven interleaved process pairs with 300 ms warmup per process. Build and startup time are excluded. Timings vary by machine; the shared correctness fixtures and parity tolerances remain the accuracy gates.

Reproduce the comparison locally from the repository root with mise 2026.9.1 or newer after installing the configured Node and Swift versions in mise.toml:

mise install
mise exec -- npm ci --prefix typescript
mise exec -- node benchmarks/run.mjs --base v0.4.1 --skip '^(global|planets)/'

The --skip pattern leaves out global eclipse and planetary workloads absent from that base. Use --skip '^(solar|global|planets)/' when the base predates 0.4.0, which is when the solar eclipse workloads arrived. For a newer base that lacks only planets, use --skip '^planets/'.

CI runs the harness on code changes and fails on median regressions over 20%. Results include timing tables, raw samples, checksums, and revision/toolchain metadata. See the harness guide for choosing a baseline, running one port, and inspecting reports.

Event searches scale with window length. Run a full 151-year sweep in a worker or background task; use shorter windows for interactive queries.

Usage

TypeScript

npm install @openwaters/almanac
import {
  nextLunarEclipse, lunarEclipseVisibility, nextSolarEclipse, solarObscuration, nextGlobalSolarEclipse, sunEvents, starAltAz
} from '@openwaters/almanac';

const observer = { latitudeDeg: 48.5, longitudeDeg: -123.0 };

const eclipse = nextLunarEclipse(new Date());
const visibility = lunarEclipseVisibility(eclipse, observer);
console.log(eclipse.kind, eclipse.peak, visibility.visibleAtPeak);

const solar = nextSolarEclipse(new Date(), observer);
console.log(solar.kind, solar.peak, solar.obscuration, solar.sunAltDeg.peak);
console.log(solarObscuration(solar.peak, observer));   // fraction of the Sun's disc covered, 0 to 1

const anywhere = nextGlobalSolarEclipse(new Date());   // no observer: the next solar eclipse on Earth
console.log(anywhere.kind, anywhere.peak, anywhere.latitudeDeg, anywhere.longitudeDeg);   // where the shadow axis lands; null for a partial

const today = new Date();
const tomorrow = new Date(today.getTime() + 24 * 60 * 60 * 1000);
for (const { kind, time } of sunEvents(today, tomorrow, observer)) {
  console.log(kind, time.toISOString());
}

// Betelgeuse from its J2000 catalog position: az/alt in degrees, refracted.
const { azDeg, altDeg } = starAltAz(88.792939, 7.407064, today, observer);

For an unobstructed sea horizon, supply eye height above the water separately from elevation above sea level. Body altitudes remain measured from the horizontal plane:

import { horizonDip, sunAltAz } from '@openwaters/almanac';
const eyeHeightM = 2;
const viewer = { latitudeDeg: 48.5, longitudeDeg: -123.0, elevationM: 2 };
const horizonAltDeg = horizonDip(viewer, eyeHeightM);
const sunAltitudeAboveHorizonDeg = sunAltAz(new Date(), viewer).altDeg - horizonAltDeg;
const crossings = sunEvents(today, tomorrow, viewer, eyeHeightM);

Swift

.package(url: "https://github.com/openwatersio/almanac.git", exact: "0.7.0")
import Almanac
import Foundation

let observer = try Observer(latitudeDeg: 48.5, longitudeDeg: -123.0)

let eclipse = try nextLunarEclipse(after: Date())
let visibility = try lunarEclipseVisibility(eclipse, observer: observer)
print(eclipse.kind, eclipse.peak, visibility.visibleAtPeak)

let solar = try nextSolarEclipse(after: Date(), observer: observer)
print(solar.kind, solar.peak, solar.obscuration, solar.sunAltDeg.peak)
print(try solarObscuration(at: solar.peak, observer: observer))   // fraction of the Sun's disc covered, 0 to 1

let anywhere = try nextGlobalSolarEclipse(after: Date())   // no observer: the next solar eclipse on Earth
print(anywhere.kind, anywhere.peak, anywhere.axisDistanceKm)
if let lat = anywhere.latitudeDeg, let lon = anywhere.longitudeDeg {
  print(lat, lon)   // where the shadow axis lands; nil for a partial
}

let today = Date()
let tomorrow = today.addingTimeInterval(24 * 60 * 60)
for event in try sunEvents(from: today, to: tomorrow, observer: observer) {
  print(event.kind, event.time)
}

// Betelgeuse from its J2000 catalog position: az/alt in degrees, refracted.
let star = try starAltAz(raDeg: 88.792939, decDeg: 7.407064, at: today, observer: observer)

Planetary views

Mercury, Venus, Earth, Mars, Jupiter, Saturn, Uranus, and Neptune have geometric Sun-centered positions in AU, in fixed J2000 equatorial coordinates. Place the Sun at (0, 0, 0) and orient the scene's camera for the desired view:

import { planetHeliocentricPosition, planetAltAz, planetIllumination, planetEvents,
  horizonDip, sunAltAz } from '@openwaters/almanac';
import type { Planet } from '@openwaters/almanac';

const planets: Planet[] = ['mercury', 'venus', 'earth', 'mars', 'jupiter', 'saturn', 'uranus', 'neptune'];
const now = new Date();
const positions = planets.map(planet => ({ planet, ...planetHeliocentricPosition(planet, now) }));

const viewer = { latitudeDeg: 48.5, longitudeDeg: -123.0, elevationM: 2 };
const horizonAltDeg = horizonDip(viewer, 2);
const venus = planetAltAz('venus', now, viewer);
const brightness = planetIllumination('venus', now);
console.log(venus.azDeg, venus.altDeg - horizonAltDeg, sunAltAz(now, viewer).altDeg,
  brightness.magnitude, brightness.elongationDeg);
const crossings = planetEvents('venus', now, new Date(now.getTime() + 86400000), viewer, 2);
let now = Date()
let positions = try Planet.allCases.map { try planetHeliocentricPosition($0, at: now) }
let viewer = try Observer(latitudeDeg: 48.5, longitudeDeg: -123.0, elevationM: 2)
let horizon = try horizonDip(observer: viewer, heightAboveGroundM: 2)
let venus = try planetAltAz(.venus, at: now, observer: viewer)
let brightness = try planetIllumination(.venus, at: now)
print(venus.azDeg, venus.altDeg - horizon, brightness.magnitude, brightness.elongationDeg)
let crossings = try planetEvents(.venus, from: now, to: now.addingTimeInterval(86400),
  observer: viewer, heightAboveGroundM: 2)

Earth is included for the solar-system view. Earth-based position, altitude/azimuth, illumination, and rise/set reject Earth. Sky coordinates include light-time and apparent corrections; heliocentric scene coordinates do not.

A viewing-window policy can combine planet altitude above the horizon, Sun altitude, elongation, and brightness. Actual visibility also depends on weather and observing conditions. Magnitude is approximate: the pinned model and Horizons differ by up to 0.75 magnitudes for extreme Venus crescents in the fixture interval.

Eclipse searches

Search for a previous eclipse or all eclipses in a time window:

import { previousLunarEclipse, lunarEclipses } from '@openwaters/almanac';
const last = previousLunarEclipse(new Date());
const eclipses = lunarEclipses(new Date('2026-08-24T00:00:00Z'), new Date('2026-09-02T12:00:00Z'));
let last = try previousLunarEclipse(before: Date())
let eclipses = try lunarEclipses(from: today, to: tomorrow)

Ranges include peaks at the start and exclude peaks at the end. Contacts may extend outside the range. Previous/next searches skip peaks within 100 ms of the anchor. Search results are global; apply lunarEclipseVisibility for an observer.

Solar eclipses are searched for an observer, because their contacts only exist for a place: nextSolarEclipse(after, observer), previousSolarEclipse(before, observer), and solarEclipses(startUtc, endUtc, observer). An eclipse whose Sun is below the horizon at C1, the peak, and C4 is not returned.

To ask about the whole Earth instead, nextGlobalSolarEclipse(after), previousGlobalSolarEclipse(before), and globalSolarEclipses(startUtc, endUtc) need no observer. Each eclipse reports its greatest eclipse, the shadow axis's distance from the Earth's center, and where the axis meets the ground, with the kind and obscuration seen there. When the axis misses the Earth the eclipse is partial and has no ground point, and greatest eclipse falls on the Earth's limb nearest the axis, with the Sun on the horizon: greatestLatitudeDeg, greatestLongitudeDeg, and greatestObscuration give that place for every eclipse.

solarEclipseCentralLine(peak, stepSeconds) samples where the axis meets the ground from its first contact to its last, and solarEclipseAxisPoint(time) gives that point at any instant. How far the next totality passes from a place is a distance to each point of a line:

import { nextGlobalSolarEclipse, solarEclipseCentralLine } from '@openwaters/almanac';
let next = nextGlobalSolarEclipse(new Date());
while (next.kind !== 'total') next = nextGlobalSolarEclipse(next.peak);
const line = solarEclipseCentralLine(next.peak);   // every whole minute of the path, and its two ends
var next = try nextGlobalSolarEclipse(after: Date())
while next.kind != .total { next = try nextGlobalSolarEclipse(after: next.peak) }
let line = try solarEclipseCentralLine(peak: next.peak)   // every whole minute of the path, and its two ends

Points are a median 47 km apart at the default step but hundreds apart beside an end, where the shadow races along the horizon; pass a smaller stepSeconds for a finer line.

About

Offline sun & moon engine — positions, rise/set, moon phase, eclipses. Twin Swift + TypeScript ports, one fixture corpus.

Topics

Resources

Contributing

Stars

3 stars

Watchers

1 watching

Forks

Releases

Contributors

Languages