Offline sky engine for the Salish Sea and everywhere else: Sun and Moon positions, rise, set, twilight, Moon phase, lunar 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 packageAlmanactypescript/— npm@openwaters/almanacfixtures/— 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/ROADMAP.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.
Almanac includes a shared performance harness for both ports: 15 workloads cover positions, a 228-hour sky track, short/year/polar event windows, full-range moon phases, and next/previous/range eclipse searches, including empty windows.
The v0.2.1 optimizations reuse Moon and shadow geometry, flatten scratch arrays, and refine altitude crossings with fewer position evaluations. In the hosted CI comparison, median query time fell relative to the v0.2.0 astronomy code:
| Workload | TypeScript: less time | Swift: less time |
|---|---|---|
| Moon positions / 1,024 hours | 29.8% | 22.1% |
| Sky track / 228 hours | 40.0% | 42.4% |
| Sun events / 228 hours | 39.3% | 40.2% |
| Moon events / 228 hours | 39.1% | 35.1% |
| Previous lunar eclipse | 26.0% | 36.6% |
| Lunar eclipses / 1950–2100 | 26.4% | 23.4% |
TypeScript was measured on Ubuntu; Swift used release builds on macOS. 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.2.0CI 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.
npm install @openwaters/almanacimport { nextLunarEclipse, lunarEclipseVisibility, 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 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);.package(url: "https://github.com/openwatersio/almanac.git", exact: "0.3.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 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)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.