Commit d3f811952b
Unsigned
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 @@ | |||
| 1 | import ObservingLog | ||
| 2 | import SkyEngine | ||
| 3 | import SwiftUI | ||
| 4 | |||
| 5 | /// Observing programs: the bundled Messier list plus Org files in Documents/Observations/Programs. | ||
| 6 | struct 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 | |||
| 44 | struct 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 @@ | |||
| 1 | import Foundation | ||
| 2 | import Library | ||
| 3 | import 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. | ||
| 7 | public 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. | ||
| 72 | public 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. | ||
| 152 | public 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 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | extension 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 @@ | |||
| 1 | import Foundation | ||
| 2 | import 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 @@ | |||
| 1 | import Foundation | ||
| 2 | import 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 | } | ||