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/v5A reference implementation lives in cmd/dusk. It calls every exported function and
renders the day as one chronological list, so each documented edge case is reachable
from the command line.
The library returns its results grouped by the call that produced them — sun, three twilight bands, moon — but a day is not lived in that order. Sorted by the clock, a twilight table's dawn column stops running backwards, and a moonset belonging to the previous night's rise stops appearing above the moonrise it precedes.
go install github.com/philoserf/dusk/v5/cmd/dusk@latest
dusk --lat 42.9634 --lon -85.6681 --tz America/Detroit --date 2025-06-21
dusk --lat 69.6492 --lon 18.9553 --tz Europe/Oslo --date 2025-12-21 # polar night
dusk --lat 69.6492 --lon 18.9553 --tz Europe/Oslo --date 2025-06-21 # midnight sun
dusk --lat -1.2921 --lon 36.8219 --tz Africa/Nairobi --json
dusk --version--lat, --lon, and --tz are all required: a latitude of 0 is the equator rather
than "unset", and the zone is needed to place the day's events on the clock. --date is
a calendar day and defaults to today in that zone.
Sunday 21 December 2025
69.6492°N 18.9553°E · Europe/Oslo
The sun does not rise today (polar night). Twilight still
reaches civil depth around midday.
The moon neither rises nor sets today.
06:28 Astronomical dawn
07:46 Nautical dawn
09:31 Civil dawn
13:53 Civil dusk
15:37 Nautical dusk
16:56 Astronomical dusk
Dark 13h33m (astronomical, tonight)
Moon New Moon, 2%
Polar geometry is a result, not a failure: the report renders and exits 0. A misuse of
the flags and a date outside the library's range both exit 1, told apart by the message
rather than the status (usage: versus unsupported date:).
A complete program showing error handling and formatted output:
package main
import (
"fmt"
"log"
"time"
"github.com/philoserf/dusk/v5"
)
func main() {
// Grand Rapids, Michigan, which keeps Eastern time.
loc, err := time.LoadLocation("America/Detroit")
if err != nil {
log.Fatal(err)
}
obs, err := dusk.NewObserver(42.9634, -85.6681, loc)
if err != nil {
log.Fatal(err)
}
date := dusk.Date{Year: 2025, Month: time.June, Day: 21}
sun, err := dusk.SunriseSunset(date, obs)
if err != nil {
log.Fatal(err)
}
switch sun.Horizon {
case dusk.StaysAbove:
fmt.Println("Midnight sun — the sun does not set today.")
case dusk.StaysBelow:
fmt.Println("Polar night — the sun does not rise today.")
case dusk.Crosses:
}
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))
}phase, err := dusk.LunarPhase(time.Date(2024, 1, 18, 3, 0, 0, 0, time.UTC))
if err != nil {
log.Fatal(err)
}
fmt.Printf("%s — illumination %.1f%%, waxing: %t\n", phase.Name, phase.Illumination, phase.Elongation < 180)Twilight takes the depression angle as a parameter: 6 degrees for civil, 12 for nautical, 18 for astronomical. Dawn and Dusk are both on the day you asked for, the same day SunriseSunset reports:
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 := dusk.Date{Year: 2025, Month: time.June, Day: 21}
tw, err := dusk.Twilight(date, obs, 6)
if err != nil {
log.Fatal(err)
}
fmt.Printf("Dawn: %s\n", tw.Dawn.Format(time.Kitchen))
fmt.Printf("Dusk: %s\n", tw.Dusk.Format(time.Kitchen))The two boundaries are symmetric about solar transit, so either both exist or neither does — a polar day or night returns an error for the whole band rather than half a result.
Overnight darkness spans two days, so it is not a field on the result. Subtract tonight's dusk from tomorrow's dawn:
next, err := dusk.Twilight(date.AddDate(0, 0, 1), obs, 18)
if err != nil {
log.Fatal(err)
}
fmt.Printf("Astronomical night: %s\n", next.Dawn.Sub(tw.Dusk))At extreme latitudes the Sun may never rise or never set. That is an answer, not a failure — read it off Horizon and keep the rest of the result:
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 := dusk.Date{Year: 2025, Month: time.June, Day: 21}
sun, err := dusk.SunriseSunset(midsummer, obs)
if err != nil {
log.Fatal(err)
}
switch sun.Horizon {
case dusk.StaysAbove:
fmt.Println("Midnight sun — no sunset at this latitude today.")
case dusk.StaysBelow:
fmt.Println("Polar night — no sunrise at this latitude today.")
case dusk.Crosses:
}
// Solar noon is reported on every day at every latitude, polar ones included.
fmt.Printf("Solar noon: %s\n", sun.Noon.Format("15:04"))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, and phase name
Twilight(date, obs, depression)— both boundaries of one band on one day. Pass 6 for civil, 12 for nautical, 18 for astronomical (the IAU/USNO values), or any angle you need
NewObserver(lat, lon, loc)— create a validated observer from latitude, longitude, and timezone
Plain data; format them however you need. Observer implements fmt.Stringer, the result types do not.
SunEvent—Rise,Noon,Settimes andDuration(daylight)MoonEvent—Rise,Settimes andAboveHorizonTwilightEvent—DawnandDusktimes, both on the queried dayLunarPhaseInfo—Illumination,Elongation,Name
LunarPhaseInfo has been reduced twice, both times for publishing a value that was a
restatement of one already in the struct. Each removal is one expression to undo:
| removed in | field | recover with |
|---|---|---|
| v4.0.0 | Angle (Meeus phase angle) |
acos(2*Illumination/100 - 1), negated when Elongation >= 180 |
| v5.0.0 | Waxing |
Elongation < 180 |
| v5.0.0 | DaysApprox |
Elongation / 360 * 29.53059 |
DaysApprox is the one to think twice about before recovering: elongation does not advance
linearly in time, so the result is not the lunation age the old name promised. Writing the
expression is a choice to accept that; reading a field called DaysApprox was not.
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.
- The day-based entry points take a
Date— year, month, day, no zone and no time of day. Until v5.0.0 they took atime.Timeand kept only the calendar day as resolved in the observer's zone, which silently selected the previous day for anyone who built the date intime.UTC. Convert an instant withdusk.DateIn(t, loc), which is the call the library used to make invisibly. LunarPhaseis the exception and takes an instant, because phase is Sun-Earth-Moon geometry and changes measurably within a day. The package having two entry-point shapes is deliberate: it answers two kinds of question.- Longitude is east-positive, west-negative (e.g., New York is -74.006).
Observeris constructed viaNewObserver, which validates coordinates and rejects NaN/Inf.- Polar geometry is a result, not an error:
SunEvent.HorizonandTwilightEvent.HorizonreportCrosses,StaysAboveorStaysBelow, withRise/Set/Dawn/Duskzero when there was no crossing.errormeans a nil timezone, bad coordinates or a date out of range. - A zero-value
time.Timesignals "event did not occur" (e.g., the Moon does not rise on a given day). Check with.IsZero(). Twilightreturns both boundaries of one band on the queried day, symmetric about solar transit. Overnight darkness is tonight's dusk to tomorrow's dawn — two calls, because it spans two days.
Sunrise and sunset agree with USNO to within about two minutes. Measured against published USNO values across twelve place/date pairs from the equator to 68°N, the largest disagreement is 115 seconds, on sunset near an equinox at high latitude. The two boundaries are solved independently rather than mirrored about solar noon, which is what keeps that figure under two minutes; a residual asymmetry remains and is tracked in #114. Moonrise/moonset uses the full Meeus Chapter 47 periodic terms with a minute-by-minute altitude scan, interpolating the crossing between samples, and agrees with USNO to within about a minute: measured against published USNO values over 28 rise/set events spanning latitudes 55°S to 64°N and all four seasons, the largest deviation was 32 seconds, and USNO itself publishes only to the minute. 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.27+. Zero dependencies.
GPL-3.0. See LICENSE.
Originally created by observerly. This fork includes bug fixes, algorithm improvements, and a complete rewrite.