Skip to content

Latest commit

 

History

422 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

dusk

CI Go Reference Status: Stable

A single, zero-dependency Go package for astronomical calculations — sunrise/sunset, moonrise/moonset, twilight, and lunar phase — based on Meeus's Astronomical Algorithms.

Install

go get github.com/philoserf/dusk/v5

Command line

A 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:).

Examples

Sunrise and sunset

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)
}

Moonrise and moonset

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))
}

Lunar phase

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)

Civil twilight

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))

Polar geometry

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"))

API

Solar

  • SunriseSunset(date, obs) — sunrise, solar noon, sunset, and daylight duration

Lunar

  • MoonriseMoonset(date, obs) — moonrise/moonset times and whether the Moon was above the horizon at the start of the day
  • LunarPhase(date) — illumination, elongation, and phase name

Twilight

  • 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

Observer

  • NewObserver(lat, lon, loc) — create a validated observer from latitude, longitude, and timezone

Result types

Plain data; format them however you need. Observer implements fmt.Stringer, the result types do not.

  • SunEvent — Rise, Noon, Set times and Duration (daylight)
  • MoonEvent — Rise, Set times and AboveHorizon
  • TwilightEvent — Dawn and Dusk times, both on the queried day
  • LunarPhaseInfo — 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.

Errors

  • ErrNilLocation — nil timezone passed to NewObserver
  • ErrNonFiniteCoord — NaN or Inf coordinates
  • ErrInvalidCoord — latitude or longitude out of range
  • ErrDateOutOfRange — date outside supported Julian date range (~1677–2262)

Conventions

  • 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 a time.Time and kept only the calendar day as resolved in the observer's zone, which silently selected the previous day for anyone who built the date in time.UTC. Convert an instant with dusk.DateIn(t, loc), which is the call the library used to make invisibly.
  • LunarPhase is 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).
  • Observer is constructed via NewObserver, which validates coordinates and rejects NaN/Inf.
  • Polar geometry is a result, not an error: SunEvent.Horizon and TwilightEvent.Horizon report Crosses, StaysAbove or StaysBelow, with Rise/Set/Dawn/Dusk zero when there was no crossing. error means a nil timezone, bad coordinates or a date out of range.
  • A zero-value time.Time signals "event did not occur" (e.g., the Moon does not rise on a given day). Check with .IsZero().
  • Twilight returns 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.

Accuracy

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).

Requirements

Go 1.27+. Zero dependencies.

License

GPL-3.0. See LICENSE.

Originally created by observerly. This fork includes bug fixes, algorithm improvements, and a complete rewrite.

About

Zero-dependency Go package for astronomical calculations: sunrise/sunset, moonrise/moonset, twilight, and lunar phase, based on Meeus.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

1 watching

Forks

Releases

Sponsor this project

Used by

Contributors

Languages