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

Packages/MoonriseKit/Sources/ObservingLog/Programs.swift

59d401dca1a327705632d1e2e20f00f807dee828
moonrise/Packages/MoonriseKit/Sources/ObservingLog/Programs.swift history · blame · raw

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}