diff --git a/Intents/GetNextAltitudeCrossing.swift b/Intents/GetNextAltitudeCrossing.swift new file mode 100644 index 00000000..cbfd9327 --- /dev/null +++ b/Intents/GetNextAltitudeCrossing.swift @@ -0,0 +1,47 @@ +// +// GetNextAltitudeCrossing.swift +// Solstice +// +// Created by Daniel Eden on 24/04/2026. +// + +import Foundation +import AppIntents +import CoreLocation + +struct GetNextAltitudeCrossing: AppIntent { + static var title: LocalizedStringResource = "Get Next Sun Altitude Crossing" + static var description = IntentDescription("Returns the next time in the following 24 hours that the sun's altitude crosses a given value in a given direction (rising, falling, or either). Returns no value if no such crossing occurs.") + + @Parameter(title: "Altitude") + var altitude: Measurement + + @Parameter(title: "Direction", default: .either) + var direction: AltitudeCrossingDirection + + @Parameter(title: "Start Date") + var startDate: Date? + + @Parameter(title: "Location") + var location: LocationAppEntity? + + static var parameterSummary: some ParameterSummary { + Summary("Next time sun altitude crosses \(\.$altitude) (\(\.$direction)) after \(\.$startDate) in \(\.$location)") + } + + func perform() async throws -> some IntentResult & ReturnsValue { + let start = startDate ?? .now + let (coordinate, timeZone) = try await resolveSunGeometryLocation(location) + let targetDegrees = altitude.converted(to: .degrees).value + + let crossing = findNextAltitudeCrossing( + targetDegrees: targetDegrees, + direction: direction, + start: start, + coordinate: coordinate, + timeZone: timeZone + ) + + return .result(value: crossing) + } +} diff --git a/Intents/GetNextAzimuthCrossing.swift b/Intents/GetNextAzimuthCrossing.swift new file mode 100644 index 00000000..72815a0a --- /dev/null +++ b/Intents/GetNextAzimuthCrossing.swift @@ -0,0 +1,47 @@ +// +// GetNextAzimuthCrossing.swift +// Solstice +// +// Created by Daniel Eden on 24/04/2026. +// + +import Foundation +import AppIntents +import CoreLocation + +struct GetNextAzimuthCrossing: AppIntent { + static var title: LocalizedStringResource = "Get Next Sun Azimuth Crossing" + static var description = IntentDescription("Returns the next time in the following 24 hours that the sun's azimuth (compass bearing) crosses a given value in a given direction (clockwise, counterclockwise, or either). Returns no value if no such crossing occurs.") + + @Parameter(title: "Azimuth") + var azimuth: Measurement + + @Parameter(title: "Direction", default: .either) + var direction: AzimuthCrossingDirection + + @Parameter(title: "Start Date") + var startDate: Date? + + @Parameter(title: "Location") + var location: LocationAppEntity? + + static var parameterSummary: some ParameterSummary { + Summary("Next time sun azimuth crosses \(\.$azimuth) (\(\.$direction)) after \(\.$startDate) in \(\.$location)") + } + + func perform() async throws -> some IntentResult & ReturnsValue { + let start = startDate ?? .now + let (coordinate, timeZone) = try await resolveSunGeometryLocation(location) + let targetDegrees = azimuth.converted(to: .degrees).value + + let crossing = findNextAzimuthCrossing( + targetDegrees: targetDegrees, + direction: direction, + start: start, + coordinate: coordinate, + timeZone: timeZone + ) + + return .result(value: crossing) + } +} diff --git a/Intents/GetSolarNoon.swift b/Intents/GetSolarNoon.swift new file mode 100644 index 00000000..68bd4645 --- /dev/null +++ b/Intents/GetSolarNoon.swift @@ -0,0 +1,33 @@ +// +// GetSolarNoon.swift +// Solstice +// +// Created by Daniel Eden on 24/04/2026. +// + +import Foundation +import AppIntents +import CoreLocation + +struct GetSolarNoon: AppIntent { + static var title: LocalizedStringResource = "Get Solar Noon" + static var description = IntentDescription("Returns the time at which the sun crosses the local meridian (solar noon) on the given date.") + + @Parameter(title: "Date") + var date: Date? + + @Parameter(title: "Location") + var location: LocationAppEntity? + + static var parameterSummary: some ParameterSummary { + Summary("Get solar noon on \(\.$date) in \(\.$location)") + } + + func perform() async throws -> some IntentResult & ReturnsValue { + let evaluationDate = date ?? .now + let (coordinate, timeZone) = try await resolveSunGeometryLocation(location) + + let solar = NTSolar(for: evaluationDate, coordinate: coordinate, timeZone: timeZone) + return .result(value: solar?.solarNoon) + } +} diff --git a/Intents/GetSunAltitude.swift b/Intents/GetSunAltitude.swift new file mode 100644 index 00000000..8ae82e07 --- /dev/null +++ b/Intents/GetSunAltitude.swift @@ -0,0 +1,37 @@ +// +// GetSunAltitude.swift +// Solstice +// +// Created by Daniel Eden on 24/04/2026. +// + +import Foundation +import AppIntents +import CoreLocation + +struct GetSunAltitude: AppIntent { + static var title: LocalizedStringResource = "Get Sun Altitude" + static var description = IntentDescription("Returns the sun's altitude above the horizon in degrees. Negative values mean the sun is below the horizon.") + + @Parameter(title: "Date") + var date: Date? + + @Parameter(title: "Location") + var location: LocationAppEntity? + + static var parameterSummary: some ParameterSummary { + Summary("Get sun altitude at \(\.$date) in \(\.$location)") + } + + func perform() async throws -> some IntentResult & ReturnsValue> { + let evaluationDate = date ?? .now + let (coordinate, timeZone) = try await resolveSunGeometryLocation(location) + + guard let solar = NTSolar(for: evaluationDate, coordinate: coordinate, timeZone: timeZone) else { + return .result(value: Measurement(value: 0, unit: .degrees)) + } + + let degrees = solar.altitude(at: evaluationDate) + return .result(value: Measurement(value: degrees, unit: .degrees)) + } +} diff --git a/Intents/GetSunAzimuth.swift b/Intents/GetSunAzimuth.swift new file mode 100644 index 00000000..03c9132c --- /dev/null +++ b/Intents/GetSunAzimuth.swift @@ -0,0 +1,37 @@ +// +// GetSunAzimuth.swift +// Solstice +// +// Created by Daniel Eden on 24/04/2026. +// + +import Foundation +import AppIntents +import CoreLocation + +struct GetSunAzimuth: AppIntent { + static var title: LocalizedStringResource = "Get Sun Azimuth" + static var description = IntentDescription("Returns the sun's compass bearing (azimuth) in degrees, measured clockwise from true north. 0° = North, 90° = East, 180° = South, 270° = West.") + + @Parameter(title: "Date") + var date: Date? + + @Parameter(title: "Location") + var location: LocationAppEntity? + + static var parameterSummary: some ParameterSummary { + Summary("Get sun azimuth at \(\.$date) in \(\.$location)") + } + + func perform() async throws -> some IntentResult & ReturnsValue> { + let evaluationDate = date ?? .now + let (coordinate, timeZone) = try await resolveSunGeometryLocation(location) + + guard let solar = NTSolar(for: evaluationDate, coordinate: coordinate, timeZone: timeZone) else { + return .result(value: Measurement(value: 0, unit: .degrees)) + } + + let degrees = solar.azimuth(at: evaluationDate) + return .result(value: Measurement(value: degrees, unit: .degrees)) + } +} diff --git a/Intents/GetSunPositionAtTime.swift b/Intents/GetSunPositionAtTime.swift new file mode 100644 index 00000000..de111f39 --- /dev/null +++ b/Intents/GetSunPositionAtTime.swift @@ -0,0 +1,36 @@ +// +// GetSunPositionAtTime.swift +// Solstice +// +// Created by Daniel Eden on 24/04/2026. +// + +import Foundation +import AppIntents +import CoreLocation + +struct GetSunPositionAtTime: AppIntent { + static var title: LocalizedStringResource = "Get Sun Position" + static var description = IntentDescription("Returns both the sun's altitude and azimuth at a given date and location, so downstream Shortcut actions can consume either as a magic variable.") + + @Parameter(title: "Date") + var date: Date + + @Parameter(title: "Location") + var location: LocationAppEntity? + + static var parameterSummary: some ParameterSummary { + Summary("Get sun position at \(\.$date) in \(\.$location)") + } + + func perform() async throws -> some IntentResult & ReturnsValue { + let (coordinate, timeZone) = try await resolveSunGeometryLocation(location) + + guard let solar = NTSolar(for: date, coordinate: coordinate, timeZone: timeZone) else { + return .result(value: SunPositionEntity()) + } + + let (altitude, azimuth) = solar.altitudeAndAzimuth(at: date) + return .result(value: SunPositionEntity(altitudeDegrees: altitude, azimuthDegrees: azimuth)) + } +} diff --git a/Intents/IsSunOnBearing.swift b/Intents/IsSunOnBearing.swift new file mode 100644 index 00000000..c67991c8 --- /dev/null +++ b/Intents/IsSunOnBearing.swift @@ -0,0 +1,50 @@ +// +// IsSunOnBearing.swift +// Solstice +// +// Created by Daniel Eden on 24/04/2026. +// + +import Foundation +import AppIntents +import CoreLocation + +struct IsSunOnBearing: AppIntent { + static var title: LocalizedStringResource = "Is Sun Shining On Bearing" + static var description = IntentDescription("Returns true if the sun is above the horizon and its azimuth is within a given tolerance of a compass bearing. Useful for automations like \"is the sun shining on my east-facing wall?\".") + + @Parameter(title: "Bearing") + var bearing: Measurement + + @Parameter(title: "Tolerance") + var tolerance: Measurement? + + @Parameter(title: "Date") + var date: Date? + + @Parameter(title: "Location") + var location: LocationAppEntity? + + static var parameterSummary: some ParameterSummary { + Summary("Is the sun shining on bearing \(\.$bearing) (±\(\.$tolerance)) at \(\.$date) in \(\.$location)") + } + + func perform() async throws -> some IntentResult & ReturnsValue { + let evaluationDate = date ?? .now + let (coordinate, timeZone) = try await resolveSunGeometryLocation(location) + + guard let solar = NTSolar(for: evaluationDate, coordinate: coordinate, timeZone: timeZone) else { + return .result(value: false) + } + + let (altitude, azimuth) = solar.altitudeAndAzimuth(at: evaluationDate) + guard altitude > 0 else { + return .result(value: false) + } + + let bearingDegrees = bearing.converted(to: .degrees).value + let toleranceDegrees = abs((tolerance ?? Measurement(value: 60, unit: .degrees)).converted(to: .degrees).value) + let diff = signedAngularDifference(from: bearingDegrees, to: azimuth) + return .result(value: abs(diff) <= toleranceDegrees) + } +} diff --git a/Intents/IsSunUp.swift b/Intents/IsSunUp.swift new file mode 100644 index 00000000..877e2004 --- /dev/null +++ b/Intents/IsSunUp.swift @@ -0,0 +1,36 @@ +// +// IsSunUp.swift +// Solstice +// +// Created by Daniel Eden on 24/04/2026. +// + +import Foundation +import AppIntents +import CoreLocation + +struct IsSunUp: AppIntent { + static var title: LocalizedStringResource = "Is Sun Up" + static var description = IntentDescription("Returns true if the sun is currently above the horizon at the given location.") + + @Parameter(title: "Date") + var date: Date? + + @Parameter(title: "Location") + var location: LocationAppEntity? + + static var parameterSummary: some ParameterSummary { + Summary("Is the sun up at \(\.$date) in \(\.$location)") + } + + func perform() async throws -> some IntentResult & ReturnsValue { + let evaluationDate = date ?? .now + let (coordinate, timeZone) = try await resolveSunGeometryLocation(location) + + guard let solar = NTSolar(for: evaluationDate, coordinate: coordinate, timeZone: timeZone) else { + return .result(value: false) + } + + return .result(value: solar.altitude(at: evaluationDate) > 0) + } +} diff --git a/Intents/SolsticeShortcutsProvider.swift b/Intents/SolsticeShortcutsProvider.swift index e1a7a2ce..cb1db867 100644 --- a/Intents/SolsticeShortcutsProvider.swift +++ b/Intents/SolsticeShortcutsProvider.swift @@ -45,6 +45,57 @@ struct SolsticeShortcutsProvider: AppShortcutsProvider { shortTitle: "View remaining daylight", systemImageName: "timer" ), + AppShortcut( + intent: GetSunAltitude(), + phrases: [ + "Get sun altitude from \(.applicationName)", + "What's the sun's altitude in \(.applicationName)", + ], + shortTitle: "Get sun altitude", + systemImageName: "arrow.up.forward" + ), + AppShortcut( + intent: GetSunAzimuth(), + phrases: [ + "Get sun azimuth from \(.applicationName)", + "What's the sun's azimuth in \(.applicationName)", + ], + shortTitle: "Get sun azimuth", + systemImageName: "safari" + ), + AppShortcut( + intent: IsSunUp(), + phrases: [ + "Is the sun up in \(.applicationName)", + ], + shortTitle: "Is sun up", + systemImageName: "sun.horizon" + ), + AppShortcut( + intent: GetSolarNoon(), + phrases: [ + "Get solar noon from \(.applicationName)", + "When is solar noon in \(.applicationName)", + ], + shortTitle: "Get solar noon", + systemImageName: "sun.max.fill" + ), + AppShortcut( + intent: IsSunOnBearing(), + phrases: [ + "Is the sun shining on a bearing in \(.applicationName)", + ], + shortTitle: "Is sun on bearing", + systemImageName: "sun.max.circle" + ), + AppShortcut( + intent: GetSunPositionAtTime(), + phrases: [ + "Get sun position from \(.applicationName)", + ], + shortTitle: "Get sun position", + systemImageName: "location.north.line" + ), ] } } diff --git a/Intents/SunGeometryHelpers.swift b/Intents/SunGeometryHelpers.swift new file mode 100644 index 00000000..79a57b29 --- /dev/null +++ b/Intents/SunGeometryHelpers.swift @@ -0,0 +1,257 @@ +// +// SunGeometryHelpers.swift +// Solstice +// +// Created by Daniel Eden on 24/04/2026. +// + +import Foundation +import AppIntents +import CoreLocation + +// MARK: - Location Resolution + +/// Resolves a `LocationAppEntity?` parameter from a sun-geometry App Intent +/// into a concrete coordinate + timezone pair, falling back to the device's +/// current location when the entity is nil or represents the current location. +func resolveSunGeometryLocation(_ entity: LocationAppEntity?) async throws -> (coordinate: CLLocationCoordinate2D, timeZone: TimeZone) { + if let entity, !entity.isCurrentLocation { + let tz = entity.timeZoneIdentifier.flatMap(TimeZone.init(identifier:)) ?? .autoupdatingCurrent + return (CLLocationCoordinate2D(latitude: entity.latitude, longitude: entity.longitude), tz) + } + + let location = try await CurrentLocation.fetchCurrentLocation() + let tz = await reverseGeocodedTimeZone(for: location) ?? .autoupdatingCurrent + return (location.coordinate, tz) +} + +private let sunGeometryGeocoder = CLGeocoder() + +private func reverseGeocodedTimeZone(for location: CLLocation) async -> TimeZone? { + do { + let placemarks = try await sunGeometryGeocoder.reverseGeocodeLocation(location) + return placemarks.first?.timeZone + } catch { + return nil + } +} + +// MARK: - Angular Math + +/// Returns the signed angular difference `to - from` wrapped into `-180...180`. +/// Positive = `to` is clockwise from `from`; negative = counterclockwise. +func signedAngularDifference(from: Double, to: Double) -> Double { + var delta = (to - from).truncatingRemainder(dividingBy: 360.0) + if delta > 180 { + delta -= 360 + } else if delta <= -180 { + delta += 360 + } + return delta +} + +// MARK: - Sun Position Entity + +/// Transient entity exposing both altitude and azimuth for the sun at a point +/// in time. Used as the return value of `GetSunPositionAtTime` so both +/// properties are individually available as magic variables in Shortcuts. +struct SunPositionEntity: TransientAppEntity { + static var typeDisplayRepresentation: TypeDisplayRepresentation { + TypeDisplayRepresentation(name: "Sun Position") + } + + @Property(title: "Altitude") + var altitude: Measurement + + @Property(title: "Azimuth") + var azimuth: Measurement + + var displayRepresentation: DisplayRepresentation { + DisplayRepresentation( + title: "Sun Position", + subtitle: "Altitude \(altitude.formatted()), Azimuth \(azimuth.formatted())" + ) + } + + init() { + self.altitude = Measurement(value: 0, unit: .degrees) + self.azimuth = Measurement(value: 0, unit: .degrees) + } + + init(altitudeDegrees: Double, azimuthDegrees: Double) { + self.altitude = Measurement(value: altitudeDegrees, unit: .degrees) + self.azimuth = Measurement(value: azimuthDegrees, unit: .degrees) + } +} + +// MARK: - Crossing Direction Enums + +enum AltitudeCrossingDirection: String, AppEnum { + case rising + case falling + case either + + static var typeDisplayRepresentation: TypeDisplayRepresentation { + TypeDisplayRepresentation(name: "Altitude Direction") + } + + static var caseDisplayRepresentations: [AltitudeCrossingDirection: DisplayRepresentation] = [ + .rising: "Rising", + .falling: "Falling", + .either: "Either", + ] +} + +enum AzimuthCrossingDirection: String, AppEnum { + case clockwise + case counterclockwise + case either + + static var typeDisplayRepresentation: TypeDisplayRepresentation { + TypeDisplayRepresentation(name: "Azimuth Direction") + } + + static var caseDisplayRepresentations: [AzimuthCrossingDirection: DisplayRepresentation] = [ + .clockwise: "Clockwise", + .counterclockwise: "Counterclockwise", + .either: "Either", + ] +} + +// MARK: - Crossing Search + +private let crossingSearchWindow: TimeInterval = 24 * 60 * 60 +private let crossingSampleInterval: TimeInterval = 60 +private let crossingBisectionPrecision: TimeInterval = 1 + +/// Finds the next time the sun's altitude crosses `targetDegrees` in the +/// specified direction, within 24 hours of `start`. Returns `nil` if no such +/// crossing exists in the window. +/// +/// The sun's altitude is a smooth function of time, so we sample at a coarse +/// interval until we see a sign change in `altitude - target` that matches +/// the direction, then bisect within the bracket. +func findNextAltitudeCrossing( + targetDegrees: Double, + direction: AltitudeCrossingDirection, + start: Date, + coordinate: CLLocationCoordinate2D, + timeZone: TimeZone +) -> Date? { + guard let solar = NTSolar(for: start, coordinate: coordinate, timeZone: timeZone) else { + return nil + } + + let end = start.addingTimeInterval(crossingSearchWindow) + let evaluate: (Date) -> Double = { solar.altitude(at: $0) - targetDegrees } + + var t0 = start + var v0 = evaluate(t0) + + while t0 < end { + let t1 = min(t0.addingTimeInterval(crossingSampleInterval), end) + let v1 = evaluate(t1) + + let matchesDirection: Bool = { + switch direction { + case .rising: return v0 < 0 && v1 >= 0 + case .falling: return v0 > 0 && v1 <= 0 + case .either: return (v0 < 0 && v1 > 0) || (v0 > 0 && v1 < 0) || v1 == 0 + } + }() + + if matchesDirection { + return bisect(low: t0, high: t1, lowValue: v0, evaluate: evaluate) + } + + t0 = t1 + v0 = v1 + } + + return nil +} + +/// Finds the next time the sun's azimuth crosses `targetDegrees` in the +/// specified direction, within 24 hours of `start`. Returns `nil` if no such +/// crossing occurs. +/// +/// Azimuth is discontinuous at 0°/360°; we operate on the signed angular +/// difference (`azimuth - target`, wrapped to `-180...180`) so that a +/// zero-crossing in this signed diff corresponds to an actual crossing of +/// the target bearing. Samples bracketing the wrap discontinuity (|Δ| > 180°) +/// are skipped. +func findNextAzimuthCrossing( + targetDegrees: Double, + direction: AzimuthCrossingDirection, + start: Date, + coordinate: CLLocationCoordinate2D, + timeZone: TimeZone +) -> Date? { + guard let solar = NTSolar(for: start, coordinate: coordinate, timeZone: timeZone) else { + return nil + } + + let end = start.addingTimeInterval(crossingSearchWindow) + let evaluate: (Date) -> Double = { signedAngularDifference(from: targetDegrees, to: solar.azimuth(at: $0)) } + + var t0 = start + var v0 = evaluate(t0) + + while t0 < end { + let t1 = min(t0.addingTimeInterval(crossingSampleInterval), end) + let v1 = evaluate(t1) + + // Skip samples straddling the ±180° wrap discontinuity. + if abs(v1 - v0) > 180 { + t0 = t1 + v0 = v1 + continue + } + + let matchesDirection: Bool = { + switch direction { + case .clockwise: return v0 < 0 && v1 >= 0 + case .counterclockwise: return v0 > 0 && v1 <= 0 + case .either: return (v0 < 0 && v1 > 0) || (v0 > 0 && v1 < 0) || v1 == 0 + } + }() + + if matchesDirection { + return bisect(low: t0, high: t1, lowValue: v0, evaluate: evaluate) + } + + t0 = t1 + v0 = v1 + } + + return nil +} + +/// Bisect over `[low, high]` to find the time where `evaluate` crosses zero. +/// Assumes `lowValue` and `evaluate(high)` have opposite signs (or `lowValue == 0`). +private func bisect( + low: Date, + high: Date, + lowValue: Double, + evaluate: (Date) -> Double +) -> Date { + var lo = low + var hi = high + var loVal = lowValue + + while hi.timeIntervalSince(lo) > crossingBisectionPrecision { + let mid = lo.addingTimeInterval(hi.timeIntervalSince(lo) / 2) + let midVal = evaluate(mid) + if midVal == 0 { + return mid + } + if (loVal < 0 && midVal < 0) || (loVal > 0 && midVal > 0) { + lo = mid + loVal = midVal + } else { + hi = mid + } + } + + return lo.addingTimeInterval(hi.timeIntervalSince(lo) / 2) +} diff --git a/Solstice.xcodeproj/project.pbxproj b/Solstice.xcodeproj/project.pbxproj index 8bc5c357..10c08cea 100644 --- a/Solstice.xcodeproj/project.pbxproj +++ b/Solstice.xcodeproj/project.pbxproj @@ -184,6 +184,15 @@ 7195128429B73341009D282F /* SkyGradient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7117008A29A52B04001BE478 /* SkyGradient.swift */; }; 7195128A29B8855F009D282F /* View+EllipticalEdgeMask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7195120029AFBBEC009D282F /* View+EllipticalEdgeMask.swift */; }; 7197DD8029CA312200FEBE55 /* SolsticeShortcutsProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7197DD7F29CA312200FEBE55 /* SolsticeShortcutsProvider.swift */; }; + 71BEEF112F4AA00100BEEFBE /* SunGeometryHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71BEEF012F4AA00100BEEFBE /* SunGeometryHelpers.swift */; }; + 71BEEF122F4AA00100BEEFBE /* GetSunAltitude.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71BEEF022F4AA00100BEEFBE /* GetSunAltitude.swift */; }; + 71BEEF132F4AA00100BEEFBE /* GetSunAzimuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71BEEF032F4AA00100BEEFBE /* GetSunAzimuth.swift */; }; + 71BEEF142F4AA00100BEEFBE /* IsSunUp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71BEEF042F4AA00100BEEFBE /* IsSunUp.swift */; }; + 71BEEF152F4AA00100BEEFBE /* GetSolarNoon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71BEEF052F4AA00100BEEFBE /* GetSolarNoon.swift */; }; + 71BEEF162F4AA00100BEEFBE /* IsSunOnBearing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71BEEF062F4AA00100BEEFBE /* IsSunOnBearing.swift */; }; + 71BEEF172F4AA00100BEEFBE /* GetSunPositionAtTime.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71BEEF072F4AA00100BEEFBE /* GetSunPositionAtTime.swift */; }; + 71BEEF182F4AA00100BEEFBE /* GetNextAltitudeCrossing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71BEEF082F4AA00100BEEFBE /* GetNextAltitudeCrossing.swift */; }; + 71BEEF192F4AA00100BEEFBE /* GetNextAzimuthCrossing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71BEEF092F4AA00100BEEFBE /* GetNextAzimuthCrossing.swift */; }; 7198468528E5895E00E866CE /* SolsticeApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7198468428E5895E00E866CE /* SolsticeApp.swift */; }; 7198468728E5895E00E866CE /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7198468628E5895E00E866CE /* ContentView.swift */; }; 7198468C28E5895F00E866CE /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 7198468B28E5895F00E866CE /* Preview Assets.xcassets */; }; @@ -446,6 +455,15 @@ 7195120229AFBD61009D282F /* WidgetExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = WidgetExtension.entitlements; sourceTree = ""; }; 7195125729B48ECD009D282F /* TimeZone++.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TimeZone++.swift"; sourceTree = ""; }; 7197DD7F29CA312200FEBE55 /* SolsticeShortcutsProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SolsticeShortcutsProvider.swift; sourceTree = ""; }; + 71BEEF012F4AA00100BEEFBE /* SunGeometryHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SunGeometryHelpers.swift; sourceTree = ""; }; + 71BEEF022F4AA00100BEEFBE /* GetSunAltitude.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetSunAltitude.swift; sourceTree = ""; }; + 71BEEF032F4AA00100BEEFBE /* GetSunAzimuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetSunAzimuth.swift; sourceTree = ""; }; + 71BEEF042F4AA00100BEEFBE /* IsSunUp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IsSunUp.swift; sourceTree = ""; }; + 71BEEF052F4AA00100BEEFBE /* GetSolarNoon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetSolarNoon.swift; sourceTree = ""; }; + 71BEEF062F4AA00100BEEFBE /* IsSunOnBearing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IsSunOnBearing.swift; sourceTree = ""; }; + 71BEEF072F4AA00100BEEFBE /* GetSunPositionAtTime.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetSunPositionAtTime.swift; sourceTree = ""; }; + 71BEEF082F4AA00100BEEFBE /* GetNextAltitudeCrossing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetNextAltitudeCrossing.swift; sourceTree = ""; }; + 71BEEF092F4AA00100BEEFBE /* GetNextAzimuthCrossing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetNextAzimuthCrossing.swift; sourceTree = ""; }; 7198468128E5895E00E866CE /* Solstice.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Solstice.app; sourceTree = BUILT_PRODUCTS_DIR; }; 7198468428E5895E00E866CE /* SolsticeApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SolsticeApp.swift; sourceTree = ""; }; 7198468628E5895E00E866CE /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; @@ -664,11 +682,20 @@ 719511F429AF5CA8009D282F /* Intents */ = { isa = PBXGroup; children = ( + 71BEEF082F4AA00100BEEFBE /* GetNextAltitudeCrossing.swift */, + 71BEEF092F4AA00100BEEFBE /* GetNextAzimuthCrossing.swift */, + 71BEEF052F4AA00100BEEFBE /* GetSolarNoon.swift */, + 71BEEF022F4AA00100BEEFBE /* GetSunAltitude.swift */, + 71BEEF032F4AA00100BEEFBE /* GetSunAzimuth.swift */, + 71BEEF072F4AA00100BEEFBE /* GetSunPositionAtTime.swift */, 719511F529AF5CB4009D282F /* GetSunriseTime.swift */, 719511F729AF5CE1009D282F /* GetSunsetTime.swift */, + 71BEEF062F4AA00100BEEFBE /* IsSunOnBearing.swift */, + 71BEEF042F4AA00100BEEFBE /* IsSunUp.swift */, 718177662F2EA2EC007A2E9E /* LocationAppEntity.swift */, 7181775C2F2E9BCF007A2E9E /* SolsticeConfigurationIntent.swift */, 7197DD7F29CA312200FEBE55 /* SolsticeShortcutsProvider.swift */, + 71BEEF012F4AA00100BEEFBE /* SunGeometryHelpers.swift */, 719511F929AF5CF7009D282F /* ViewDaylight.swift */, 719511FB29AF5D0E009D282F /* ViewRemainingDaylight.swift */, ); @@ -1383,6 +1410,15 @@ 71C718222E83361C006A6835 /* TimeTravelCompactView.swift in Sources */, 71E3C4192EA12B030005C884 /* View+backportGlassEffect.swift in Sources */, 7197DD8029CA312200FEBE55 /* SolsticeShortcutsProvider.swift in Sources */, + 71BEEF112F4AA00100BEEFBE /* SunGeometryHelpers.swift in Sources */, + 71BEEF122F4AA00100BEEFBE /* GetSunAltitude.swift in Sources */, + 71BEEF132F4AA00100BEEFBE /* GetSunAzimuth.swift in Sources */, + 71BEEF142F4AA00100BEEFBE /* IsSunUp.swift in Sources */, + 71BEEF152F4AA00100BEEFBE /* GetSolarNoon.swift in Sources */, + 71BEEF162F4AA00100BEEFBE /* IsSunOnBearing.swift in Sources */, + 71BEEF172F4AA00100BEEFBE /* GetSunPositionAtTime.swift in Sources */, + 71BEEF182F4AA00100BEEFBE /* GetNextAltitudeCrossing.swift in Sources */, + 71BEEF192F4AA00100BEEFBE /* GetNextAzimuthCrossing.swift in Sources */, 711DDC762EA7A1A400DFE99B /* GraphicalLocationListRow.swift in Sources */, 711782E42E4E77AF0006CE5B /* View+glassButtonStyle.swift in Sources */, 71C105A72E7D311B00A76EBB /* TimeMachine++.swift in Sources */, @@ -1809,7 +1845,7 @@ GCC_WARN_UNUSED_VARIABLE = YES; INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Solstice uses your location to calculate local sunrise and sunset times"; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 3.1.2; + MARKETING_VERSION = 3.2.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -1871,7 +1907,7 @@ GCC_WARN_UNUSED_VARIABLE = YES; INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Solstice uses your location to calculate local sunrise and sunset times"; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 3.1.2; + MARKETING_VERSION = 3.2.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; diff --git a/Solstice/Helpers/NTSolar.swift b/Solstice/Helpers/NTSolar.swift index 6c91fff2..1b4a6011 100644 --- a/Solstice/Helpers/NTSolar.swift +++ b/Solstice/Helpers/NTSolar.swift @@ -688,31 +688,58 @@ extension NTSolar { /// method), so results are consistent with the sunrise/sunset times already /// computed by this struct. func altitude(at date: Date) -> Double { + altitudeAndAzimuth(at: date).altitude + } + + /// Returns the sun's azimuth in degrees at the given instant. + /// Measured clockwise from true north, in the range `0..<360` + /// (0 = North, 90 = East, 180 = South, 270 = West). + func azimuth(at date: Date) -> Double { + altitudeAndAzimuth(at: date).azimuth + } + + /// Returns the sun's altitude and azimuth at the given instant. + /// + /// - Altitude is in degrees; positive = above horizon, negative = below. + /// - Azimuth is in degrees, measured clockwise from true north (0..<360). + func altitudeAndAzimuth(at date: Date) -> (altitude: Double, azimuth: Double) { var utcCal = Calendar(identifier: .gregorian) utcCal.timeZone = TimeZone(secondsFromGMT: 0)! let comps = utcCal.dateComponents([.year, .month, .day, .hour, .minute, .second], from: date) guard let year = comps.year, let month = comps.month, let day = comps.day, let hour = comps.hour, let minute = comps.minute, let second = comps.second - else { return 0 } + else { return (0, 0) } // UT as a decimal hour let UT = Double(hour) + Double(minute) / 60.0 + Double(second) / 3600.0 - // Days since J2000.0 at the exact instant (used for both sun position and sidereal time) + // Days since J2000.0 at the exact instant let d = Double(NTSolar.days_since_2000_Jan_0(y: year, m: month, d: day)) + UT / 24.0 // Sun's equatorial coordinates at this instant let (sRA, sdec, _) = NTSolar.sun_RA_dec(d: d) - // Local Mean Sidereal Time in degrees: GMST0(d) + UT_in_degrees + longitude + // Local Mean Sidereal Time in degrees let LMST = NTSolar.revolution(x: NTSolar.GMST0(d: d) + UT * 15.0 + coordinate.longitude) - // Local Hour Angle: how far the sun has moved past the meridian + // Local Hour Angle (positive westward after the meridian) let HA = LMST - sRA - // Standard altitude formula: sin(alt) = sin(lat)sin(dec) + cos(lat)cos(dec)cos(HA) - let sin_alt = NTSolar.sind(x: coordinate.latitude) * NTSolar.sind(x: sdec) - + NTSolar.cosd(x: coordinate.latitude) * NTSolar.cosd(x: sdec) * NTSolar.cosd(x: HA) - return NTSolar.asind(x: sin_alt) + let lat = coordinate.latitude + + // Altitude: sin(alt) = sin(lat)sin(dec) + cos(lat)cos(dec)cos(HA) + let sin_alt = NTSolar.sind(x: lat) * NTSolar.sind(x: sdec) + + NTSolar.cosd(x: lat) * NTSolar.cosd(x: sdec) * NTSolar.cosd(x: HA) + let altitude = NTSolar.asind(x: sin_alt) + + // Azimuth measured westward from south, then shifted by +180° to get + // the compass bearing from true north (clockwise). + let azFromSouth = NTSolar.atan2d( + y: NTSolar.sind(x: HA), + x: NTSolar.cosd(x: HA) * NTSolar.sind(x: lat) - NTSolar.tand(x: sdec) * NTSolar.cosd(x: lat) + ) + let azimuth = NTSolar.revolution(x: azFromSouth + 180.0) + + return (altitude, azimuth) } } diff --git a/SolsticeTests/SunGeometryTests.swift b/SolsticeTests/SunGeometryTests.swift new file mode 100644 index 00000000..efb69e1f --- /dev/null +++ b/SolsticeTests/SunGeometryTests.swift @@ -0,0 +1,251 @@ +// +// SunGeometryTests.swift +// SolsticeTests +// +// Covers the pure-math building blocks used by the sun-geometry App Intents: +// - signedAngularDifference (wrap-around handling) +// - NTSolar altitude/azimuth (self-consistency with sunrise/sunset/solarNoon) +// - findNextAltitudeCrossing / findNextAzimuthCrossing (bisection) +// + +import Testing +import Foundation +import CoreLocation +@testable import Solstice + +struct SunGeometryTests { + // MARK: - Reference locations + + /// Greenwich Observatory — longitude ~0° keeps UT == local solar time for sanity checks. + static let greenwich = CLLocationCoordinate2D(latitude: 51.4779, longitude: -0.0015) + /// Sydney — Southern Hemisphere sanity check. + static let sydney = CLLocationCoordinate2D(latitude: -33.8688, longitude: 151.2093) + /// Quito — near-equatorial, useful for high-altitude-noon checks. + static let quito = CLLocationCoordinate2D(latitude: -0.1807, longitude: -78.4678) + + static func utcDate(year: Int, month: Int, day: Int, hour: Int = 12, minute: Int = 0) -> Date { + var components = DateComponents() + components.year = year + components.month = month + components.day = day + components.hour = hour + components.minute = minute + components.second = 0 + components.timeZone = TimeZone(secondsFromGMT: 0) + return Calendar(identifier: .gregorian).date(from: components)! + } + + // MARK: - signedAngularDifference + + @Test("signedAngularDifference handles plain cases") + func signedDiffPlain() { + #expect(abs(signedAngularDifference(from: 0, to: 0)) < 1e-9) + #expect(abs(signedAngularDifference(from: 90, to: 180) - 90) < 1e-9) + #expect(abs(signedAngularDifference(from: 180, to: 90) - (-90)) < 1e-9) + } + + @Test("signedAngularDifference wraps around 0/360") + func signedDiffWraps() { + // From 350 to 10 is +20 (clockwise), not -340. + #expect(abs(signedAngularDifference(from: 350, to: 10) - 20) < 1e-9) + // From 10 to 350 is -20 (counterclockwise). + #expect(abs(signedAngularDifference(from: 10, to: 350) - (-20)) < 1e-9) + // From 0 to 359 is -1, not +359. + #expect(abs(signedAngularDifference(from: 0, to: 359) - (-1)) < 1e-9) + } + + @Test("signedAngularDifference at the 180° antipode is in (-180, 180]") + func signedDiffAntipode() { + let diff = signedAngularDifference(from: 0, to: 180) + #expect(abs(abs(diff) - 180) < 1e-9) + } + + @Test("signedAngularDifference tolerates out-of-range inputs") + func signedDiffOutOfRange() { + // 720° == 360° == 0°, so diff from 0 to 720 is 0. + #expect(abs(signedAngularDifference(from: 0, to: 720)) < 1e-9) + // Negative bearings normalize the same way. + #expect(abs(signedAngularDifference(from: 0, to: -10) - (-10)) < 1e-9) + } + + // MARK: - IsSunOnBearing logic (composed from signed diff + altitude) + + @Test("IsSunOnBearing logic handles east wall across midnight boundary of bearing") + func sunOnBearingEastWallLogic() { + // Simulate: sun's azimuth is 5°, east wall faces 355°, tolerance 60°. + // Angular distance wraps around: diff is +10°, which is within tolerance. + let diff = signedAngularDifference(from: 355, to: 5) + #expect(abs(diff) <= 60) + } + + @Test("IsSunOnBearing logic rejects when sun is opposite the wall") + func sunOnBearingOppositeWallLogic() { + // East wall faces 90°, sun is at 280° (roughly west-northwest). + let diff = signedAngularDifference(from: 90, to: 280) + #expect(abs(diff) > 60) + } + + // MARK: - NTSolar altitude/azimuth sanity + + @Test("Sun altitude is strictly positive between sunrise and sunset") + func altitudePositiveDuringDay() throws { + let date = Self.utcDate(year: 2025, month: 6, day: 21, hour: 12) + let solar = try #require(NTSolar(for: date, coordinate: Self.greenwich, timeZone: .gmt)) + let sunrise = try #require(solar.sunrise) + let sunset = try #require(solar.sunset) + + let midday = Date(timeIntervalSince1970: (sunrise.timeIntervalSince1970 + sunset.timeIntervalSince1970) / 2) + #expect(solar.altitude(at: midday) > 0) + } + + @Test("Sun altitude is approximately zero at sunrise and sunset") + func altitudeNearZeroAtHorizonEvents() throws { + let date = Self.utcDate(year: 2025, month: 3, day: 20, hour: 12) + let solar = try #require(NTSolar(for: date, coordinate: Self.greenwich, timeZone: .gmt)) + let sunrise = try #require(solar.sunrise) + let sunset = try #require(solar.sunset) + + // NTSolar applies a -35'/-0.583° geometric correction for refraction & upper limb. + // Actual center-of-sun altitude at sunrise/sunset is ~-0.583°, so allow 1° slack. + #expect(abs(solar.altitude(at: sunrise)) < 1.0) + #expect(abs(solar.altitude(at: sunset)) < 1.0) + } + + @Test("At solar noon the sun is within a degree of due south (northern hemisphere)") + func azimuthAtSolarNoonGreenwich() throws { + let date = Self.utcDate(year: 2025, month: 6, day: 21, hour: 12) + let solar = try #require(NTSolar(for: date, coordinate: Self.greenwich, timeZone: .gmt)) + let noon = try #require(solar.solarNoon) + + let az = solar.azimuth(at: noon) + let diffFromSouth = abs(signedAngularDifference(from: 180, to: az)) + #expect(diffFromSouth < 1.0) + } + + @Test("At solar noon the sun is within a degree of due north (southern hemisphere)") + func azimuthAtSolarNoonSydney() throws { + let date = Self.utcDate(year: 2025, month: 6, day: 21, hour: 2) // noon-ish in Sydney + let tz = TimeZone(identifier: "Australia/Sydney")! + let solar = try #require(NTSolar(for: date, coordinate: Self.sydney, timeZone: tz)) + let noon = try #require(solar.solarNoon) + + let az = solar.azimuth(at: noon) + let diffFromNorth = abs(signedAngularDifference(from: 0, to: az)) + #expect(diffFromNorth < 1.0) + } + + @Test("Azimuth is always in 0..<360") + func azimuthInRange() throws { + let date = Self.utcDate(year: 2025, month: 9, day: 22, hour: 6) + let solar = try #require(NTSolar(for: date, coordinate: Self.quito, timeZone: .gmt)) + for hour in stride(from: 0.0, through: 24.0, by: 0.5) { + let sample = date.addingTimeInterval(hour * 3600) + let az = solar.azimuth(at: sample) + #expect(az >= 0 && az < 360) + } + } + + // MARK: - findNextAltitudeCrossing + + @Test("findNextAltitudeCrossing rising @ 0° matches NTSolar's sunrise") + func altitudeCrossingRisingAtHorizon() throws { + // Start search a few hours before sunrise. + let date = Self.utcDate(year: 2025, month: 6, day: 21, hour: 0) + let solar = try #require(NTSolar(for: date, coordinate: Self.greenwich, timeZone: .gmt)) + let sunrise = try #require(solar.sunrise) + + let crossing = try #require(findNextAltitudeCrossing( + targetDegrees: 0, + direction: .rising, + start: date, + coordinate: Self.greenwich, + timeZone: .gmt + )) + + // Bisection should land within ~2 minutes of NTSolar's sunrise. They don't + // match exactly because NTSolar's sunrise includes an atmospheric refraction + // correction (-35') that the raw altitude function doesn't apply. + #expect(abs(crossing.timeIntervalSince(sunrise)) < 10 * 60) + } + + @Test("findNextAltitudeCrossing falling @ 0° matches NTSolar's sunset") + func altitudeCrossingFallingAtHorizon() throws { + let date = Self.utcDate(year: 2025, month: 6, day: 21, hour: 0) + let solar = try #require(NTSolar(for: date, coordinate: Self.greenwich, timeZone: .gmt)) + let sunset = try #require(solar.sunset) + + let crossing = try #require(findNextAltitudeCrossing( + targetDegrees: 0, + direction: .falling, + start: date, + coordinate: Self.greenwich, + timeZone: .gmt + )) + + #expect(abs(crossing.timeIntervalSince(sunset)) < 10 * 60) + } + + @Test("findNextAltitudeCrossing rising target can be interrogated") + func altitudeCrossingRisingTarget() throws { + // On midsummer at Greenwich the sun comfortably passes 30° altitude. + let date = Self.utcDate(year: 2025, month: 6, day: 21, hour: 0) + let crossing = try #require(findNextAltitudeCrossing( + targetDegrees: 30, + direction: .rising, + start: date, + coordinate: Self.greenwich, + timeZone: .gmt + )) + + let solar = try #require(NTSolar(for: crossing, coordinate: Self.greenwich, timeZone: .gmt)) + #expect(abs(solar.altitude(at: crossing) - 30) < 0.01) + } + + @Test("findNextAltitudeCrossing returns nil for unreachable altitude") + func altitudeCrossingUnreachable() { + // The sun never reaches 89° altitude at Greenwich. + let date = Self.utcDate(year: 2025, month: 6, day: 21, hour: 0) + let crossing = findNextAltitudeCrossing( + targetDegrees: 89, + direction: .rising, + start: date, + coordinate: Self.greenwich, + timeZone: .gmt + ) + #expect(crossing == nil) + } + + // MARK: - findNextAzimuthCrossing + + @Test("findNextAzimuthCrossing clockwise @ 180° matches solar noon (northern hemisphere)") + func azimuthCrossingSouthMeridian() throws { + let date = Self.utcDate(year: 2025, month: 6, day: 21, hour: 0) + let solar = try #require(NTSolar(for: date, coordinate: Self.greenwich, timeZone: .gmt)) + let noon = try #require(solar.solarNoon) + + let crossing = try #require(findNextAzimuthCrossing( + targetDegrees: 180, + direction: .either, + start: date, + coordinate: Self.greenwich, + timeZone: .gmt + )) + + #expect(abs(crossing.timeIntervalSince(noon)) < 5 * 60) + } + + @Test("findNextAzimuthCrossing returns a crossing near 90° (due east) in the morning") + func azimuthCrossingEastMorning() throws { + let date = Self.utcDate(year: 2025, month: 3, day: 20, hour: 0) + let crossing = try #require(findNextAzimuthCrossing( + targetDegrees: 90, + direction: .clockwise, + start: date, + coordinate: Self.greenwich, + timeZone: .gmt + )) + + let solar = try #require(NTSolar(for: crossing, coordinate: Self.greenwich, timeZone: .gmt)) + #expect(abs(signedAngularDifference(from: 90, to: solar.azimuth(at: crossing))) < 0.1) + } +}