Packages/MoonriseKit/Sources/ObservingLog/Programs.swift
169 lines · 7576 bytes
8 symbols in this file
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}