dusk

package module
v3.0.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Mar 31, 2026 License: GPL-3.0 Imports: 3 Imported by: 0

README

dusk

CI Go Reference

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/v3

Examples

Sunrise and sunset

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

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

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.

Polar error handling

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

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, approximate age, waxing/waning, phase angle, and name
Twilight
  • CivilTwilight(date, obs) — sun 6 degrees below horizon
  • NauticalTwilight(date, obs) — sun 12 degrees below horizon
  • AstronomicalTwilight(date, obs) — sun 18 degrees below horizon
Observer
  • NewObserver(lat, lon, loc) — create a validated observer from latitude, longitude, and timezone
Result types

All result types implement fmt.Stringer:

  • SunEventRise, Noon, Set times and Duration (daylight)
  • MoonEventRise, Set times and AboveHorizon
  • TwilightEventDusk, Dawn times and NightDuration (overnight darkness)
  • LunarPhaseInfoIllumination, Elongation, Angle, DaysApprox, Waxing, Name
Errors
  • ErrCircumpolar — object always above the horizon (e.g., midnight sun)
  • ErrNeverRises — object never rises (e.g., polar night)
  • 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.
  • 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.
  • Functions that can fail return error. Two sentinel errors distinguish polar edge cases: ErrCircumpolar and ErrNeverRises.
  • 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 functions return tonight's Dusk and tomorrow morning's Dawn. To get this morning's dawn, call with yesterday's date.

Accuracy

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

Requirements

Go 1.24+. Zero dependencies.

License

GPL-3.0. See LICENSE.

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

Documentation

Overview

Package dusk provides astronomical calculations: twilight times, sunrise/sunset, moonrise/moonset, and lunar phase.

All angles are in degrees. Time parameters use time.Time. Functions that produce local times accept an Observer with a timezone.

Two sentinel errors distinguish polar edge cases: ErrCircumpolar (object always above the horizon) and ErrNeverRises (object never rises).

Zero-value time.Time in result structs signals "event did not occur" for a specific day (e.g., the Moon rises but does not set before midnight). Check with time.Time.IsZero. This is distinct from sentinel errors, which indicate the geometry makes the event impossible at the given latitude.

References

  • Meeus, Jean. Astronomical Algorithms. 2nd ed. Willmann-Bell, 1998.

Index

Examples

Constants

View Source
const ErrCircumpolar = errString("dusk: object is circumpolar (always above the horizon)")

ErrCircumpolar is returned when a celestial object is circumpolar (always above the horizon) at the given latitude.

View Source
const ErrDateOutOfRange = errString("dusk: date outside valid range (1677-09-21 to 2262-04-11)")

ErrDateOutOfRange is returned when a date falls outside the valid range for Julian date calculations (the int64 nanosecond bounds, approximately 1677-09-21 to 2262-04-11).

View Source
const ErrInvalidCoord = errString("dusk: latitude must be in [-90, 90] and longitude in [-180, 180]")

ErrInvalidCoord is returned when latitude or longitude are outside the valid range in NewObserver.

View Source
const ErrNeverRises = errString("dusk: object never rises at this latitude")

ErrNeverRises is returned when a celestial object never rises above the horizon at the given latitude.

View Source
const ErrNilLocation = errString("dusk: location must not be nil")

ErrNilLocation is returned when a nil *time.Location is passed to NewObserver.

View Source
const ErrNonFiniteCoord = errString("dusk: coordinates must be finite (NaN and Inf are not allowed)")

ErrNonFiniteCoord is returned when NaN or Inf coordinates are passed to NewObserver.

Variables

This section is empty.

Functions

This section is empty.

Types

type LunarPhaseInfo

type LunarPhaseInfo struct {
	Illumination float64 // percentage 0-100
	Elongation   float64 // degrees 0-360
	Angle        float64 // phase angle in degrees (may be negative per Meeus formula)
	DaysApprox   float64 // rough days into lunation (linear estimate from elongation)
	Waxing       bool    // true from New Moon to Full Moon (elongation 0-180)
	Name         string  // "New Moon", "Waxing Crescent", etc.
}

LunarPhaseInfo describes the Moon's current phase.

func LunarPhase

func LunarPhase(date time.Time) (LunarPhaseInfo, error)

LunarPhase returns the lunar phase at the given instant.

Unlike SunriseSunset and MoonriseMoonset which use only the calendar date, LunarPhase uses the exact time — the phase changes continuously. The result depends on the UTC instant; timezone does not affect the calculation.

The phase angle uses the Meeus approach: solar ecliptic longitude from the mean-anomaly method, lunar ecliptic position from Chapter 47 tables.

An error is returned if the date is out of the valid Julian date range.

Example
package main

import (
	"fmt"
	"time"

	"github.com/philoserf/dusk/v3"
)

func main() {
	date := time.Date(2024, 1, 25, 18, 0, 0, 0, time.UTC)
	phase, err := dusk.LunarPhase(date)
	if err != nil {
		fmt.Printf("error: %v\n", err)
		return
	}
	fmt.Printf("Phase: %s\n", phase.Name)
	fmt.Printf("Illumination: %.0f%%\n", phase.Illumination)
}
Output:
Phase: Full Moon
Illumination: 100%

func (LunarPhaseInfo) String

func (l LunarPhaseInfo) String() string

String returns a human-readable representation of the lunar phase.

type MoonEvent

type MoonEvent struct {
	Rise         time.Time // zero value if the Moon does not rise
	Set          time.Time // zero value if the Moon does not set
	AboveHorizon bool      // true if Moon was above the horizon at start of day
}

MoonEvent holds the rise and set times for the Moon on a given day, along with the duration between rise and set.

func MoonriseMoonset

func MoonriseMoonset(date time.Time, obs Observer) (MoonEvent, error)

MoonriseMoonset computes the moonrise and moonset times for the given date at the specified observer position and timezone. The date is converted to the observer's timezone to determine the local calendar day, then the function scans that local day (midnight to midnight) for rise/set events. This means the same time.Time can produce different results for observers in different timezones.

The algorithm scans minute-by-minute through the day to detect altitude sign changes. This is slow by design (~1440 ecliptic-position evaluations). A single call takes approximately 1-2 ms on modern hardware (Apple M-series or equivalent; see BenchmarkMoonriseMoonset). Callers computing moonrise/moonset for many dates (e.g., a 30-day calendar ≈ 30-60 ms) should expect proportional cost and may benefit from caching or parallelization.

The one-minute resolution means events shorter than one minute may not be detected. At polar or near-polar latitudes, the Moon can graze the horizon briefly enough to fall within a single scan step.

An error is returned if the date is out of the valid Julian date range.

Example
package main

import (
	"fmt"
	"time"

	"github.com/philoserf/dusk/v3"
)

func main() {
	loc, err := time.LoadLocation("America/New_York")
	if err != nil {
		fmt.Println("error:", err)
		return
	}
	date := time.Date(2024, 1, 15, 0, 0, 0, 0, loc)
	obs, err := dusk.NewObserver(40.7128, -74.0060, loc)
	if err != nil {
		fmt.Println("error:", err)
		return
	}

	evt, err := dusk.MoonriseMoonset(date, obs)
	if err != nil {
		fmt.Println("error:", err)
		return
	}
	if !evt.Rise.IsZero() {
		fmt.Printf("Moonrise: %s\n", evt.Rise.Format("15:04"))
	}
	if !evt.Set.IsZero() {
		fmt.Printf("Moonset:  %s\n", evt.Set.Format("15:04"))
	}
}
Output:
Moonrise: 10:06
Moonset:  22:13

func (MoonEvent) String

func (m MoonEvent) String() string

String returns a human-readable representation of the moon event.

type Observer

type Observer struct {
	// contains filtered or unexported fields
}

Observer represents a geographic position on Earth used as the viewpoint for all astronomical calculations.

func NewObserver

func NewObserver(lat, lon float64, loc *time.Location) (Observer, error)

NewObserver constructs an Observer after validating all inputs. lat must be in [-90, 90], lon in [-180, 180], and loc must not be nil. NaN and infinite values are rejected.

Example
package main

import (
	"fmt"
	"math"
	"time"

	"github.com/philoserf/dusk/v3"
)

func main() {
	// Valid observer
	obs, err := dusk.NewObserver(40.7128, -74.006, time.UTC)
	if err != nil {
		fmt.Println("unexpected error:", err)
		return
	}
	fmt.Println(obs)

	// Invalid: latitude out of range
	_, err = dusk.NewObserver(91, 0, time.UTC)
	fmt.Println(err)

	// Invalid: NaN
	_, err = dusk.NewObserver(math.NaN(), 0, time.UTC)
	fmt.Println(err)
}
Output:
40.7128°, -74.0060° (UTC)
dusk: latitude must be in [-90, 90] and longitude in [-180, 180]
dusk: coordinates must be finite (NaN and Inf are not allowed)

func (Observer) Lat

func (o Observer) Lat() float64

Lat returns the observer's latitude in degrees.

func (Observer) Location

func (o Observer) Location() *time.Location

Location returns the observer's timezone.

func (Observer) Lon

func (o Observer) Lon() float64

Lon returns the observer's longitude in degrees (east positive, west negative).

func (Observer) String

func (o Observer) String() string

String returns a human-readable representation of the observer.

type SunEvent

type SunEvent struct {
	Rise     time.Time
	Noon     time.Time
	Set      time.Time
	Duration time.Duration
}

SunEvent holds the times of sunrise, solar noon, sunset, and the duration of daylight for a single day.

func SunriseSunset

func SunriseSunset(date time.Time, obs Observer) (SunEvent, error)

SunriseSunset computes sunrise, solar noon, and sunset for the given date and observer position. The observer must be constructed via NewObserver. The date is converted to the observer's timezone to determine the local calendar day; the time-of-day is ignored. Output times are converted to the observer's timezone.

The algorithm follows the NOAA solar calculator method (derived from Meeus, Astronomical Algorithms).

Example
package main

import (
	"fmt"
	"time"

	"github.com/philoserf/dusk/v3"
)

func main() {
	loc, err := time.LoadLocation("America/Chicago")
	if err != nil {
		fmt.Println("error:", err)
		return
	}
	date := time.Date(2025, 6, 21, 0, 0, 0, 0, loc)
	obs, err := dusk.NewObserver(42.9634, -85.6681, loc)
	if err != nil {
		fmt.Println("error:", err)
		return
	}

	sun, err := dusk.SunriseSunset(date, obs)
	if err != nil {
		fmt.Println("error:", err)
		return
	}
	fmt.Printf("Sunrise: %s\n", sun.Rise.Format("15:04"))
	fmt.Printf("Sunset:  %s\n", sun.Set.Format("15:04"))
}
Output:
Sunrise: 05:03
Sunset:  20:25
Example (Polar)
package main

import (
	"errors"
	"fmt"
	"time"

	"github.com/philoserf/dusk/v3"
)

func main() {
	loc, err := time.LoadLocation("Europe/Oslo")
	if err != nil {
		fmt.Println("error:", err)
		return
	}
	obs, err := dusk.NewObserver(69.65, 18.96, loc)
	if err != nil {
		fmt.Println("error:", err)
		return
	}

	// Tromsø on June 21 — midnight sun
	date := time.Date(2024, 6, 21, 0, 0, 0, 0, loc)
	_, err = dusk.SunriseSunset(date, obs)
	if errors.Is(err, dusk.ErrCircumpolar) {
		fmt.Println("Midnight sun — no sunrise or sunset")
	}

	// Tromsø on December 21 — polar night
	date = time.Date(2024, 12, 21, 0, 0, 0, 0, loc)
	_, err = dusk.SunriseSunset(date, obs)
	if errors.Is(err, dusk.ErrNeverRises) {
		fmt.Println("Polar night — sun never rises")
	}
}
Output:
Midnight sun — no sunrise or sunset
Polar night — sun never rises

func (SunEvent) String

func (s SunEvent) String() string

String returns a human-readable representation of the sun event.

type TwilightEvent

type TwilightEvent struct {
	Dusk          time.Time     // evening boundary (today)
	Dawn          time.Time     // morning boundary (tomorrow)
	NightDuration time.Duration // time from Dusk to Dawn (overnight darkness)
}

TwilightEvent holds the dusk and dawn times of a twilight period. Dusk is tonight's boundary (sun passes below the depression angle). Dawn is tomorrow morning's boundary (sun passes above the depression angle). To get this morning's dawn, call with yesterday's date.

func AstronomicalTwilight

func AstronomicalTwilight(date time.Time, obs Observer) (TwilightEvent, error)

AstronomicalTwilight computes the evening astronomical twilight period (Sun 18 degrees below the horizon) for the given date and observer position.

func CivilTwilight

func CivilTwilight(date time.Time, obs Observer) (TwilightEvent, error)

CivilTwilight computes the evening civil twilight period (Sun 6 degrees below the horizon) for the given date and observer position. Dusk is tonight's civil dusk; Dawn is tomorrow morning's civil dawn.

Example
package main

import (
	"fmt"
	"time"

	"github.com/philoserf/dusk/v3"
)

func main() {
	loc, err := time.LoadLocation("America/Los_Angeles")
	if err != nil {
		fmt.Println("error:", err)
		return
	}
	date := time.Date(2025, 6, 21, 0, 0, 0, 0, loc)
	obs, err := dusk.NewObserver(47.6062, -122.3321, loc)
	if err != nil {
		fmt.Println("error:", err)
		return
	}

	tw, err := dusk.CivilTwilight(date, obs)
	if err != nil {
		fmt.Println("error:", err)
		return
	}
	fmt.Printf("Dusk: %s\n", tw.Dusk.Format("15:04"))
	fmt.Printf("Dawn: %s\n", tw.Dawn.Format("15:04"))
}
Output:
Dusk: 21:51
Dawn: 04:31

func NauticalTwilight

func NauticalTwilight(date time.Time, obs Observer) (TwilightEvent, error)

NauticalTwilight computes the evening nautical twilight period (Sun 12 degrees below the horizon) for the given date and observer position.

func (TwilightEvent) String

func (tw TwilightEvent) String() string

String returns a human-readable representation of the twilight event.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL