diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..02c0875 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj diff --git a/Dockerfile b/Dockerfile new file mode 100755 index 0000000..76166bd --- /dev/null +++ b/Dockerfile @@ -0,0 +1,5 @@ +FROM swift +WORKDIR /app +COPY . ./ +CMD swift package clean +CMD swift test --parallel diff --git a/LICENSE b/LICENSE new file mode 100755 index 0000000..5735690 --- /dev/null +++ b/LICENSE @@ -0,0 +1,17 @@ + DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE + Version 2, December 2004 + +Copyright (C) 2018-2019 Binary Birds + +Authors: + + Tibor Bodecs + +Everyone is permitted to copy and distribute verbatim or modified +copies of this license document, and changing it is allowed as long +as the name is changed. + + DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. You just DO WHAT THE FUCK YOU WANT TO. diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..c621875 --- /dev/null +++ b/Package.swift @@ -0,0 +1,21 @@ +// swift-tools-version:4.2 +import PackageDescription + +let package = Package( + name: "Sunlight", + products: [ + .library(name: "Sunlight", targets: ["Sunlight"]), + ], + dependencies: [ + + ], + targets: [ + .target( + name: "Sunlight", + dependencies: [], + path: "./Sources/"), + .testTarget( + name: "SunlightTests", + dependencies: ["Sunlight"]), + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..f54aa73 --- /dev/null +++ b/README.md @@ -0,0 +1,82 @@ +# Sunlight (☀️) + +Calculate dawn, dusk, golden and blue hour times by using various algorithms. + + + +### Twilight Types + +![](https://www.photopills.com/sites/default/files/tutorials/2014/twilights-magic-hours.jpg) + + + +- Civil Twilight +- Nautical Twilight +- Astronomical Twilight +- Dawn (official) +- Dusk (official) +- The Golden Hour +- The Blue Hour + + + +## Usage + +Some examples: + +```swift +import Sunlight + +let sunlight = SunlightCalculator(latitude: 47.49801, longitude: 19.03991) + +let officialDawn = sunlight.calculate(.dawn, twilight: .official) +let officialDusk = sunlight.calculate(.dusk, twilight: .official) + +let civilDawn = sunlight.calculate(.dawn, twilight: .civil) +let civilDusk = sunlight.calculate(.dusk, twilight: .civil) + +let astronomicalDawn = sunlight.calculate(.dawn, twilight: .astronomical) +let astronomicalDusk = sunlight.calculate(.dusk, twilight: .astronomical) + +let nauticalDawn = sunlight.calculate(.dawn, twilight: .nautical) +let nauticalDusk = sunlight.calculate(.dusk, twilight: .nautical) + +let blueHourStart = sunlight.calculate(.dawn, twilight: .custom(-8)) +let blueHourEndGoldenHourStart = sunlight.calculate(.dusk, twilight: .custom(-4)) +let goldenHourEnd = sunlight.calculate(.dusk, twilight: .custom(6)) + +``` + + + +## Install + +Just use the [Swift Package Manager](https://theswiftdev.com/2017/11/09/swift-package-manager-tutorial/) as usual: + +```swift +.package(url: "https://github.com/binarybirds/sunlight", from: "1.0.0"), +``` + +⚠️ Don't forget to add "Sunlight" to your target as a dependency! + + + +## License + +[WTFPL](LICENSE) - Do what the fuck you want to. + + + +## Other sources + +- https://en.wikipedia.org/wiki/Position_of_the_Sun +- https://en.wikipedia.org/wiki/Sunrise_equation + +- https://www.codeproject.com/Articles/100174/Calculate-and-Draw-Moon-Phase + +- http://lamminet.fi/jarmo/rscalc.cc + +- https://www.timeanddate.com/astronomy/different-types-twilight.html + +- https://www.photopills.com/articles/understanding-golden-hour-blue-hour-and-twilights + diff --git a/Sources/FloatingPoint+Extensions.swift b/Sources/FloatingPoint+Extensions.swift new file mode 100644 index 0000000..fa87cb9 --- /dev/null +++ b/Sources/FloatingPoint+Extensions.swift @@ -0,0 +1,42 @@ +// +// FloatingPoint+Extensions.swift +// Sunlight +// +// Created by Tibor Bödecs on 2019. 02. 07.. +// Copyright © 2018-2019. Tibor Bödecs. All rights reserved. +// + +import Foundation + +extension FloatingPoint { + + var radians: Self { + return self * .pi / 180 + } + + var degrees: Self { + return self * 180 / .pi + } + + // Reduce angle to within 0..360 degrees + var reduceAngle: Self { + return self - 360 * floor(self / 360) as Self + } + + // Reduce angle to within -180..+180 degrees + var reduceAngle180: Self { + let value = self / 360 + 1 / 2 + return self - 360 * floor(value) + } + + func normalise(withMaximum maximum: Self) -> Self { + var value = self + if value < 0 { + value += maximum + } + if value > maximum { + value -= maximum + } + return value + } +} diff --git a/Sources/SchlyterAlgorithm.swift b/Sources/SchlyterAlgorithm.swift new file mode 100755 index 0000000..57ae690 --- /dev/null +++ b/Sources/SchlyterAlgorithm.swift @@ -0,0 +1,101 @@ +// +// SchlyterAlgorithm.swift +// Sunlight +// +// Created by Tibor Bödecs on 2018. 01. 16.. +// Copyright © 2018-2019. Tibor Bödecs. All rights reserved. +// + +import Foundation + +// http://stjarnhimlen.se/comp/sunriset.c +public struct SchlyterAlgorithm: SunlightCalculatorAlgorithm { + + public init() { + + } + + private func daysSince2000Jan0(_ y: Int, _ m: Int, _ d: Int) -> Int { + return (367 * y - ((7 * (y + ((m + 9) / 12))) / 4) + (275 * m / 9) + d - 730_530) + } + + private func GMST0(_ d: Double) -> Double { + return ((180.0 + 356.047_0 + 282.940_4) + (0.985_600_258_5 + 4.70935e-5) * d).reduceAngle + } + + private func sunposAtDay(_ d: Double, lon: inout Double, r: inout Double) { + let M = (356.047_0 + 0.985_600_258_5 * d).reduceAngle + let w = 282.940_4 + 4.70935e-5 * d + let e = 0.016_709 - 1.151e-9 * d + + let E = M + e.degrees * sin(M.radians) * (1.0 + e * cos(M.radians)) + let x = cos(E.radians) - e + let y = sqrt(1.0 - e * e) * sin(E.radians) + r = sqrt(x * x + y * y) + let v = atan2(y, x).degrees + lon = v + w + if lon >= 360.0 { + lon -= 360.0 + } + } + + private func sun_RA_decAtDay(_ d: Double, RA: inout Double, dec: inout Double, r: inout Double) { + var lon: Double = 0 + + self.sunposAtDay(d, lon: &lon, r: &r) + + let xs = r * cos(lon.radians) + let ys = r * sin(lon.radians) + let obl_ecl = 23.439_3 - 3.563E-7 * d + let xe = xs + let ye = ys * cos(obl_ecl.radians) + let ze = ys * sin(obl_ecl.radians) + RA = atan2(ye, xe).degrees + dec = atan2(ze, sqrt(xe * xe + ye * ye)).degrees + } + + public func calculate(_ transition: Transition, + on date: Date, + latitude: Double, + longitude: Double, + twilight: Twilight) -> Date? { + + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(abbreviation: "UTC")! + + let dcs = calendar.dateComponents([.year, .month, .day], from: date) + let year = calendar.component(.year, from: date) + let month = calendar.component(.month, from: date) + let day = calendar.component(.day, from: date) + let newDate = calendar.date(from: dcs)! + + var sRA: Double = 0 + var sdec: Double = 0 + var sr: Double = 0 + + let d = Double(self.daysSince2000Jan0(year, month, day)) + 0.5 - longitude / 360.0 + let sidtime = (self.GMST0(d) + 180.0 + longitude).reduceAngle + + self.sun_RA_decAtDay(d, RA: &sRA, dec: &sdec, r: &sr) + + let tsouth = 12.0 - (sidtime - sRA).reduceAngle180 / 15.0 + let sradius = 0.266_6 / sr + + var alt = twilight.degrees + if case .official = twilight { //upper_limb = 1 + alt -= sradius + } + + let cost = (sin(alt.radians) - sin(latitude.radians) * sin(sdec.radians)) / (cos(latitude.radians) * cos(sdec.radians)) + guard cost < 1, cost > -1 else { + return nil + } + let t = acos(cost).degrees / 15.0 + + var val = tsouth + t + if transition == .dawn { + val = tsouth - t + } + return newDate.addingTimeInterval(val * 3_600) + } +} diff --git a/Sources/SunlightCalculator.swift b/Sources/SunlightCalculator.swift new file mode 100644 index 0000000..c75d068 --- /dev/null +++ b/Sources/SunlightCalculator.swift @@ -0,0 +1,35 @@ +// +// Sunlight.swift +// Sunlight +// +// Created by Tibor Bödecs on 2019. 02. 10.. +// Copyright © 2018-2019. Tibor Bödecs. All rights reserved. +// + +import Foundation + +public struct SunlightCalculator { + + public let algorithm: SunlightCalculatorAlgorithm + public let latitude: Double + public let longitude: Double + public let date: Date + + public init(using algorithm: SunlightCalculatorAlgorithm = SchlyterAlgorithm(), + date: Date = Date(), + latitude: Double, + longitude: Double) { + self.algorithm = algorithm + self.latitude = latitude + self.longitude = longitude + self.date = date + } + + public func calculate(_ transition: Transition, twilight: Twilight) -> Date? { + return self.algorithm.calculate(transition, + on: self.date, + latitude: self.latitude, + longitude: self.longitude, + twilight: twilight) + } +} diff --git a/Sources/SunlightProtocols.swift b/Sources/SunlightProtocols.swift new file mode 100644 index 0000000..ad89d45 --- /dev/null +++ b/Sources/SunlightProtocols.swift @@ -0,0 +1,47 @@ +// +// SunlightProtocols.swift +// Sunlight +// +// Created by Tibor Bödecs on 2019. 02. 10.. +// Copyright © 2018-2019. Tibor Bödecs. All rights reserved. +// + +import Foundation + +public enum Transition { + case dawn + case dusk +} + +public enum Twilight { + + case official + case civil + case nautical + case astronomical + case custom(Double) + + public var degrees: Double { + switch self { + case .official: + return -35.0 / 60.0 + case .civil: + return -6 + case .nautical: + return -12 + case .astronomical: + return -18 + case .custom(let value): + return value + } + } +} + +public protocol SunlightCalculatorAlgorithm { + func calculate(_ transition: Transition, + on date: Date, + latitude: Double, + longitude: Double, + twilight: Twilight) -> Date? +} + diff --git a/Sources/WilliamsAlgorithm.swift b/Sources/WilliamsAlgorithm.swift new file mode 100644 index 0000000..4e5bb0c --- /dev/null +++ b/Sources/WilliamsAlgorithm.swift @@ -0,0 +1,98 @@ +// +// WilliamsAlgorithm.swift +// Sunlight +// +// Created by Tibor Bödecs on 2019. 02. 11.. +// Copyright © 2018-2019. Tibor Bödecs. All rights reserved. +// + +import Foundation + +// http://edwilliams.org/sunrise_sunset_algorithm.htm +public struct WilliamsAlgorithm: SunlightCalculatorAlgorithm { + + public init() { + + } + + public func calculate(_ transition: Transition, + on date: Date, + latitude: Double, + longitude: Double, + twilight: Twilight) -> Date? { + + let zenith = -1 * twilight.degrees + 90 + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "UTC")! + let dayInt = calendar.ordinality(of: .day, in: .year, for: date)! + let day = Double(dayInt) + + // longitude to hour value and calculate an approx. time + let lngHour = longitude / 15 + let hourTime: Double = transition == .dawn ? 6 : 18 + let t = day + ((hourTime - lngHour) / 24) + + // Calculate the suns mean anomaly + let M = (0.9856 * t) - 3.289 + + // Calculate the sun's true longitude + let subexpression1 = 1.916 * sin(M.radians) + let subexpression2 = 0.020 * sin(2 * M.radians) + var L = M + subexpression1 + subexpression2 + 282.634 + L = L.normalise(withMaximum: 360) + + // sun's right ascension + var RA = atan(0.91764 * tan(L.radians)).degrees + RA = RA.normalise(withMaximum: 360) + + // RA value needs to be in the same quadrant as L + let Lquadrant = floor(L / 90) * 90 + let RAquadrant = floor(RA / 90) * 90 + RA = RA + (Lquadrant - RAquadrant) + // RA into hours + RA = RA / 15 + + // declination + let sinDec = 0.39782 * sin(L.radians) + let cosDec = cos(asin(sinDec)) + + // local hour angle + let cosH = (cos(zenith.radians) - (sinDec * sin(latitude.radians))) / (cosDec * cos(latitude.radians)) + + // no transition + guard cosH < 1, cosH > -1 else { + return nil + } + + let tempH = transition == .dawn ? 360 - acos(cosH).degrees : acos(cosH).degrees + let H = tempH / 15.0 + + // local mean time of rising + let T = H + RA - (0.06571 * t) - 6.622 + + var UT = T - lngHour + UT = UT.normalise(withMaximum: 24) + + let hour = floor(UT) + let minute = floor((UT - hour) * 60.0) + let second = (((UT - hour) * 60) - minute) * 60.0 + let shouldBeYesterday = lngHour > 0 && UT > 12 && transition == .dawn + let shouldBeTomorrow = lngHour < 0 && UT < 12 && transition == .dusk + let setDate: Date + if shouldBeYesterday { + setDate = Date(timeInterval: -86_400, since: date) + } + else if shouldBeTomorrow { + setDate = Date(timeInterval: 86_400, since: date) + } + else { + setDate = date + } + + var components = calendar.dateComponents([.day, .month, .year], from: setDate) + components.hour = Int(hour) + components.minute = Int(minute) + components.second = Int(second) + return calendar.date(from: components) + } +} diff --git a/Tests/LinuxMain.swift b/Tests/LinuxMain.swift new file mode 100644 index 0000000..1668ec8 --- /dev/null +++ b/Tests/LinuxMain.swift @@ -0,0 +1,7 @@ +import XCTest + +import SunlightTests + +var tests = [XCTestCaseEntry]() +tests += SunlightTests.allTests() +XCTMain(tests) \ No newline at end of file diff --git a/Tests/SunlightTests/SunlightTests.swift b/Tests/SunlightTests/SunlightTests.swift new file mode 100644 index 0000000..858073d --- /dev/null +++ b/Tests/SunlightTests/SunlightTests.swift @@ -0,0 +1,81 @@ +import XCTest +@testable import Sunlight + +final class SunlightTests: XCTestCase { + + static var allTests = [ + ("testSchlyterAlgorithm", testSchlyterAlgorithm), + ("testWilliamsAlgorithm", testWilliamsAlgorithm) + ] + + let date = Date(timeIntervalSince1970: 1549918775) + let latitude = 47.49801 + let longitude = 19.03991 + + func calculate(algorithm: SunlightCalculatorAlgorithm, transition: Transition, twilight: Twilight) -> Date? { + return algorithm.calculate(transition, + on: self.date, + latitude: self.latitude, + longitude: self.longitude, + twilight: twilight) + } + + func test(_ algorithm: SunlightCalculatorAlgorithm, + _ transition: Transition, + _ twilight: Twilight, + _ expectation: TimeInterval) { + + guard let result = self.calculate(algorithm: algorithm, transition: transition, twilight: twilight) else { + return XCTFail("Date should be present!") + } + XCTAssertEqual(result.timeIntervalSince1970, expectation, "Calculated date is not equal to expectation.") + } + + func testSchlyterAlgorithm() { + let algorithm = SchlyterAlgorithm() + + self.test(algorithm, .dawn, .official, 1549864559.794311) + self.test(algorithm, .dusk, .official, 1549900806.1035957) + + self.test(algorithm, .dawn, .civil, 1549862645.721756) + self.test(algorithm, .dusk, .civil, 1549902720.1761508) + + self.test(algorithm, .dawn, .astronomical, 1549858333.9143946) + self.test(algorithm, .dusk, .astronomical, 1549907031.983512) + + self.test(algorithm, .dawn, .nautical, 1549860473.1792872) + self.test(algorithm, .dusk, .nautical, 1549904892.7186193) + + self.test(algorithm, .dawn, .custom(-8), 1549861916.0246394) + self.test(algorithm, .dawn, .custom(-4), 1549863382.5804334) + self.test(algorithm, .dawn, .custom(6), 1549867231.1860623) + + self.test(algorithm, .dusk, .custom(6), 1549898134.7118442) + self.test(algorithm, .dusk, .custom(-4), 1549901983.3174734) + self.test(algorithm, .dusk, .custom(-8), 1549903449.8732672) + } + + func testWilliamsAlgorithm() { + let algorithm = WilliamsAlgorithm() + + self.test(algorithm, .dawn, .official, 1549864694.0) + self.test(algorithm, .dusk, .official, 1549900731.0) + + self.test(algorithm, .dawn, .civil, 1549862676.0) + self.test(algorithm, .dusk, .civil, 1549902746.0) + + self.test(algorithm, .dawn, .astronomical, 1549858363.0) + self.test(algorithm, .dusk, .astronomical, 1549907056.0) + + self.test(algorithm, .dawn, .nautical, 1549860503.0) + self.test(algorithm, .dusk, .nautical, 1549904917.0) + + self.test(algorithm, .dawn, .custom(-8), 1549861946.0) + self.test(algorithm, .dawn, .custom(-4), 1549863414.0) + self.test(algorithm, .dawn, .custom(6), 1549867266.0) + + self.test(algorithm, .dusk, .custom(6), 1549898164.0) + self.test(algorithm, .dusk, .custom(-4), 1549902009.0) + self.test(algorithm, .dusk, .custom(-8), 1549903475.0) + } +} diff --git a/Tests/SunlightTests/XCTestManifests.swift b/Tests/SunlightTests/XCTestManifests.swift new file mode 100644 index 0000000..4ae8cce --- /dev/null +++ b/Tests/SunlightTests/XCTestManifests.swift @@ -0,0 +1,9 @@ +import XCTest + +#if !os(macOS) +public func allTests() -> [XCTestCaseEntry] { + return [ + testCase(SunlightTests.allTests), + ] +} +#endif \ No newline at end of file