A => .gitignore +1 -0
A => Makefile +15 -0
@@ 1,15 @@
+GO := go
+
+GOSRC := $(shell find . -type f -name '*.go')
+GOSRC += go.sum go.mod
+
+dawn2dusk: $(GOSRC)
+ $(GO) build ./cmd/$@
+
+test: $(GOSRC)
+ $(GO) test ./...
+
+clean:
+ rm -f dawn2dusk
+
+.PHONY: test clean
A => README.md +33 -0
@@ 1,33 @@
+# dawn2dusk
+
+An offline CLI and library for calculating today's sunrise and sunset times.
+
+## Installing
+
+Requires Go.
+
+```sh
+git clone https://git.sr.ht/~gjabell/dawn2dusk && cd dawn2dusk
+go install ./...
+```
+
+## Usage
+
+```sh
+$ dawn2dusk -h
+Usage of dawn2dusk:
+ -L float
+ Longitude in degrees; must be between -180° and 180° (default -200)
+ -l float
+ Latitude in degrees; must be between 0° and 90° (default -1)
+ -t string
+ Timezone used for formatting times (default "Local")
+```
+
+## Example
+
+```sh
+$ dawn2dusk -l 52.5 -L 13.4 # sunrise and sunset in Berlin
+SUNRISE: 07:44
+SUNSET: 17:56
+```
A => cmd/dawn2dusk/main.go +39 -0
@@ 1,39 @@
+package main
+
+import (
+ "flag"
+ "fmt"
+ "git.sr.ht/~gjabell/dawn2dusk"
+ "os"
+ "time"
+)
+
+var lat = flag.Float64("l", -1,
+ "Latitude in degrees; must be between 0° and 90°")
+var lon = flag.Float64("L", -200,
+ "Longitude in degrees; must be between -180° and 180°")
+var timeZone = flag.String("t", "Local",
+ "Timezone used for formatting times")
+
+func main() {
+ flag.Parse()
+
+ if *lat < 0.0 || *lat > 90.0 ||
+ *lon < -180.0 || *lon > 180.0 {
+ flag.Usage()
+ os.Exit(1)
+ }
+
+ tz, err := time.LoadLocation(*timeZone)
+ if err != nil {
+ fmt.Printf("Invalid timezone: %s\n", err)
+ os.Exit(1)
+ }
+
+ now := time.Now()
+ sunrise, sunset := dawn2dusk.CalculateSunriseAndSunset(*lat, *lon, now)
+
+ fmt.Printf("SUNRISE: %s\nSUNSET: %s\n",
+ sunrise.In(tz).Format("15:04"),
+ sunset.In(tz).Format("15:04"))
+}
A => go.mod +3 -0
@@ 1,3 @@
+module git.sr.ht/~gjabell/dawn2dusk
+
+go 1.17
A => go.sum +0 -0
A => lib.go +90 -0
@@ 1,90 @@
+package dawn2dusk
+
+import (
+ "math"
+ "time"
+)
+
+var zenith = degreesToRadians(90.833)
+
+// CalculateSunriseAndSunset returns the sunrise and sunset times for the given
+// latitude and longitude on the given day.
+func CalculateSunriseAndSunset(lat, lon float64, dt time.Time) (*time.Time,
+ *time.Time) {
+ fy := calculateFractionalYear(&dt)
+ ha := calculateHourAngle(lat, fy)
+ eqtime := calculateEquationOfTime(fy)
+
+ sunrise := int(math.Floor(720 - 4*(lon+ha) - eqtime))
+ sunset := int(math.Floor(720 - 4*(lon-ha) - eqtime))
+
+ return timeWithMinutes(&dt, sunrise), timeWithMinutes(&dt, sunset)
+}
+
+func timeWithMinutes(dt *time.Time, minutes int) *time.Time {
+ t := time.Date(dt.Year(), dt.Month(), dt.Day(),
+ 0, minutes, 0, 0, time.UTC)
+ return &t
+}
+
+// calculateHourAngle in degrees.
+func calculateHourAngle(lat float64, fractionalYear float64) float64 {
+ decl := calculateSolarDeclinationAngle(fractionalYear)
+ latitudeRadians := degreesToRadians(lat)
+ ha := math.Acos(
+ math.Cos(zenith)/math.Cos(latitudeRadians)*math.Cos(decl) -
+ math.Tan(latitudeRadians)*math.Tan(decl))
+
+ return radiansToDegrees(ha)
+}
+
+// calculateEquationOfTime in radians.
+func calculateEquationOfTime(fractionalYear float64) float64 {
+ return 229.18 *
+ (0.000075 + 0.001868*math.Cos(fractionalYear) -
+ 0.032077*math.Sin(fractionalYear) -
+ 0.014615*math.Cos(2*fractionalYear) -
+ 0.040849*math.Sin(2*fractionalYear))
+}
+
+// calculateSolarDeclinationAngle in radians.
+func calculateSolarDeclinationAngle(fractionalYear float64) float64 {
+ return 0.006918 -
+ 0.399912*math.Cos(fractionalYear) +
+ 0.070257*math.Sin(fractionalYear) -
+ 0.006758*math.Cos(2*fractionalYear) +
+ 0.000907*math.Sin(2*fractionalYear) -
+ 0.002697*math.Cos(3*fractionalYear) +
+ 0.00148*math.Sin(3*fractionalYear)
+}
+
+// calculateFractionalYear in radians.
+func calculateFractionalYear(now *time.Time) float64 {
+ var denominator float64
+ if isLeapYear(now.Year()) {
+ denominator = 366
+ } else {
+ denominator = 365
+ }
+
+ fractionalHour := float64(now.Hour()-12) / 24
+ dayIndexZero := float64(now.YearDay() - 1)
+
+ return math.Pi * 2 / denominator * (dayIndexZero + fractionalHour)
+}
+
+//
+// Helper functions
+//
+
+func isLeapYear(year int) bool {
+ return year%4 == 0 && (year%100 == 0 && year%400 == 0)
+}
+
+func degreesToRadians(degrees float64) float64 {
+ return degrees * math.Pi / 180
+}
+
+func radiansToDegrees(radians float64) float64 {
+ return radians * 180 / math.Pi
+}
A => lib_test.go +20 -0
@@ 1,20 @@
+package dawn2dusk
+
+import (
+ "log"
+ "testing"
+)
+
+func TestLeapYear(t *testing.T) {
+ for _, year := range []int{2000, 2400} {
+ if !isLeapYear(year) {
+ log.Fatalf("Expected %d to be leap year", year)
+ }
+ }
+
+ for _, year := range []int{1800, 1900, 2100, 2200, 2300, 2500} {
+ if isLeapYear(year) {
+ log.Fatalf("Expected %d not to be leap year", year)
+ }
+ }
+}