A single, zero-dependency Go package for astronomical calculations — sunrise/sunset, moonrise/moonset, twilight, and lunar phase — based on Meeus's Astronomical Algorithms.
go get github.com/philoserf/dusk/v3A complete program showing error handling and formatted output:
package main
import (
"errors"
"fmt"
"log"
"time"
"github.com/philoserf/dusk/v3"
)
func main() {
loc, err := time.LoadLocation("America/Chicago")
if err != nil {
log.Fatal(err)
}
obs, err := dusk.NewObserver(42.9634, -85.6681, loc)
if err != nil {
log.Fatal(err)
}
date := time.Date(2025, 6, 21, 0, 0, 0, 0, time.UTC)
sun, err := dusk.SunriseSunset(date, obs)
if err != nil {
if errors.Is(err, dusk.ErrCircumpolar) {
fmt.Println("Midnight sun — the sun does not set today.")
return
}
if errors.Is(err, dusk.ErrNeverRises) {
fmt.Println("Polar night — the sun does not rise today.")
return
}
log.Fatal(err)
}
fmt.Printf("Sunrise: %s\n", sun.Rise.Format(time.Kitchen))
fmt.Printf("Noon: %s\n", sun.Noon.Format(time.Kitchen))
fmt.Printf("Sunset: %s\n", sun.Set.Format(time.Kitchen))
fmt.Printf("Daylight: %s\n", sun.Duration)
}The Moon may not rise or set on a given day. Use IsZero() to check, and AboveHorizon to determine whether the Moon was up at the start of the day:
moon, err := dusk.MoonriseMoonset(date, obs)
if err != nil {
log.Fatal(err)
}
switch {
case moon.Rise.IsZero() && moon.Set.IsZero():
if moon.AboveHorizon {
fmt.Println("Moon is above the horizon all day.")
} else {
fmt.Println("Moon is below the horizon all day.")
}
case moon.Rise.IsZero():
fmt.Println("Moon was already up at midnight.")
fmt.Printf("Moonset: %s\n", moon.Set.Format(time.Kitchen))
case moon.Set.IsZero():
fmt.Printf("Moonrise: %s\n", moon.Rise.Format(time.Kitchen))
fmt.Println("Moon stays up past midnight.")
default:
fmt.Printf("Moonrise: %s\n", moon.Rise.Format(time.Kitchen))
fmt.Printf("Moonset: %s\n", moon.Set.Format(time.Kitchen))
}All result types implement fmt.Stringer. Printing a LunarPhaseInfo value directly produces output like Waxing Gibbous 67.3% (day 10.1):
phase, err := dusk.LunarPhase(time.Date(2024, 1, 18, 3, 0, 0, 0, time.UTC))
if err != nil {
log.Fatal(err)
}
fmt.Println(phase) // e.g., "Waxing Gibbous 67.3% (day 10.1)"
fmt.Printf("Illumination: %.1f%% Waxing: %t\n", phase.Illumination, phase.Waxing)Twilight functions return tonight's Dusk and tomorrow morning's Dawn. To get this morning's dawn, call with yesterday's date:
loc, err := time.LoadLocation("America/Los_Angeles")
if err != nil {
log.Fatal(err)
}
obs, err := dusk.NewObserver(47.6062, -122.3321, loc)
if err != nil {
log.Fatal(err)
}
date := time.Date(2025, 6, 21, 0, 0, 0, 0, time.UTC)
tw, err := dusk.CivilTwilight(date, obs)
if err != nil {
log.Fatal(err)
}
fmt.Printf("Dusk: %s\n", tw.Dusk.Format(time.Kitchen))
fmt.Printf("Dawn: %s\n", tw.Dawn.Format(time.Kitchen))
fmt.Printf("Night duration: %s\n", tw.NightDuration)NauticalTwilight and AstronomicalTwilight follow the same signature.
At extreme latitudes, sunrise/sunset and twilight may be geometrically impossible. Use errors.Is to match the sentinel errors:
loc, err := time.LoadLocation("Arctic/Longyearbyen")
if err != nil {
log.Fatal(err)
}
obs, err := dusk.NewObserver(78.2, 15.6, loc) // Svalbard
if err != nil {
log.Fatal(err)
}
midsummer := time.Date(2025, 6, 21, 0, 0, 0, 0, time.UTC)
_, err = dusk.SunriseSunset(midsummer, obs)
if errors.Is(err, dusk.ErrCircumpolar) {
fmt.Println("Midnight sun — no sunset at this latitude today.")
}
if errors.Is(err, dusk.ErrNeverRises) {
fmt.Println("Polar night — no sunrise at this latitude today.")
}SunriseSunset(date, obs)— sunrise, solar noon, sunset, and daylight duration
MoonriseMoonset(date, obs)— moonrise/moonset times and whether the Moon was above the horizon at the start of the dayLunarPhase(date)— illumination, elongation, approximate age, waxing/waning, phase angle, and name
CivilTwilight(date, obs)— sun 6 degrees below horizonNauticalTwilight(date, obs)— sun 12 degrees below horizonAstronomicalTwilight(date, obs)— sun 18 degrees below horizon
NewObserver(lat, lon, loc)— create a validated observer from latitude, longitude, and timezone
All result types implement fmt.Stringer:
SunEvent—Rise,Noon,Settimes andDuration(daylight)MoonEvent—Rise,Settimes andAboveHorizonTwilightEvent—Dusk,Dawntimes andNightDuration(overnight darkness)LunarPhaseInfo—Illumination,Elongation,Angle,DaysApprox,Waxing,Name
ErrCircumpolar— object always above the horizon (e.g., midnight sun)ErrNeverRises— object never rises (e.g., polar night)ErrNilLocation— nil timezone passed toNewObserverErrNonFiniteCoord— NaN or Inf coordinatesErrInvalidCoord— latitude or longitude out of rangeErrDateOutOfRange— date outside supported Julian date range (~1677–2262)
- All angles are in degrees.
- Longitude is east-positive, west-negative (e.g., New York is -74.006).
Observeris constructed viaNewObserver, which validates coordinates and rejects NaN/Inf.- Functions that can fail return
error. Two sentinel errors distinguish polar edge cases:ErrCircumpolarandErrNeverRises. - A zero-value
time.Timesignals "event did not occur" (e.g., the Moon does not rise on a given day). Check with.IsZero(). - Twilight functions return tonight's Dusk and tomorrow morning's Dawn. To get this morning's dawn, call with yesterday's date.
Sunrise/sunset times are typically within 1-2 minutes of USNO data. Moonrise/moonset uses a simplified Meeus approach with a minute-by-minute altitude scan and can differ from USNO by up to ~20 minutes. Lunar phase illumination is within 1-2% of published values. Lunar ecliptic position uses the full Meeus Chapter 47 periodic terms (100+ coefficients).
Go 1.24+. Zero dependencies.
GPL-3.0. See LICENSE.
Originally created by observerly. This fork includes bug fixes, algorithm improvements, and a complete rewrite.