Commit 78a9ae10fc
Unsigned
Layout: unified · split
App/Sources/LaunchesScreen.swift +11 −2
| @@ -208,10 +208,19 @@ struct LaunchDetail: View { | |||
| 208 | } | 208 | } |
| 209 | let attributes = LaunchActivityAttributes(launchID: launch.id, rocket: launch.rocket, mission: launch.mission ?? launch.name, location: launch.location) | 209 | let attributes = LaunchActivityAttributes(launchID: launch.id, rocket: launch.rocket, mission: launch.mission ?? launch.name, location: launch.location) |
| 210 | let state = LaunchActivityAttributes.ContentState(net: launch.net, status: launch.status) | 210 | let state = LaunchActivityAttributes.ContentState(net: launch.net, status: launch.status) |
| 211 | let content = ActivityContent(state: state, staleDate: launch.net.addingTimeInterval(3600)) | ||
| 212 | // Live Activities end after 8 hours, so one for a later launch starts an hour before it. | ||
| 213 | let start = launch.net.addingTimeInterval(-3600) | ||
| 211 | do { | 214 | do { |
| 212 | _ = try Activity.request(attributes: attributes, content: .init(state: state, staleDate: launch.net.addingTimeInterval(3600))) | 215 | if start > .now { |
| 216 | let alert = AlertConfiguration(title: "\(attributes.rocket) launches in an hour", body: "\(attributes.mission)", sound: .default) | ||
| 217 | _ = try Activity.request(attributes: attributes, content: content, style: .standard, alertConfiguration: alert, start: start) | ||
| 218 | message = "Countdown will appear on the Lock Screen an hour before launch." | ||
| 219 | } else { | ||
| 220 | _ = try Activity.request(attributes: attributes, content: content) | ||
| 221 | message = "Countdown added to the Lock Screen." | ||
| 222 | } | ||
| 213 | following = true | 223 | following = true |
| 214 | message = "Countdown added to the Lock Screen." | ||
| 215 | } catch { | 224 | } catch { |
| 216 | message = "Couldn't start the countdown: \(error.localizedDescription)" | 225 | message = "Couldn't start the countdown: \(error.localizedDescription)" |
| 217 | } | 226 | } |
Packages/MoonriseKit/Sources/ObservingLog/Equipment.swift +13 −4
| @@ -51,8 +51,12 @@ public struct Equipment: Sendable, Identifiable, Equatable, Hashable { | |||
| 51 | public var notes: String | 51 | public var notes: String |
| 52 | /// Retired items stay in the inventory for old entries but leave the picker. | 52 | /// Retired items stay in the inventory for old entries but leave the picker. |
| 53 | public var retired: Bool | 53 | public var retired: Bool |
| 54 | /// Properties added by hand, in file order. | ||
| 55 | public var otherProperties: [OrgProperty] | ||
| 54 | 56 | ||
| 55 | public init(id: String = UUID().uuidString, kind: Kind, name: String, aperture: Double? = nil, focalLength: Double? = nil, magnification: Double? = nil, fieldOfView: Double? = nil, sensor: String = "", notes: String = "", retired: Bool = false) { | 57 | static let knownKeys: Set = [":ID:", ":TYPE:", ":APERTURE:", ":FOCAL_LENGTH:", ":FOCAL_RATIO:", ":MAGNIFICATION:", ":FIELD_OF_VIEW:", ":SENSOR:", ":RETIRED:"] |
| 58 | |||
| 59 | public init(id: String = UUID().uuidString, kind: Kind, name: String, aperture: Double? = nil, focalLength: Double? = nil, magnification: Double? = nil, fieldOfView: Double? = nil, sensor: String = "", notes: String = "", retired: Bool = false, otherProperties: [OrgProperty] = []) { | ||
| 56 | self.id = id | 60 | self.id = id |
| 57 | self.kind = kind | 61 | self.kind = kind |
| 58 | self.name = name | 62 | self.name = name |
| @@ -63,6 +67,7 @@ public struct Equipment: Sendable, Identifiable, Equatable, Hashable { | |||
| 63 | self.sensor = sensor | 67 | self.sensor = sensor |
| 64 | self.notes = notes | 68 | self.notes = notes |
| 65 | self.retired = retired | 69 | self.retired = retired |
| 70 | self.otherProperties = otherProperties | ||
| 66 | } | 71 | } |
| 67 | 72 | ||
| 68 | /// Focal length over aperture, when both are known. | 73 | /// Focal length over aperture, when both are known. |
| @@ -87,6 +92,7 @@ public struct Equipment: Sendable, Identifiable, Equatable, Hashable { | |||
| 87 | if retired { props.append((":RETIRED:", "t")) } | 92 | if retired { props.append((":RETIRED:", "t")) } |
| 88 | var lines = ["* \(name)", ":PROPERTIES:"] | 93 | var lines = ["* \(name)", ":PROPERTIES:"] |
| 89 | lines += props.map { "\($0.0) \($0.1)" } | 94 | lines += props.map { "\($0.0) \($0.1)" } |
| 95 | lines += otherProperties.map(\.line) | ||
| 90 | lines.append(":END:") | 96 | lines.append(":END:") |
| 91 | let body = notes.trimmingCharacters(in: .whitespacesAndNewlines) | 97 | let body = notes.trimmingCharacters(in: .whitespacesAndNewlines) |
| 92 | if !body.isEmpty { lines += ["", body] } | 98 | if !body.isEmpty { lines += ["", body] } |
| @@ -98,12 +104,14 @@ public struct Equipment: Sendable, Identifiable, Equatable, Hashable { | |||
| 98 | let lines = text.components(separatedBy: "\n") | 104 | let lines = text.components(separatedBy: "\n") |
| 99 | guard let head = lines.first, head.hasPrefix("* ") else { return nil } | 105 | guard let head = lines.first, head.hasPrefix("* ") else { return nil } |
| 100 | var props: [String: String] = [:] | 106 | var props: [String: String] = [:] |
| 107 | var other: [OrgProperty] = [] | ||
| 101 | var i = 1 | 108 | var i = 1 |
| 102 | if i < lines.count, lines[i] == ":PROPERTIES:" { | 109 | if i < lines.count, lines[i] == ":PROPERTIES:" { |
| 103 | i += 1 | 110 | i += 1 |
| 104 | while i < lines.count, lines[i] != ":END:" { | 111 | while i < lines.count, lines[i] != ":END:" { |
| 105 | let parts = lines[i].split(separator: " ", maxSplits: 1).map(String.init) | 112 | if let p = OrgProperty.parse(lines[i]) { |
| 106 | if parts.count == 2 { props[parts[0]] = parts[1] } | 113 | if !Self.knownKeys.contains(p.key) { other.append(p) } else if !p.value.isEmpty { props[p.key] = p.value } |
| 114 | } | ||
| 107 | i += 1 | 115 | i += 1 |
| 108 | } | 116 | } |
| 109 | i += 1 | 117 | i += 1 |
| @@ -122,7 +130,8 @@ public struct Equipment: Sendable, Identifiable, Equatable, Hashable { | |||
| 122 | fieldOfView: leadingNumber(":FIELD_OF_VIEW:"), | 130 | fieldOfView: leadingNumber(":FIELD_OF_VIEW:"), |
| 123 | sensor: props[":SENSOR:"] ?? "", | 131 | sensor: props[":SENSOR:"] ?? "", |
| 124 | notes: lines.dropFirst(i).joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines), | 132 | notes: lines.dropFirst(i).joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines), |
| 125 | retired: props[":RETIRED:"] == "t" | 133 | retired: props[":RETIRED:"] == "t", |
| 134 | otherProperties: other | ||
| 126 | ) | 135 | ) |
| 127 | } | 136 | } |
| 128 | } | 137 | } |
Packages/MoonriseKit/Sources/ObservingLog/Observation.swift +34 −4
| @@ -1,5 +1,26 @@ | |||
| 1 | import Foundation | 1 | import Foundation |
| 2 | 2 | ||
| 3 | /// A property-drawer entry the app does not use, kept so saving does not drop it. | ||
| 4 | public struct OrgProperty: Sendable, Equatable, Hashable { | ||
| 5 | /// With colons, e.g. ":TRANSPARENCY:". | ||
| 6 | public var key: String | ||
| 7 | public var value: String | ||
| 8 | |||
| 9 | public init(key: String, value: String) { | ||
| 10 | self.key = key | ||
| 11 | self.value = value | ||
| 12 | } | ||
| 13 | |||
| 14 | var line: String { value.isEmpty ? key : "\(key) \(value)" } | ||
| 15 | |||
| 16 | /// Splits a drawer line into key and value; nil for lines that are not a property. | ||
| 17 | static func parse(_ line: String) -> OrgProperty? { | ||
| 18 | let parts = line.split(separator: " ", maxSplits: 1).map(String.init) | ||
| 19 | guard let key = parts.first, key.count > 2, key.hasPrefix(":"), key.hasSuffix(":") else { return nil } | ||
| 20 | return OrgProperty(key: key, value: parts.count == 2 ? parts[1] : "") | ||
| 21 | } | ||
| 22 | } | ||
| 23 | |||
| 3 | /// One logged observation, stored as an Org file that Emacs and orgstar can read. | 24 | /// One logged observation, stored as an Org file that Emacs and orgstar can read. |
| 4 | public struct Observation: Sendable, Identifiable, Equatable { | 25 | public struct Observation: Sendable, Identifiable, Equatable { |
| 5 | public var id: String | 26 | public var id: String |
| @@ -15,8 +36,12 @@ public struct Observation: Sendable, Identifiable, Equatable { | |||
| 15 | /// 1 (poor) to 5 (excellent); nil when not noted. | 36 | /// 1 (poor) to 5 (excellent); nil when not noted. |
| 16 | public var seeing: Int? | 37 | public var seeing: Int? |
| 17 | public var notes: String | 38 | public var notes: String |
| 39 | /// Properties added by hand, in file order. | ||
| 40 | public var otherProperties: [OrgProperty] | ||
| 41 | |||
| 42 | static let knownKeys: Set = [":ID:", ":PLACE:", ":COORDINATES:", ":EQUIPMENT:", ":EQUIPMENT_IDS:", ":SEEING:"] | ||
| 18 | 43 | ||
| 19 | public init(id: String = UUID().uuidString, object: String, date: Date, place: String, latitude: Double? = nil, longitude: Double? = nil, equipment: String = "", equipmentIDs: [String] = [], seeing: Int? = nil, notes: String = "") { | 44 | public init(id: String = UUID().uuidString, object: String, date: Date, place: String, latitude: Double? = nil, longitude: Double? = nil, equipment: String = "", equipmentIDs: [String] = [], seeing: Int? = nil, notes: String = "", otherProperties: [OrgProperty] = []) { |
| 20 | self.id = id | 45 | self.id = id |
| 21 | self.object = object | 46 | self.object = object |
| 22 | self.date = date | 47 | self.date = date |
| @@ -27,6 +52,7 @@ public struct Observation: Sendable, Identifiable, Equatable { | |||
| 27 | self.equipmentIDs = equipmentIDs | 52 | self.equipmentIDs = equipmentIDs |
| 28 | self.seeing = seeing | 53 | self.seeing = seeing |
| 29 | self.notes = notes | 54 | self.notes = notes |
| 55 | self.otherProperties = otherProperties | ||
| 30 | } | 56 | } |
| 31 | 57 | ||
| 32 | static let orgDate: Date.VerbatimFormatStyle = .init(format: "\(year: .defaultDigits)-\(month: .twoDigits)-\(day: .twoDigits) \(weekday: .abbreviated) \(hour: .twoDigits(clock: .twentyFourHour, hourCycle: .zeroBased)):\(minute: .twoDigits)", locale: Locale(identifier: "en_US_POSIX"), timeZone: .current, calendar: Calendar(identifier: .gregorian)) | 58 | static let orgDate: Date.VerbatimFormatStyle = .init(format: "\(year: .defaultDigits)-\(month: .twoDigits)-\(day: .twoDigits) \(weekday: .abbreviated) \(hour: .twoDigits(clock: .twentyFourHour, hourCycle: .zeroBased)):\(minute: .twoDigits)", locale: Locale(identifier: "en_US_POSIX"), timeZone: .current, calendar: Calendar(identifier: .gregorian)) |
| @@ -40,6 +66,7 @@ public struct Observation: Sendable, Identifiable, Equatable { | |||
| 40 | if let seeing { props.append((":SEEING:", "\(seeing)/5")) } | 66 | if let seeing { props.append((":SEEING:", "\(seeing)/5")) } |
| 41 | var lines = ["* \(object)", "[\(date.formatted(Self.orgDate))]", ":PROPERTIES:"] | 67 | var lines = ["* \(object)", "[\(date.formatted(Self.orgDate))]", ":PROPERTIES:"] |
| 42 | lines += props.map { "\($0.0) \($0.1)" } | 68 | lines += props.map { "\($0.0) \($0.1)" } |
| 69 | lines += otherProperties.map(\.line) | ||
| 43 | lines.append(":END:") | 70 | lines.append(":END:") |
| 44 | let body = notes.trimmingCharacters(in: .whitespacesAndNewlines) | 71 | let body = notes.trimmingCharacters(in: .whitespacesAndNewlines) |
| 45 | if !body.isEmpty { lines += ["", body] } | 72 | if !body.isEmpty { lines += ["", body] } |
| @@ -53,12 +80,14 @@ public struct Observation: Sendable, Identifiable, Equatable { | |||
| 53 | lines.count > 1, lines[1].hasPrefix("["), lines[1].hasSuffix("]"), | 80 | lines.count > 1, lines[1].hasPrefix("["), lines[1].hasSuffix("]"), |
| 54 | let date = try? Date(String(lines[1].dropFirst().dropLast()), strategy: Self.orgDate.parseStrategy) else { return nil } | 81 | let date = try? Date(String(lines[1].dropFirst().dropLast()), strategy: Self.orgDate.parseStrategy) else { return nil } |
| 55 | var props: [String: String] = [:] | 82 | var props: [String: String] = [:] |
| 83 | var other: [OrgProperty] = [] | ||
| 56 | var i = 2 | 84 | var i = 2 |
| 57 | if i < lines.count, lines[i] == ":PROPERTIES:" { | 85 | if i < lines.count, lines[i] == ":PROPERTIES:" { |
| 58 | i += 1 | 86 | i += 1 |
| 59 | while i < lines.count, lines[i] != ":END:" { | 87 | while i < lines.count, lines[i] != ":END:" { |
| 60 | let parts = lines[i].split(separator: " ", maxSplits: 1).map(String.init) | 88 | if let p = OrgProperty.parse(lines[i]) { |
| 61 | if parts.count == 2 { props[parts[0]] = parts[1] } | 89 | if !Self.knownKeys.contains(p.key) { other.append(p) } else if !p.value.isEmpty { props[p.key] = p.value } |
| 90 | } | ||
| 62 | i += 1 | 91 | i += 1 |
| 63 | } | 92 | } |
| 64 | i += 1 | 93 | i += 1 |
| @@ -75,7 +104,8 @@ public struct Observation: Sendable, Identifiable, Equatable { | |||
| 75 | equipment: props[":EQUIPMENT:"] ?? "", | 104 | equipment: props[":EQUIPMENT:"] ?? "", |
| 76 | equipmentIDs: props[":EQUIPMENT_IDS:"]?.split(separator: " ").map(String.init) ?? [], | 105 | equipmentIDs: props[":EQUIPMENT_IDS:"]?.split(separator: " ").map(String.init) ?? [], |
| 77 | seeing: props[":SEEING:"].flatMap { Int($0.prefix(1)) }, | 106 | seeing: props[":SEEING:"].flatMap { Int($0.prefix(1)) }, |
| 78 | notes: notes | 107 | notes: notes, |
| 108 | otherProperties: other | ||
| 79 | ) | 109 | ) |
| 80 | } | 110 | } |
| 81 | } | 111 | } |
Packages/MoonriseKit/Sources/SkyEngine/Events.swift +17 −3
| @@ -136,14 +136,28 @@ extension SkyEngine { | |||
| 136 | } | 136 | } |
| 137 | } | 137 | } |
| 138 | 138 | ||
| 139 | // Meteor showers, with how much moonlight there is at peak | 139 | // Meteor showers, with the Moon on the observer's night of the peak |
| 140 | func altitude(_ body: astro_body_t, _ d: Date) -> Double { | ||
| 141 | var t = time(d) | ||
| 142 | let eq = Astronomy_Equator(body, &t, observer.astro, EQUATOR_OF_DATE, ABERRATION) | ||
| 143 | return Astronomy_Horizon(&t, observer.astro, eq.ra, eq.dec, REFRACTION_NORMAL).altitude | ||
| 144 | } | ||
| 140 | var utc = Calendar(identifier: .gregorian) | 145 | var utc = Calendar(identifier: .gregorian) |
| 141 | utc.timeZone = TimeZone(identifier: "UTC")! | 146 | utc.timeZone = TimeZone(identifier: "UTC")! |
| 142 | for y in y0...(y0 + 1) { | 147 | for y in y0...(y0 + 1) { |
| 143 | for m in MeteorShower.major { | 148 | for m in MeteorShower.major { |
| 144 | guard let d = utc.date(from: DateComponents(year: y, month: m.month, day: m.day, hour: 12)), d >= start, d <= end else { continue } | 149 | guard let d = utc.date(from: DateComponents(year: y, month: m.month, day: m.day, hour: 12)), d >= start, d <= end else { continue } |
| 145 | let lit = Astronomy_Illumination(BODY_MOON, time(d)).phase_fraction | 150 | // Local solar midnight after the evening of the peak date. |
| 146 | out.append(SkyEvent(kind: .meteorShower, date: d, title: "\(m.name) peak", detail: String(format: "Up to about %d meteors an hour under dark skies. Moon %.0f%% lit.", m.zhr, lit * 100), visibleHere: nil)) | 151 | let midnight = d.addingTimeInterval((12 - observer.longitude / 15) * 3600) |
| 152 | let lit = Astronomy_Illumination(BODY_MOON, time(midnight)).phase_fraction | ||
| 153 | // Dark hours: Sun below -18°, or just midnight where it never gets that dark. | ||
| 154 | var dark = stride(from: -12.0, through: 12.0, by: 0.25).map { midnight.addingTimeInterval($0 * 3600) }.filter { altitude(BODY_SUN, $0) < -18 } | ||
| 155 | if dark.isEmpty { dark = [midnight] } | ||
| 156 | let up = dark.map { altitude(BODY_MOON, $0) > 0 } | ||
| 157 | let moon = !up.contains(true) ? "below the horizon during the dark hours" | ||
| 158 | : !up.contains(false) ? "up all through the dark hours" | ||
| 159 | : up[0] ? "sets during the dark hours" : "rises during the dark hours" | ||
| 160 | out.append(SkyEvent(kind: .meteorShower, date: d, title: "\(m.name) peak", detail: String(format: "Up to about %d meteors an hour under dark skies. Moon %.0f%% lit, %@.", m.zhr, lit * 100, moon), visibleHere: nil)) | ||
| 147 | } | 161 | } |
| 148 | } | 162 | } |
| 149 | 163 | ||
Packages/MoonriseKit/Tests/ObservingLogTests/ObservingLogTests.swift +18
| @@ -124,3 +124,21 @@ let when = Date(timeIntervalSince1970: 1_792_202_400) // 2026-10-17T02:00:00Z | |||
| 124 | let none = EquipmentUsage(of: "camera", in: log, calendar: cal) | 124 | let none = EquipmentUsage(of: "camera", in: log, calendar: cal) |
| 125 | #expect(none.observations == 0 && none.first == nil && none.averageSeeing == nil && none.usedWith.isEmpty) | 125 | #expect(none.observations == 0 && none.first == nil && none.averageSeeing == nil && none.usedWith.isEmpty) |
| 126 | } | 126 | } |
| 127 | |||
| 128 | @Test func observationKeepsHandAddedProperties() throws { | ||
| 129 | let text = "* Saturn\n[2026-10-16 Fri 21:00]\n:PROPERTIES:\n:ID: abc\n:TRANSPARENCY: 4/5\n:PLACE: Chicago\n:CATEGORY: planet\n:BORTLE:\n:END:\n\nRings.\n" | ||
| 130 | var o = try #require(Observation(org: text)) | ||
| 131 | o.notes = "Rings and Titan." | ||
| 132 | let out = o.org | ||
| 133 | #expect(out.contains(":PLACE: Chicago\n:TRANSPARENCY: 4/5\n:CATEGORY: planet\n:BORTLE:\n:END:\n")) | ||
| 134 | #expect(Observation(org: out) == o) | ||
| 135 | } | ||
| 136 | |||
| 137 | @Test func equipmentKeepsHandAddedProperties() throws { | ||
| 138 | let text = "* Dob\n:PROPERTIES:\n:ID: scope-1\n:TYPE: telescope\n:PURCHASED: 2019-05-02\n:APERTURE: 203 mm\n:FOCAL_RATIO: f/5.9\n:SERIAL: X123\n:END:\n" | ||
| 139 | var scope = try #require(Equipment(org: text)) | ||
| 140 | scope.focalLength = 1200 | ||
| 141 | let out = scope.org | ||
| 142 | #expect(out.contains(":FOCAL_RATIO: f/5.9\n:PURCHASED: 2019-05-02\n:SERIAL: X123\n:END:\n")) | ||
| 143 | #expect(Equipment(org: out) == scope) | ||
| 144 | } | ||
Packages/MoonriseKit/Tests/SkyEngineTests/EventsTests.swift +11
| @@ -33,3 +33,14 @@ func utc(_ s: String) -> Date { try! Date(s, strategy: .iso8601) } | |||
| 33 | let kinds = Set(events.map(\.kind)) | 33 | let kinds = Set(events.map(\.kind)) |
| 34 | #expect(kinds.isSuperset(of: [.moonPhase, .lunarEclipse, .solarEclipse, .opposition, .elongation, .season, .meteorShower])) | 34 | #expect(kinds.isSuperset(of: [.moonPhase, .lunarEclipse, .solarEclipse, .opposition, .elongation, .season, .meteorShower])) |
| 35 | } | 35 | } |
| 36 | |||
| 37 | @Test func meteorShowerMoonIsForTheLocalNight() throws { | ||
| 38 | let events = SkyEngine.events(from: utc("2026-08-01T00:00:00Z"), days: 180, observer: chicago) | ||
| 39 | func detail(_ name: String) throws -> String { try #require(events.first { $0.title == "\(name) peak" }).detail } | ||
| 40 | // New moon on 12 August 2026. | ||
| 41 | #expect(try detail("Perseids").hasSuffix("Moon 0% lit, below the horizon during the dark hours.")) | ||
| 42 | // 76% at 12:00 UTC on the 21st; 82% at Chicago midnight, setting about 03:00 CDT. | ||
| 43 | #expect(try detail("Orionids").hasSuffix("Moon 82% lit, sets during the dark hours.")) | ||
| 44 | // 16% at 12:00 UTC on the 3rd; 11% at Chicago midnight, rising about 05:00 CST before dawn. | ||
| 45 | #expect(try detail("Quadrantids").hasSuffix("Moon 11% lit, rises during the dark hours.")) | ||
| 46 | } | ||