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 {
150150
151151 private func reload() {
152152 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()))
154154 }
155155}
156156
App/Sources/LogScreen.swift +2 −1
@@ -13,6 +13,7 @@ struct LogScreen: View {
1313 var body: some View {
1414 NavigationStack {
1515 List {
16 NavigationLink("Programs") { ProgramsScreen(newEntry: newEntry) }
1617 if entries.isEmpty {
1718 Text("No observations yet. Add one here, or use Log on an object's card in the Sky tab.")
1819 .font(.footnote).foregroundStyle(.secondary)
@@ -102,7 +103,7 @@ struct ObservationEditor: View {
102103 ToolbarItem(placement: .confirmationAction) {
103104 Button("Save") {
104105 do {
105 try observingLog.save(observation)
106 try observingLog.save(observation, resolver: CatalogResolver(programs: observingLog.programs()))
106107 onSave()
107108 dismiss()
108109 } 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(
2626 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")]
2727 ),
2828 .target(name: "Feeds", dependencies: ["SkyEngine"]),
29 .target(name: "ObservingLog"),
29 .target(name: "ObservingLog", dependencies: ["Library", "SkyRenderer"]),
3030 .target(name: "Library", resources: [.copy("Resources/pages"), .copy("Resources/tours"), .copy("Resources/stories")]),
3131 .target(name: "Answers", dependencies: ["SkyEngine", "SkyRenderer", "Feeds", "Library"]),
3232 .testTarget(name: "SkyEngineTests", dependencies: ["SkyEngine"]),
Packages/MoonriseKit/Sources/ObservingLog/EquipmentUsage.swift +5 −3
@@ -27,8 +27,9 @@ public struct EquipmentUsage: Sendable, Equatable {
2727 /// IDs of other items logged alongside this one, most frequent first.
2828 public var usedWith: [Tally]
2929
30 /// Usage of the item `id` in `log`. `calendar` decides month and season boundaries.
31 public init(of id: String, in log: [Observation], calendar: Calendar = .current) {
30 /// Usage of the item `id` in `log`. `calendar` decides month and season boundaries. Objects are counted by
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) {
3233 let mine = log.filter { $0.equipmentIDs.contains(id) }
3334 observations = mine.count
3435 first = mine.map(\.date).min()
@@ -37,7 +38,8 @@ public struct EquipmentUsage: Sendable, Equatable {
3738 var objects: [String: (name: String, count: Int)] = [:]
3839 for o in mine {
3940 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
4143 }
4244 distinctObjects = objects.count
4345 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 {
3636 /// 1 (poor) to 5 (excellent); nil when not noted.
3737 public var seeing: Int?
3838 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?
3943 /// Properties added by hand, in file order.
4044 public var otherProperties: [OrgProperty]
4145
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:"]
4347
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] = []) {
4549 self.id = id
4650 self.object = object
4751 self.date = date
@@ -52,19 +56,39 @@ public struct Observation: Sendable, Identifiable, Equatable {
5256 self.equipmentIDs = equipmentIDs
5357 self.seeing = seeing
5458 self.notes = notes
59 self.catalog = catalog
60 self.utcOffset = utcOffset
5561 self.otherProperties = otherProperties
5662 }
5763
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 }
5980
6081 /// Org text: one heading with an inactive timestamp, a property drawer and the notes.
6182 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))
6386 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))) }
6488 if !equipment.isEmpty { props.append((":EQUIPMENT:", equipment)) }
6589 if !equipmentIDs.isEmpty { props.append((":EQUIPMENT_IDS:", equipmentIDs.joined(separator: " "))) }
6690 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:"]
6892 lines += props.map { "\($0.0) \($0.1)" }
6993 lines += otherProperties.map(\.line)
7094 lines.append(":END:")
@@ -77,8 +101,7 @@ public struct Observation: Sendable, Identifiable, Equatable {
77101 public init?(org text: String) {
78102 let lines = text.components(separatedBy: "\n")
79103 guard let head = lines.first, head.hasPrefix("* "),
80 lines.count > 1, lines[1].hasPrefix("["), lines[1].hasSuffix("]"),
81 let date = try? Date(String(lines[1].dropFirst().dropLast()), strategy: Self.orgDate.parseStrategy) else { return nil }
104 lines.count > 1, lines[1].hasPrefix("["), lines[1].hasSuffix("]") else { return nil }
82105 var props: [String: String] = [:]
83106 var other: [OrgProperty] = []
84107 var i = 2
@@ -92,6 +115,8 @@ public struct Observation: Sendable, Identifiable, Equatable {
92115 }
93116 i += 1
94117 }
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 }
95120 let notes = lines.dropFirst(i).joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
96121 let coords = props[":COORDINATES:"]?.split(separator: ",").compactMap { Double($0.trimmingCharacters(in: .whitespaces)) }
97122 self.init(
@@ -105,6 +130,8 @@ public struct Observation: Sendable, Identifiable, Equatable {
105130 equipmentIDs: props[":EQUIPMENT_IDS:"]?.split(separator: " ").map(String.init) ?? [],
106131 seeing: props[":SEEING:"].flatMap { Int($0.prefix(1)) },
107132 notes: notes,
133 catalog: props[":CATALOG:"]?.split(separator: " ").map(String.init) ?? [],
134 utcOffset: offset,
108135 otherProperties: other
109136 )
110137 }
@@ -129,13 +156,30 @@ public struct ObservingLog: Sendable {
129156 .sorted { $0.date > $1.date }
130157 }
131158
132 /// Writes the observation, replacing any earlier file with the same ID.
133 public func save(_ o: Observation) throws {
159 /// Writes the observation, replacing any earlier file with the same ID. Sets `utcOffset` from the device's zone
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] }
134166 try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
135167 try delete(id: o.id)
136168 try o.org.write(to: fileURL(for: o), atomically: true, encoding: .utf8)
137169 }
138170
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
139183 public func delete(id: String) throws {
140184 let files = (try? FileManager.default.contentsOfDirectory(at: folder, includingPropertiesForKeys: nil)) ?? []
141185 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
142142 #expect(out.contains(":FOCAL_RATIO: f/5.9\n:PURCHASED: 2019-05-02\n:SERIAL: X123\n:END:\n"))
143143 #expect(Equipment(org: out) == scope)
144144}
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}