krz/moonrise

Night-sky companion for iPhone, Apple Watch and Vision Pro: what's up tonight, the Moon, planets, launches and missions, and a Library of the solar system. SwiftUI app with a Cloudflare Workers backend. astronomy cloudflare-workers ios night-sky space swiftui visionos watchos

Commit d3f811952b

d3f811952bf17166d9c9bca589499d3680e1d717

parent: b327e0d6e7

Unsigned

cmc <hello@cleberg.net> · 2026-10-20 03:19 UTC

Observing programs, phase 1: Messier and Org program lists (!66)

Log format
- `:CATALOG:` (space-separated IDs, e.g. `M31`) and `:UTC_OFFSET:` (`-05:00`) added to entries. `ObservingLog.save` sets the offset from the device zone when missing and keeps an existing one; it sets `:CATALOG:` when the object name resolves to an ID not already listed, and keeps the existing IDs when the name doesn't resolve.
- Entries without `:UTC_OFFSET:` are still read in the device zone. Files are only rewritten when saved from the editor.

Matching (`CatalogResolver`)
- Designations normalise to letters+number: `M 31`, `Messier 31`, `m031` -> `M31`; `NGC 6205` -> `NGC6205`. Exact matches only.
- Common names: Library deep-sky subtitle before " · " (`Andromeda Galaxy`, `Winnecke 4`), plus user program headings that carry `:CATALOG:`. Case, diacritics, curly apostrophes and extra spaces ignored.
- Used for program progress and the equipment "Most observed" / object count (catalogued objects show as their ID).

Programs
- Messier: all 110 from the Library pages, positions from `messier.json`. M102 is listed and matched as `M102` but has no common name and no position (OpenNGC treats it as a duplicate of M101, as on the chart since !57), so it never appears under "up tonight".
- User programs: `*.org` in `Documents/Observations/Programs/` (folder created when the Programs screen opens). Format:
  ```org
  #+TITLE: Autumn clusters
  * Double Cluster
  :PROPERTIES:
  :CATALOG: NGC869 NGC884
  :RA: 34.75
  :DEC: 57.13
  :END:
  * M45
  ```
  One top-level heading per object. `:CATALOG:` gives its IDs, else the heading is resolved, else it is matched by name. `:RA:`/`:DEC:` are J2000 degrees; Messier objects without them take the bundled position. Without `#+TITLE:` the file name is the title.
- Log screen: "Programs" row (not a toolbar button: a third toolbar icon clips the "Observing log" title in the audit). Program list shows n / total. Detail: "Not yet logged, up tonight" (best altitude >= 25° while the Sun is below -12°, 15-minute samples over `Tonight`'s window, `SkyEngine.bestInDarkness`), then every object with first-logged date or "Log". "Log" pre-fills object name, `:CATALOG:`, place, coordinates and time.

Dependencies: `ObservingLog` now depends on `Library` and `SkyRenderer` (only the app links it).

Tests: `swift test` (all pass, new ObservingLog and SkyEngine tests), server `npm test` 27/27, accessibility audit 78 before and after (Sky 3, Settings 2, System 17, Sun 4, News & launches 26, Library 14, Library page: Saturn 12). The audit does not visit the log; a local, uncommitted run of the audit on Log / Programs / Messier / a user program showed the Log screen's 3 existing issues and 0 on the new screens.

Not verified: on a device; with files edited in Emacs or orgstar; the up-tonight list after the user changes location or time in the sky view (it uses the sky's location and the current time); program screens use the log's dark/blue style, not night red.

Layout: unified · split

App/Sources/EquipmentScreen.swift +1 −1
@@ -150,7 +150,7 @@ struct EquipmentDetail: View {
150 150
151 private func reload() { 151 private func reload() {
152 names = Dictionary(equipmentInventory.all().map { ($0.id, $0.name) }, uniquingKeysWith: { a, _ in a }) 152 names = Dictionary(equipmentInventory.all().map { ($0.id, $0.name) }, uniquingKeysWith: { a, _ in a })
153 usage = EquipmentUsage(of: item.id, in: observingLog.all()) 153 usage = EquipmentUsage(of: item.id, in: observingLog.all(), resolver: CatalogResolver(programs: observingLog.programs()))
154 } 154 }
155} 155}
156 156
App/Sources/LogScreen.swift +2 −1
@@ -13,6 +13,7 @@ struct LogScreen: View {
13 var body: some View { 13 var body: some View {
14 NavigationStack { 14 NavigationStack {
15 List { 15 List {
16 NavigationLink("Programs") { ProgramsScreen(newEntry: newEntry) }
16 if entries.isEmpty { 17 if entries.isEmpty {
17 Text("No observations yet. Add one here, or use Log on an object's card in the Sky tab.") 18 Text("No observations yet. Add one here, or use Log on an object's card in the Sky tab.")
18 .font(.footnote).foregroundStyle(.secondary) 19 .font(.footnote).foregroundStyle(.secondary)
@@ -102,7 +103,7 @@ struct ObservationEditor: View {
102 ToolbarItem(placement: .confirmationAction) { 103 ToolbarItem(placement: .confirmationAction) {
103 Button("Save") { 104 Button("Save") {
104 do { 105 do {
105 try observingLog.save(observation) 106 try observingLog.save(observation, resolver: CatalogResolver(programs: observingLog.programs()))
106 onSave() 107 onSave()
107 dismiss() 108 dismiss()
108 } catch { 109 } catch {
App/Sources/ProgramsScreen.swift added +136
@@ -0,0 +1,136 @@
1import ObservingLog
2import SkyEngine
3import SwiftUI
4
5/// Observing programs: the bundled Messier list plus Org files in Documents/Observations/Programs.
6struct ProgramsScreen: View {
7 var newEntry: () -> Observation
8 @State private var programs: [ObservingProgram] = []
9 @State private var log: [Observation] = []
10
11 var body: some View {
12 let resolver = CatalogResolver(programs: programs)
13 List {
14 Section {
15 ForEach(programs) { p in
16 let n = ProgramProgress(program: p, log: log, resolver: resolver).count
17 NavigationLink { ProgramDetail(program: p, newEntry: newEntry) } label: {
18 VStack(alignment: .leading, spacing: 6) {
19 HStack {
20 Text(p.title)
21 Spacer()
22 Text("\(n) / \(p.objects.count)").monospacedDigit().foregroundStyle(.secondary)
23 }
24 ProgressView(value: Double(n), total: Double(max(p.objects.count, 1)))
25 .accessibilityHidden(true)
26 }
27 }
28 .accessibilityLabel("\(p.title), \(n) of \(p.objects.count) logged")
29 }
30 } footer: {
31 Text("Add your own lists as Org files in Files › On My iPhone › Moonrise › Observations › Programs: a #+TITLE: line, then one top-level heading per object, such as * M13 or * NGC 869. A :CATALOG: property sets an object's IDs, and :RA: and :DEC: in degrees its position.")
32 }
33 }
34 .navigationTitle("Programs")
35 .navigationBarTitleDisplayMode(.inline)
36 .onAppear {
37 try? FileManager.default.createDirectory(at: observingLog.programsFolder, withIntermediateDirectories: true)
38 programs = observingLog.programs()
39 log = observingLog.all()
40 }
41 }
42}
43
44struct ProgramDetail: View {
45 struct Candidate: Sendable {
46 var object: ObservingProgram.Object
47 var best: Date
48 var position: Horizontal
49 }
50
51 var program: ObservingProgram
52 var newEntry: () -> Observation
53 @State private var progress: ProgramProgress?
54 @State private var upTonight: [Candidate]?
55 @State private var editing: Observation?
56
57 var body: some View {
58 List {
59 Section {
60 if let upTonight {
61 if upTonight.isEmpty {
62 Text("None of the objects still to log gets 25° up in full darkness tonight.").font(.footnote).foregroundStyle(.secondary)
63 }
64 ForEach(Array(upTonight.enumerated()), id: \.offset) { _, c in
65 row(c.object, trailing: "\(c.best.formatted(date: .omitted, time: .shortened)) · \(Int(c.position.altitude.rounded()))° \(SkyEngine.compass(c.position.azimuth))")
66 }
67 } else {
68 ProgressView()
69 }
70 } header: {
71 Text("Not yet logged, up tonight")
72 } footer: {
73 Text("Best time while the Sun is more than 12° below the horizon.")
74 }
75 Section("\(progress?.count ?? 0) of \(program.objects.count) logged") {
76 ForEach(Array(program.objects.enumerated()), id: \.offset) { _, o in
77 row(o, trailing: progress?.first[o.id].map { $0.date.formatted(date: .abbreviated, time: .omitted) } ?? "Log")
78 }
79 }
80 }
81 .navigationTitle(program.title)
82 .navigationBarTitleDisplayMode(.inline)
83 .sheet(item: $editing) { o in
84 ObservationEditor(observation: o) { reload() }
85 }
86 .onAppear(perform: reload)
87 .task { await findUpTonight() }
88 }
89
90 /// Opens the first entry for a logged object, else a new entry for it.
91 private func row(_ o: ObservingProgram.Object, trailing: String) -> some View {
92 let logged = progress?.first[o.id]
93 return Button {
94 if let logged {
95 editing = logged
96 } else {
97 var entry = newEntry()
98 entry.object = o.name
99 entry.catalog = o.ids
100 editing = entry
101 }
102 } label: {
103 HStack(alignment: .firstTextBaseline) {
104 VStack(alignment: .leading, spacing: 2) {
105 Text(o.name)
106 if !o.detail.isEmpty { Text(o.detail).font(.footnote).foregroundStyle(.secondary) }
107 }
108 Spacer()
109 Text(trailing).font(.footnote).monospacedDigit().foregroundStyle(logged == nil ? .primary : .secondary)
110 }
111 .contentShape(.rect)
112 }
113 .buttonStyle(.plain)
114 .accessibilityHint(logged == nil ? "Logs this object" : "Opens the entry")
115 }
116
117 private func reload() {
118 let programs = observingLog.programs()
119 progress = ProgramProgress(program: program, log: observingLog.all(), resolver: CatalogResolver(programs: programs))
120 if let upTonight, let progress { self.upTonight = upTonight.filter { progress.first[$0.object.id] == nil } }
121 }
122
123 private func findUpTonight() async {
124 let here = newEntry()
125 guard let lat = here.latitude, let lon = here.longitude else { upTonight = []; return }
126 let observer = Observer(latitude: lat, longitude: lon)
127 let pending = program.objects.filter { progress?.first[$0.id] == nil && $0.ra != nil && $0.dec != nil }
128 let found = await Task.detached {
129 guard let night = try? Tonight(at: .now, from: observer),
130 let best = try? SkyEngine.bestInDarkness(j2000: pending.map { ($0.ra!, $0.dec!) }, from: night.start, to: night.end, observer: observer)
131 else { return [Candidate]() }
132 return zip(pending, best).compactMap { o, b in b.map { Candidate(object: o, best: $0.date, position: $0.position) } }
133 }.value
134 upTonight = found.sorted { $0.best < $1.best }
135 }
136}
Packages/MoonriseKit/Package.swift +1 −1
@@ -26,7 +26,7 @@ let package = Package(
26 resources: [.copy("Resources/bsc5.json"), .copy("Resources/constellations.json"), .copy("Resources/coastline.json"), .copy("Resources/spacecraft.json"), .copy("Resources/skycultures.json"), .copy("Resources/messier.json")] 26 resources: [.copy("Resources/bsc5.json"), .copy("Resources/constellations.json"), .copy("Resources/coastline.json"), .copy("Resources/spacecraft.json"), .copy("Resources/skycultures.json"), .copy("Resources/messier.json")]
27 ), 27 ),
28 .target(name: "Feeds", dependencies: ["SkyEngine"]), 28 .target(name: "Feeds", dependencies: ["SkyEngine"]),
29 .target(name: "ObservingLog"), 29 .target(name: "ObservingLog", dependencies: ["Library", "SkyRenderer"]),
30 .target(name: "Library", resources: [.copy("Resources/pages"), .copy("Resources/tours"), .copy("Resources/stories")]), 30 .target(name: "Library", resources: [.copy("Resources/pages"), .copy("Resources/tours"), .copy("Resources/stories")]),
31 .target(name: "Answers", dependencies: ["SkyEngine", "SkyRenderer", "Feeds", "Library"]), 31 .target(name: "Answers", dependencies: ["SkyEngine", "SkyRenderer", "Feeds", "Library"]),
32 .testTarget(name: "SkyEngineTests", dependencies: ["SkyEngine"]), 32 .testTarget(name: "SkyEngineTests", dependencies: ["SkyEngine"]),
Packages/MoonriseKit/Sources/ObservingLog/EquipmentUsage.swift +5 −3
@@ -27,8 +27,9 @@ public struct EquipmentUsage: Sendable, Equatable {
27 /// IDs of other items logged alongside this one, most frequent first. 27 /// IDs of other items logged alongside this one, most frequent first.
28 public var usedWith: [Tally] 28 public var usedWith: [Tally]
29 29
30 /// Usage of the item `id` in `log`. `calendar` decides month and season boundaries. 30 /// Usage of the item `id` in `log`. `calendar` decides month and season boundaries. Objects are counted by
31 public init(of id: String, in log: [Observation], calendar: Calendar = .current) { 31 /// catalogue ID where `resolver` finds one, else by name.
32 public init(of id: String, in log: [Observation], calendar: Calendar = .current, resolver: CatalogResolver = .messier) {
32 let mine = log.filter { $0.equipmentIDs.contains(id) } 33 let mine = log.filter { $0.equipmentIDs.contains(id) }
33 observations = mine.count 34 observations = mine.count
34 first = mine.map(\.date).min() 35 first = mine.map(\.date).min()
@@ -37,7 +38,8 @@ public struct EquipmentUsage: Sendable, Equatable {
37 var objects: [String: (name: String, count: Int)] = [:] 38 var objects: [String: (name: String, count: Int)] = [:]
38 for o in mine { 39 for o in mine {
39 let name = o.object.trimmingCharacters(in: .whitespaces) 40 let name = o.object.trimmingCharacters(in: .whitespaces)
40 objects[name.lowercased(), default: (name, 0)].count += 1 41 let key = resolver.keys(for: o)[0]
42 objects[key, default: (key == CatalogResolver.fold(name) ? name : key, 0)].count += 1
41 } 43 }
42 distinctObjects = objects.count 44 distinctObjects = objects.count
43 topObjects = objects.values.map { Tally(key: $0.name, count: $0.count) }.sorted(by: Self.order) 45 topObjects = objects.values.map { Tally(key: $0.name, count: $0.count) }.sorted(by: Self.order)
Packages/MoonriseKit/Sources/ObservingLog/Observation.swift +53 −9
@@ -36,12 +36,16 @@ public struct Observation: Sendable, Identifiable, Equatable {
36 /// 1 (poor) to 5 (excellent); nil when not noted. 36 /// 1 (poor) to 5 (excellent); nil when not noted.
37 public var seeing: Int? 37 public var seeing: Int?
38 public var notes: String 38 public var notes: String
39 /// Catalogue IDs, e.g. ["M31"]; see `CatalogResolver`.
40 public var catalog: [String]
41 /// Seconds east of UTC that the timestamp is written in. Nil for older entries, which are read in the device's zone.
42 public var utcOffset: Int?
39 /// Properties added by hand, in file order. 43 /// Properties added by hand, in file order.
40 public var otherProperties: [OrgProperty] 44 public var otherProperties: [OrgProperty]
41 45
42 static let knownKeys: Set = [":ID:", ":PLACE:", ":COORDINATES:", ":EQUIPMENT:", ":EQUIPMENT_IDS:", ":SEEING:"] 46 static let knownKeys: Set = [":ID:", ":CATALOG:", ":PLACE:", ":COORDINATES:", ":UTC_OFFSET:", ":EQUIPMENT:", ":EQUIPMENT_IDS:", ":SEEING:"]
43 47
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] = []) { 48 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 = "", catalog: [String] = [], utcOffset: Int? = nil, otherProperties: [OrgProperty] = []) {
45 self.id = id 49 self.id = id
46 self.object = object 50 self.object = object
47 self.date = date 51 self.date = date
@@ -52,19 +56,39 @@ public struct Observation: Sendable, Identifiable, Equatable {
52 self.equipmentIDs = equipmentIDs 56 self.equipmentIDs = equipmentIDs
53 self.seeing = seeing 57 self.seeing = seeing
54 self.notes = notes 58 self.notes = notes
59 self.catalog = catalog
60 self.utcOffset = utcOffset
55 self.otherProperties = otherProperties 61 self.otherProperties = otherProperties
56 } 62 }
57 63
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)) 64 static func orgDate(_ zone: TimeZone) -> Date.VerbatimFormatStyle {
65 .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: zone, calendar: Calendar(identifier: .gregorian))
66 }
67
68 private var zone: TimeZone { utcOffset.flatMap(TimeZone.init(secondsFromGMT:)) ?? .current }
69
70 /// "-05:00" for -18000.
71 static func offsetText(_ seconds: Int) -> String {
72 let m = abs(seconds) / 60
73 return String(format: "%@%02d:%02d", seconds < 0 ? "-" : "+", m / 60, m % 60)
74 }
75
76 static func offset(_ text: String) -> Int? {
77 guard let m = text.wholeMatch(of: /([+-])(\d\d):(\d\d)/), let h = Int(m.2), let min = Int(m.3) else { return nil }
78 return (m.1 == "-" ? -1 : 1) * (h * 3600 + min * 60)
79 }
59 80
60 /// Org text: one heading with an inactive timestamp, a property drawer and the notes. 81 /// Org text: one heading with an inactive timestamp, a property drawer and the notes.
61 public var org: String { 82 public var org: String {
62 var props = [(":ID:", id), (":PLACE:", place)] 83 var props = [(":ID:", id)]
84 if !catalog.isEmpty { props.append((":CATALOG:", catalog.joined(separator: " "))) }
85 props.append((":PLACE:", place))
63 if let latitude, let longitude { props.append((":COORDINATES:", String(format: "%.4f, %.4f", latitude, longitude))) } 86 if let latitude, let longitude { props.append((":COORDINATES:", String(format: "%.4f, %.4f", latitude, longitude))) }
87 if let utcOffset { props.append((":UTC_OFFSET:", Self.offsetText(utcOffset))) }
64 if !equipment.isEmpty { props.append((":EQUIPMENT:", equipment)) } 88 if !equipment.isEmpty { props.append((":EQUIPMENT:", equipment)) }
65 if !equipmentIDs.isEmpty { props.append((":EQUIPMENT_IDS:", equipmentIDs.joined(separator: " "))) } 89 if !equipmentIDs.isEmpty { props.append((":EQUIPMENT_IDS:", equipmentIDs.joined(separator: " "))) }
66 if let seeing { props.append((":SEEING:", "\(seeing)/5")) } 90 if let seeing { props.append((":SEEING:", "\(seeing)/5")) }
67 var lines = ["* \(object)", "[\(date.formatted(Self.orgDate))]", ":PROPERTIES:"] 91 var lines = ["* \(object)", "[\(date.formatted(Self.orgDate(zone)))]", ":PROPERTIES:"]
68 lines += props.map { "\($0.0) \($0.1)" } 92 lines += props.map { "\($0.0) \($0.1)" }
69 lines += otherProperties.map(\.line) 93 lines += otherProperties.map(\.line)
70 lines.append(":END:") 94 lines.append(":END:")
@@ -77,8 +101,7 @@ public struct Observation: Sendable, Identifiable, Equatable {
77 public init?(org text: String) { 101 public init?(org text: String) {
78 let lines = text.components(separatedBy: "\n") 102 let lines = text.components(separatedBy: "\n")
79 guard let head = lines.first, head.hasPrefix("* "), 103 guard let head = lines.first, head.hasPrefix("* "),
80 lines.count > 1, lines[1].hasPrefix("["), lines[1].hasSuffix("]"), 104 lines.count > 1, lines[1].hasPrefix("["), lines[1].hasSuffix("]") else { return nil }
81 let date = try? Date(String(lines[1].dropFirst().dropLast()), strategy: Self.orgDate.parseStrategy) else { return nil }
82 var props: [String: String] = [:] 105 var props: [String: String] = [:]
83 var other: [OrgProperty] = [] 106 var other: [OrgProperty] = []
84 var i = 2 107 var i = 2
@@ -92,6 +115,8 @@ public struct Observation: Sendable, Identifiable, Equatable {
92 } 115 }
93 i += 1 116 i += 1
94 } 117 }
118 let offset = props[":UTC_OFFSET:"].flatMap(Self.offset)
119 guard let date = try? Date(String(lines[1].dropFirst().dropLast()), strategy: Self.orgDate(offset.flatMap(TimeZone.init(secondsFromGMT:)) ?? .current).parseStrategy) else { return nil }
95 let notes = lines.dropFirst(i).joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) 120 let notes = lines.dropFirst(i).joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
96 let coords = props[":COORDINATES:"]?.split(separator: ",").compactMap { Double($0.trimmingCharacters(in: .whitespaces)) } 121 let coords = props[":COORDINATES:"]?.split(separator: ",").compactMap { Double($0.trimmingCharacters(in: .whitespaces)) }
97 self.init( 122 self.init(
@@ -105,6 +130,8 @@ public struct Observation: Sendable, Identifiable, Equatable {
105 equipmentIDs: props[":EQUIPMENT_IDS:"]?.split(separator: " ").map(String.init) ?? [], 130 equipmentIDs: props[":EQUIPMENT_IDS:"]?.split(separator: " ").map(String.init) ?? [],
106 seeing: props[":SEEING:"].flatMap { Int($0.prefix(1)) }, 131 seeing: props[":SEEING:"].flatMap { Int($0.prefix(1)) },
107 notes: notes, 132 notes: notes,
133 catalog: props[":CATALOG:"]?.split(separator: " ").map(String.init) ?? [],
134 utcOffset: offset,
108 otherProperties: other 135 otherProperties: other
109 ) 136 )
110 } 137 }
@@ -129,13 +156,30 @@ public struct ObservingLog: Sendable {
129 .sorted { $0.date > $1.date } 156 .sorted { $0.date > $1.date }
130 } 157 }
131 158
132 /// Writes the observation, replacing any earlier file with the same ID. 159 /// Writes the observation, replacing any earlier file with the same ID. Sets `utcOffset` from the device's zone
133 public func save(_ o: Observation) throws { 160 /// when missing, and `catalog` when the object's name resolves to an ID not already in it; a name that doesn't
161 /// resolve keeps the IDs already set.
162 public func save(_ o: Observation, resolver: CatalogResolver = .messier) throws {
163 var o = o
164 if o.utcOffset == nil { o.utcOffset = TimeZone.current.secondsFromGMT(for: o.date) }
165 if let id = resolver.id(for: o.object), !o.catalog.contains(where: { (CatalogResolver.designation($0) ?? $0) == id }) { o.catalog = [id] }
134 try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) 166 try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
135 try delete(id: o.id) 167 try delete(id: o.id)
136 try o.org.write(to: fileURL(for: o), atomically: true, encoding: .utf8) 168 try o.org.write(to: fileURL(for: o), atomically: true, encoding: .utf8)
137 } 169 }
138 170
171 /// User-defined program lists, one Org file each; see `ObservingProgram`.
172 public var programsFolder: URL { folder.appending(path: "Programs") }
173
174 /// Messier, then the files in `programsFolder` by title.
175 public func programs() -> [ObservingProgram] {
176 let files = (try? FileManager.default.contentsOfDirectory(at: programsFolder, includingPropertiesForKeys: nil)) ?? []
177 let mine = files.filter { $0.pathExtension == "org" }.compactMap { url in
178 (try? String(contentsOf: url, encoding: .utf8)).map { ObservingProgram(org: $0, id: url.deletingPathExtension().lastPathComponent) }
179 }
180 return [.messier] + mine.sorted { $0.title.localizedStandardCompare($1.title) == .orderedAscending }
181 }
182
139 public func delete(id: String) throws { 183 public func delete(id: String) throws {
140 let files = (try? FileManager.default.contentsOfDirectory(at: folder, includingPropertiesForKeys: nil)) ?? [] 184 let files = (try? FileManager.default.contentsOfDirectory(at: folder, includingPropertiesForKeys: nil)) ?? []
141 for f in files where f.pathExtension == "org" { 185 for f in files where f.pathExtension == "org" {
Packages/MoonriseKit/Sources/ObservingLog/Programs.swift added +169
@@ -0,0 +1,169 @@
1import Foundation
2import Library
3import SkyRenderer
4
5/// Maps object names in log entries and program lists to catalogue IDs such as "M31" or "NGC6205".
6/// Only exact matches after normalisation count.
7public struct CatalogResolver: Sendable {
8 /// Folded name (see `fold`) to ID.
9 public var names: [String: String]
10
11 public init(names: [String: String] = [:]) {
12 self.names = names
13 }
14
15 /// Names from the programs' aliases; the first program to use a name keeps it.
16 public init(programs: [ObservingProgram]) {
17 var names: [String: String] = [:]
18 for p in programs {
19 for o in p.objects {
20 for a in o.aliases where names[Self.fold(a)] == nil { names[Self.fold(a)] = o.id }
21 }
22 }
23 self.init(names: names)
24 }
25
26 /// Messier common names from the Library.
27 public static let messier = CatalogResolver(programs: [.messier])
28
29 /// "M 31", "Messier 31" and "m031" give "M31"; "NGC 6205" gives "NGC6205". Nil when `s` is not letters then a number.
30 public static func designation(_ s: String) -> String? {
31 guard let m = s.trimmingCharacters(in: .whitespaces).wholeMatch(of: /([A-Za-z]+) *0*(\d+)/) else { return nil }
32 let prefix = m.1.uppercased()
33 return (prefix == "MESSIER" ? "M" : prefix) + m.2
34 }
35
36 /// Case, diacritics, curly apostrophes and runs of spaces ignored.
37 public static func fold(_ s: String) -> String {
38 s.folding(options: [.caseInsensitive, .diacriticInsensitive, .widthInsensitive], locale: nil)
39 .replacingOccurrences(of: "’", with: "'")
40 .split(whereSeparator: \.isWhitespace).joined(separator: " ")
41 }
42
43 /// A known name's ID, else the designation.
44 public func id(for name: String) -> String? {
45 names[Self.fold(name)] ?? Self.designation(name)
46 }
47
48 /// What an entry counts as: its `:CATALOG:` IDs, else the resolved name, else the folded name.
49 public func keys(for o: Observation) -> [String] {
50 if !o.catalog.isEmpty { return o.catalog.map { Self.designation($0) ?? $0 } }
51 return [id(for: o.object) ?? Self.fold(o.object)]
52 }
53}
54
55/// A list of objects to observe: the bundled Messier list, or an Org file in the log's Programs folder.
56///
57/// Program file format, one top-level heading per object:
58///
59/// #+TITLE: Autumn clusters
60/// * Double Cluster
61/// :PROPERTIES:
62/// :CATALOG: NGC869 NGC884
63/// :RA: 34.75
64/// :DEC: 57.13
65/// :END:
66/// * M45
67///
68/// The heading is the object's name. `:CATALOG:` gives its IDs, else the heading is resolved as in
69/// `CatalogResolver`, else the object is matched by name. `:RA:` and `:DEC:` are J2000 degrees; Messier
70/// objects without them take the bundled position. Other lines and lower-level headings are ignored.
71/// Without `#+TITLE:` the file name is the title.
72public struct ObservingProgram: Sendable, Identifiable {
73 public struct Object: Sendable, Identifiable {
74 /// Catalogue IDs; the first is `id`. Empty when the object is matched by name.
75 public var ids: [String]
76 public var name: String
77 /// Other names the object is logged under.
78 public var aliases: [String]
79 public var detail: String
80 /// J2000, degrees.
81 public var ra: Double?
82 public var dec: Double?
83
84 public var id: String { ids.first ?? CatalogResolver.fold(name) }
85 var keys: [String] { ids.isEmpty ? [id] : ids }
86 }
87
88 public var id: String
89 public var title: String
90 public var objects: [Object]
91
92 public init(id: String, title: String, objects: [Object]) {
93 self.id = id
94 self.title = title
95 self.objects = objects
96 }
97
98 /// All 110, from the Library's deep-sky pages and the sky chart's OpenNGC positions. The common name is the
99 /// page subtitle before " · ". M102 is matched only by number and has no position, as on the chart: OpenNGC
100 /// lists it as a duplicate of M101 (scripts/make-messier.py).
101 public static let messier: ObservingProgram = {
102 let positions = Dictionary(uniqueKeysWithValues: Catalog.deepSky.map { ($0.messier, $0) })
103 let objects = Library.pages(in: .deepSky).compactMap { page -> (Int, Object)? in
104 guard let m = page.link.messier else { return nil }
105 let parts = page.subtitle.components(separatedBy: " · ")
106 let alias = parts.count > 1 && m != 102 ? [parts[0]] : []
107 return (m, Object(ids: ["M\(m)"], name: "M\(m)", aliases: alias, detail: page.subtitle, ra: positions[m]?.ra, dec: positions[m]?.dec))
108 }
109 return ObservingProgram(id: "messier", title: "Messier", objects: objects.sorted { $0.0 < $1.0 }.map(\.1))
110 }()
111
112 /// Parses a program file (format above). Names resolve against `resolver`.
113 public init(org text: String, id: String, resolver: CatalogResolver = .messier) {
114 var title = id
115 var objects: [Object] = []
116 var inDrawer = false
117 for line in text.components(separatedBy: "\n") {
118 if line.lowercased().hasPrefix("#+title:") {
119 title = line.dropFirst(8).trimmingCharacters(in: .whitespaces)
120 } else if line.hasPrefix("* ") {
121 let name = line.dropFirst(2).trimmingCharacters(in: .whitespaces)
122 let id = resolver.id(for: name)
123 objects.append(Object(ids: id.map { [$0] } ?? [], name: name, aliases: [], detail: ""))
124 inDrawer = false
125 } else if !objects.isEmpty, line.trimmingCharacters(in: .whitespaces) == ":PROPERTIES:" {
126 inDrawer = true
127 } else if inDrawer, line.trimmingCharacters(in: .whitespaces) == ":END:" {
128 inDrawer = false
129 } else if inDrawer, let p = OrgProperty.parse(line.trimmingCharacters(in: .whitespaces)) {
130 let i = objects.count - 1
131 switch p.key {
132 case ":CATALOG:":
133 objects[i].ids = p.value.split(separator: " ").map { CatalogResolver.designation(String($0)) ?? String($0) }
134 if CatalogResolver.designation(objects[i].name) == nil { objects[i].aliases = [objects[i].name] }
135 case ":RA:": objects[i].ra = Double(p.value)
136 case ":DEC:": objects[i].dec = Double(p.value)
137 default: break
138 }
139 }
140 }
141 for i in objects.indices where objects[i].ra == nil || objects[i].dec == nil {
142 if let m = Self.messier.objects.first(where: { $0.id == objects[i].id }) {
143 objects[i].ra = m.ra
144 objects[i].dec = m.dec
145 }
146 }
147 self.init(id: id, title: title.isEmpty ? id : title, objects: objects)
148 }
149}
150
151/// Which objects of a program the log has, derived each time and never stored.
152public struct ProgramProgress: Sendable {
153 /// Earliest entry for each logged object, by object ID.
154 public var first: [String: Observation]
155
156 public var count: Int { first.count }
157
158 public init(program: ObservingProgram, log: [Observation], resolver: CatalogResolver) {
159 var byKey: [String: Observation] = [:]
160 for o in log {
161 for k in resolver.keys(for: o) where o.date < byKey[k]?.date ?? .distantFuture { byKey[k] = o }
162 }
163 var first: [String: Observation] = [:]
164 for object in program.objects {
165 if let o = object.keys.compactMap({ byKey[$0] }).min(by: { $0.date < $1.date }) { first[object.id] = o }
166 }
167 self.first = first
168 }
169}
Packages/MoonriseKit/Sources/SkyEngine/ProgramVisibility.swift added +17
@@ -0,0 +1,17 @@
1import Foundation
2
3extension SkyEngine {
4 /// Highest point of each fixed J2000 position (degrees) between `start` and `end` while the Sun is below −12°,
5 /// sampled every 15 minutes; nil when it doesn't reach `minimumAltitude` in that time.
6 public static func bestInDarkness(j2000 coordinates: [(ra: Double, dec: Double)], from start: Date, to end: Date, observer: Observer, minimumAltitude: Double = 25) throws -> [(date: Date, position: Horizontal)?] {
7 var best: [(date: Date, position: Horizontal)?] = Array(repeating: nil, count: coordinates.count)
8 for s in stride(from: start.timeIntervalSince1970, through: end.timeIntervalSince1970, by: 900).map(Date.init(timeIntervalSince1970:)) {
9 guard try horizontal(.sun, at: s, from: observer).altitude < -12 else { continue }
10 for (i, h) in horizontal(j2000: coordinates, at: s, from: observer).enumerated()
11 where h.altitude >= minimumAltitude && h.altitude > best[i]?.position.altitude ?? -90 {
12 best[i] = (s, h)
13 }
14 }
15 return best
16 }
17}
Packages/MoonriseKit/Tests/ObservingLogTests/ObservingLogTests.swift +58
@@ -142,3 +142,61 @@ let when = Date(timeIntervalSince1970: 1_792_202_400) // 2026-10-17T02:00:00Z
142 #expect(out.contains(":FOCAL_RATIO: f/5.9\n:PURCHASED: 2019-05-02\n:SERIAL: X123\n:END:\n")) 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) 143 #expect(Equipment(org: out) == scope)
144} 144}
145
146@Test func catalogAndOffsetRoundTrip() throws {
147 let o = Observation(object: "M31", date: when, place: "Chicago", catalog: ["M31", "NGC224"], utcOffset: -5 * 3600)
148 let text = o.org
149 #expect(text.hasPrefix("* M31\n[2026-10-16 Fri 21:00]\n:PROPERTIES:\n"))
150 #expect(text.contains(":CATALOG: M31 NGC224\n:PLACE: Chicago\n:UTC_OFFSET: -05:00\n"))
151 #expect(Observation(org: text) == o)
152
153 let india = try #require(Observation(org: "* M42\n[2026-10-17 Sat 07:30]\n:PROPERTIES:\n:ID: x\n:UTC_OFFSET: +05:30\n:END:\n"))
154 #expect(india.date == when)
155 #expect(india.utcOffset == 19_800)
156 #expect(india.otherProperties.isEmpty)
157}
158
159@Test func entryWithoutOffsetReadsInDeviceZone() throws {
160 let text = "* Saturn\n[2026-10-16 Fri 21:00]\n:PROPERTIES:\n:ID: abc\n:END:\n"
161 let o = try #require(Observation(org: text))
162 #expect(o.utcOffset == nil)
163 #expect(o.date.formatted(Observation.orgDate(.current)) == "2026-10-16 Fri 21:00")
164 #expect(!o.org.contains(":UTC_OFFSET:"))
165}
166
167@Test func saveStampsOffsetAndCatalog() throws {
168 let dir = FileManager.default.temporaryDirectory.appending(path: "obs-\(UUID().uuidString)")
169 defer { try? FileManager.default.removeItem(at: dir) }
170 let log = ObservingLog(folder: dir)
171 let resolver = CatalogResolver(names: ["andromeda galaxy": "M31"])
172
173 try log.save(Observation(id: "a", object: "Messier 31", date: when, place: ""), resolver: resolver)
174 let a = try #require(log.all().first)
175 #expect(a.catalog == ["M31"])
176 #expect(a.utcOffset == TimeZone.current.secondsFromGMT(for: when))
177
178 // A name that doesn't resolve keeps the IDs it was logged with; one that does replaces them.
179 var b = Observation(id: "b", object: "Andromeda core", date: when, place: "", catalog: ["M31"], utcOffset: 3600)
180 try log.save(b, resolver: resolver)
181 b = try #require(log.all().first { $0.id == "b" })
182 #expect(b.catalog == ["M31"] && b.utcOffset == 3600)
183 b.object = "M 33"
184 try log.save(b, resolver: resolver)
185 #expect(log.all().first { $0.id == "b" }?.catalog == ["M33"])
186
187 try log.save(Observation(id: "c", object: "Saturn", date: when, place: ""), resolver: resolver)
188 #expect(log.all().first { $0.id == "c" }?.catalog == [])
189}
190
191@Test func equipmentUsageGroupsByCatalogue() throws {
192 let resolver = CatalogResolver(names: ["andromeda galaxy": "M31"])
193 let log = [
194 Observation(object: "M31", date: when, place: "", equipmentIDs: ["scope"]),
195 Observation(object: "Andromeda Galaxy", date: when, place: "", equipmentIDs: ["scope"]),
196 Observation(object: "Core of Andromeda", date: when, place: "", equipmentIDs: ["scope"], catalog: ["M31"]),
197 Observation(object: "Saturn", date: when, place: "", equipmentIDs: ["scope"]),
198 ]
199 let u = EquipmentUsage(of: "scope", in: log, resolver: resolver)
200 #expect(u.distinctObjects == 2)
201 #expect(u.topObjects == [.init(key: "M31", count: 3), .init(key: "Saturn", count: 1)])
202}
Packages/MoonriseKit/Tests/ObservingLogTests/ProgramsTests.swift added +105
@@ -0,0 +1,105 @@
1import Foundation
2import Testing
3@testable import ObservingLog
4
5@Test func designations() {
6 #expect(CatalogResolver.designation("M31") == "M31")
7 #expect(CatalogResolver.designation("M 31") == "M31")
8 #expect(CatalogResolver.designation(" messier 31 ") == "M31")
9 #expect(CatalogResolver.designation("M031") == "M31")
10 #expect(CatalogResolver.designation("NGC 6205") == "NGC6205")
11 #expect(CatalogResolver.designation("ic434") == "IC434")
12 #expect(CatalogResolver.designation("Saturn") == nil)
13 #expect(CatalogResolver.designation("M31 and M32") == nil)
14}
15
16@Test func messierNamesResolve() {
17 let r = CatalogResolver.messier
18 for name in ["M31", "M 31", "Messier 31", "Andromeda Galaxy", "andromeda galaxy"] {
19 #expect(r.id(for: name) == "M31", "\(name)")
20 }
21 #expect(r.id(for: "Ptolemy’s Cluster") == "M7")
22 #expect(r.id(for: "Winnecke 4") == "M40")
23 #expect(r.id(for: "M102") == "M102")
24 #expect(r.id(for: "Identity disputed") == nil)
25 #expect(r.id(for: "Saturn") == nil)
26}
27
28@Test func messierProgramHasAll110() throws {
29 let p = ObservingProgram.messier
30 #expect(p.objects.map(\.id) == (1...110).map { "M\($0)" })
31 let m31 = try #require(p.objects.first { $0.id == "M31" })
32 #expect(m31.aliases == ["Andromeda Galaxy"])
33 #expect(m31.ra != nil && m31.dec != nil)
34 // M102 has no position: OpenNGC lists it as a duplicate of M101 (see scripts/make-messier.py).
35 let m102 = try #require(p.objects.first { $0.id == "M102" })
36 #expect(m102.ra == nil && m102.dec == nil && m102.aliases.isEmpty)
37 #expect(p.objects.filter { $0.ra == nil }.count == 1)
38}
39
40@Test func programFromOrg() throws {
41 let text = """
42 #+TITLE: Autumn clusters
43 Open clusters for a small refractor.
44
45 * Double Cluster
46 :PROPERTIES:
47 :CATALOG: NGC869 NGC884
48 :RA: 34.75
49 :DEC: 57.13
50 :END:
51 Two clusters in one field.
52 ** Notes
53 * M 45
54 * Pleiades
55 * Stock 2
56 * Kemble's Cascade
57 """
58 let p = ObservingProgram(org: text, id: "autumn-clusters")
59 #expect(p.title == "Autumn clusters")
60 #expect(p.objects.map(\.id) == ["NGC869", "M45", "M45", "STOCK2", "kemble's cascade"])
61 #expect(p.objects.map(\.name) == ["Double Cluster", "M 45", "Pleiades", "Stock 2", "Kemble's Cascade"])
62 #expect(p.objects[0].ra == 34.75 && p.objects[0].dec == 57.13)
63 #expect(p.objects[0].aliases == ["Double Cluster"])
64 // Messier objects take their position from the bundled catalogue.
65 #expect(p.objects[1].ra == ObservingProgram.messier.objects[44].ra)
66 #expect(p.objects[3].ra == nil)
67
68 #expect(ObservingProgram(org: "* M13\n", id: "globulars").title == "globulars")
69
70 let r = CatalogResolver(programs: [.messier, p])
71 #expect(r.id(for: "double cluster") == "NGC869")
72 #expect(r.id(for: "Andromeda Galaxy") == "M31")
73}
74
75@Test func programsFolder() throws {
76 let dir = FileManager.default.temporaryDirectory.appending(path: "obs-\(UUID().uuidString)")
77 defer { try? FileManager.default.removeItem(at: dir) }
78 let log = ObservingLog(folder: dir)
79 #expect(log.programs().map(\.id) == ["messier"])
80 try FileManager.default.createDirectory(at: log.programsFolder, withIntermediateDirectories: true)
81 try "#+TITLE: Globulars\n* M13\n* M92\n".write(to: log.programsFolder.appending(path: "globulars.org"), atomically: true, encoding: .utf8)
82 try "notes".write(to: log.programsFolder.appending(path: "readme.txt"), atomically: true, encoding: .utf8)
83 #expect(log.programs().map(\.title) == ["Messier", "Globulars"])
84 // Program files are not log entries.
85 #expect(log.all().isEmpty)
86}
87
88@Test func progressCountsEachObjectOnce() throws {
89 let resolver = CatalogResolver.messier
90 let early = when.addingTimeInterval(-86_400)
91 let log = [
92 Observation(object: "Andromeda Galaxy", date: when, place: ""),
93 Observation(object: "M 31", date: early, place: ""),
94 Observation(object: "Dust lanes", date: when, place: "", catalog: ["M32"]),
95 Observation(object: "Saturn", date: when, place: ""),
96 Observation(object: "Kemble's Cascade", date: when, place: ""),
97 ]
98 let progress = ProgramProgress(program: .messier, log: log, resolver: resolver)
99 #expect(progress.count == 2)
100 #expect(progress.first["M31"]?.date == early)
101 #expect(progress.first["M32"] != nil)
102
103 let mine = ObservingProgram(org: "* Kemble’s Cascade\n* M31\n", id: "mine")
104 #expect(ProgramProgress(program: mine, log: log, resolver: resolver).count == 2)
105}
Packages/MoonriseKit/Tests/SkyEngineTests/ProgramVisibilityTests.swift added +21
@@ -0,0 +1,21 @@
1import Foundation
2import Testing
3@testable import SkyEngine
4
5@Test func bestInDarknessFromChicago() throws {
6 let m31 = (ra: 10.68471, dec: 41.26875)
7 let m7 = (ra: 268.46325, dec: -34.79283)
8 let m104 = (ra: 189.99763, dec: -11.62305)
9 let t = try Tonight(at: observed, from: chicago)
10 let best = try SkyEngine.bestInDarkness(j2000: [m31, m7, m104], from: t.start, to: t.end, observer: chicago)
11 let andromeda = try #require(best[0])
12 #expect(andromeda.position.altitude > 85)
13 #expect(andromeda.date > t.start && andromeda.date < t.end)
14 // M7 peaks at about 13°, M104 is near the Sun.
15 #expect(best[1] == nil)
16 #expect(best[2] == nil)
17
18 // M104 is 30° up at 13:00 CDT, but in daylight.
19 let day = try SkyEngine.bestInDarkness(j2000: [m104], from: ut(18, 0), to: ut(20, 0), observer: chicago)
20 #expect(day[0] == nil)
21}