Skip to content

Commit

Permalink
Added the first half of moon information, and then brought up questio…
Browse files Browse the repository at this point in the history
…ns to be answered based on the comments left in SolarTests
  • Loading branch information
jnewkirk committed Feb 12, 2025
1 parent b121714 commit 3102336
Show file tree
Hide file tree
Showing 11 changed files with 411 additions and 7 deletions.
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ let package = Package(
.testTarget(
name: "SunKitTests",
dependencies: ["SunKit"],
resources: [.copy("testLocations.json")]
resources: [.copy("testLocations.json"), .copy("moonData.json")]
),
]
)
8 changes: 8 additions & 0 deletions Sources/SunKit/Constant.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,13 @@ struct Constant {
return calendar
}

// The synodic month (Moon cycle) is 29.53058867 days
static let synodicMonth = 29.53058867

static let lunarNew = 0.0
static let lunarFirstQuarter = synodicMonth / 4.0
static let lunarFull = synodicMonth / 2.0
static let lunarThirdQuarter = lunarFirstQuarter * 3.0

static let utcTimezone = TimeZone(identifier: "UTC")!
}
22 changes: 22 additions & 0 deletions Sources/SunKit/LunarData.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
//
// File.swift
// SunKit
//
// Created by Jim Newkirk on 2/11/25.
//

import Foundation

public struct LunarData: Codable, Sendable {
internal init(moonRise: Date? = nil, moonSet: Date? = nil, illumination: Double, phase: LunarPhase) {
self.moonRise = moonRise
self.moonSet = moonSet
self.illumination = illumination
self.phase = phase
}

public let moonRise: Date?
public let moonSet: Date?
public let illumination: Double
public let phase: LunarPhase
}
19 changes: 19 additions & 0 deletions Sources/SunKit/LunarPhase.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
//
// File.swift
// SunKit
//
// Created by Jim Newkirk on 2/11/25.
//

import Foundation

public enum LunarPhase: String, CaseIterable, Codable, Sendable {
case new = "New"
case waningCrescent = "Waning Crescent"
case thirdQuarter = "Third Quarter"
case waningGibbous = "Waning Gibbous"
case full = "Full"
case waxingGibbous = "Waxing Gibbous"
case firstQuarter = "First Quarter"
case waxingCrescent = "Waxing Crescent"
}
121 changes: 121 additions & 0 deletions Sources/SunKit/Solar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,13 +71,21 @@ public struct Solar: Codable, Sendable {
self.daylight = nil
self.solarNoon = nil
}

let julianDate = Solar.julianDay(from: date)
let moonAge = Solar.moonAge(julianDate: julianDate)
let lunarPhase = Solar.lunarPhase(moonAge: moonAge)
let lunarIllumination = Solar.lunarIllumination(moonAge: moonAge)

self.lunar = LunarData(illumination: lunarIllumination, phase: lunarPhase)
}

let date: Date
let latitude: Double
let longitude: Double
public let dawn: SolarEvents?
public let dusk: SolarEvents?
public let lunar: LunarData
public let solarNoon: Date?
public let daylight: DateInterval?

Expand Down Expand Up @@ -244,6 +252,119 @@ public struct Solar: Codable, Sendable {
}
}

extension Solar {
/// Convert Date to Julian Day (JD)
static func julianDay(from date: Date) -> Double {
let components = Constant.calendar.dateComponents([.year, .month, .day, .hour, .minute, .second, .era], from: date)

guard var year = components.year,
let month = components.month,
let day = components.day,
let hour = components.hour,
let minute = components.minute,
let second = components.second else {
return 0.0
}

if (components.era == 0) {
year = -year + 1;
}

var Y = year
var M = month

// If month is January or February, treat it as the 13th or 14th month of the previous year
if M <= 2 {
Y -= 1
M += 12
}

let A = Y / 100
let B = 2 - A + (A / 4)

let JD =
floor(365.25 * Double(Y + 4716)) +
floor(30.6001 * Double(M + 1)) +
Double(day) +
Double(B) - 1524

// Convert time to fractional day
let dayFraction = (Double(hour) - 12.0) / 24.0 + Double(minute) / 1440.0 + Double(second) / 86400.0

return JD + dayFraction
}

static func moonAge(julianDate: Double) -> Double {
// Known new moon date (January 6, 2000 at 18:14 UTC)
let knownNewMoonJD = 2451549.5

var age = fmod(julianDate - knownNewMoonJD, Constant.synodicMonth)
if (age < 0) {
age = age + Constant.synodicMonth
}
return round(age * 10.0) / 10.0
}

static func lunarPhase(moonAge: Double) -> LunarPhase {
if (moonAge < Constant.lunarFirstQuarter) {
return .waxingCrescent
}
if (moonAge < Constant.lunarFull) {
return .waxingGibbous
}
if (moonAge < Constant.lunarThirdQuarter) {
return .waningGibbous
}

return .waningCrescent
}

static func lunarIllumination(moonAge: Double) -> Double {
let phaseAngle = (moonAge / Constant.synodicMonth) * 360.0
let illumination = 50.0 * (1 - cos(phaseAngle.degreesToRadians))

return round(illumination * 100.0) / 100.0
}

static func calculateMoonriseMoonset(latitude: Double, longitude: Double, moonAge: Double, date: Date, julianDate: Double) -> (moonRise: Date?, moonSet: Date?) {
let moonPhaseAngle = (moonAge / Constant.synodicMonth) * 360.0

// Approximate Moon's Declination
let moonDeclination = 23.44 * sin(moonPhaseAngle.degreesToRadians)
let localSiderealTime = getLocalSiderealTime(longitude: longitude, julianDate: julianDate)

let cosH =
(sin(-0.833.degreesToRadians) - sin(latitude.degreesToRadians) * sin(moonDeclination.degreesToRadians)) /
(cos(latitude.degreesToRadians) * cos(moonDeclination.degreesToRadians))

// Hour Angle Calculation
let hourAngle = acos(cosH).radiansToDegrees

// Apply Refraction Correction (~0.566° near horizon)
let refractionCorrection = 0.566
let correctedHourAngle = hourAngle - refractionCorrection

// Compute Moonrise & Moonset Times
let moonriseTime = (localSiderealTime - correctedHourAngle) / 15.0 // Convert hour angle to time
let moonsetTime = (localSiderealTime + correctedHourAngle) / 15.0

let today = Constant.calendar.startOfDay(for: date)

let riseDate = Constant.calendar.date(byAdding: .hour, value: Int(moonriseTime), to: today)
let setDate = Constant.calendar.date(byAdding: .hour, value: Int(moonsetTime), to: today)

return (moonRise: riseDate, moonSet: setDate)
}

static func getLocalSiderealTime(longitude: Double, julianDate: Double) -> Double {
let s = julianDate - 2451545.0
let t = s / 36525.0
let lst = 280.46061837 + 360.98564736629 * s + 0.000387933 * t * t - (t * t * t) / 38710000.0

return (lst + longitude).truncatingRemainder(dividingBy: 360.0)
}
}

private extension DateInterval {
init?(start: Date?, end: Date?) {
guard let start, let end else { return nil }
Expand Down
1 change: 1 addition & 0 deletions Tests/SunKitTests/Constant.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import CoreLocation

struct Constant {
static let testLocationFile = "testLocations"
static let testMoonFile = "moonData"

static var cupertino: CLLocationCoordinate2D {
CLLocationCoordinate2D(latitude: 37.322998, longitude: -122.032181)
Expand Down
16 changes: 16 additions & 0 deletions Tests/SunKitTests/Extensions/String + Ext.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
//
// File.swift
// SunKit
//
// Created by Jim Newkirk on 2/11/25.
//

import Foundation

extension String {
public func toDate() -> Date? {
let formatter = ISO8601DateFormatter()

return formatter.date(from: self)
}
}
66 changes: 66 additions & 0 deletions Tests/SunKitTests/Model/TestMoonData.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
//
// File.swift
// SunKit
//
// Created by Jim Newkirk on 2/11/25.
//

import CoreLocation
import Foundation
import SunKit
import Testing

struct MoonData: Codable {
let rise: Date
let set: Date
let phase: LunarPhase
let illumination: Double
}

struct LocationMoonInfo: Codable {
internal init(name: String, date: Date, latitude: Double, longitude: Double, moonData: MoonData) {
self.name = name
self.date = date
self.latitude = latitude
self.longitude = longitude
self.moonData = moonData
}

let name: String
let date: Date
let latitude: Double
let longitude: Double
let moonData: MoonData
}

extension LocationMoonInfo {
var coordinate: CLLocationCoordinate2D {
let coordinate = CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
guard CLLocationCoordinate2DIsValid(coordinate) else {
Issue.record("Test Location has invalid coordinates: \(coordinate)")
fatalError("Test Location has invalid coordinates: \(coordinate)")
}

return coordinate
}

static func load() -> [LocationMoonInfo] {
do {
let url = Bundle.module.url(forResource: Constant.testMoonFile, withExtension: "json")
guard let url else {
Issue.record("url is nil for moonData.json")
return []
}

let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601

let data = try Data(contentsOf: url)
let moonInfo = try decoder.decode([LocationMoonInfo].self, from: data)
return moonInfo
} catch {
Issue.record(error)
return []
}
}
}
57 changes: 57 additions & 0 deletions Tests/SunKitTests/MoonTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
//
// Test.swift
// SunKit
//
// Created by Jim Newkirk on 2/11/25.
//

import Testing
@testable import SunKit

struct MoonTests {
var testDatum: [LocationMoonInfo] = []

internal init() async throws {
testDatum = LocationMoonInfo.load()
}

@Test func testLocationCount() async throws {
#expect(testDatum.count == 5)
}

@Test
func lunarPhase() async throws {
for testData in testDatum {
let solar = Solar(date: testData.date, coordinate: testData.coordinate)

#expect(solar.lunar.phase == testData.moonData.phase,
"Test Location: \(testData.name)")
}
}

@Test
func illumination() async throws {
for testData in testDatum {
let solar = Solar(date: testData.date, coordinate: testData.coordinate)

#expect(solar.lunar.illumination == testData.moonData.illumination,
"Test Location: \(testData.name)")
}
}

@Test(arguments: zip(
[2451549.5, 2451551.5, 2451556.9, 2451564.3, 2451579.3],
[0.0, 2.0, 7.4, 14.8, 0.3]
))
func moonAge(julianDate: Double, moonAge: Double) throws {
#expect(moonAge == Solar.moonAge(julianDate: julianDate))
}

@Test(arguments: zip(
[2.0, 11.5, 18.2, 25.1],
[LunarPhase.waxingCrescent, LunarPhase.waxingGibbous, LunarPhase.waningGibbous, LunarPhase.waningCrescent]
))
func lunarPhaseByAge(moonAge: Double, moonPhase: LunarPhase) throws {
#expect(moonPhase == Solar.lunarPhase(moonAge: moonAge))
}
}
Loading

0 comments on commit 3102336

Please sign in to comment.