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/Feeds/MarsImages.swift

99 lines · 4427 bytes

13 symbols in this file
 1import Foundation
 2
 3/// Latest raw images from the Mars rovers, from NASA's public raw image feeds.
 4public struct MarsImage: Sendable, Identifiable, Hashable {
 5    public var id: String
 6    public var rover: String
 7    public var sol: Int
 8    public var camera: String
 9    public var taken: Date
10    public var thumbnail: URL
11    public var image: URL
12    public var credit: String
13
14    public static let perseveranceURL = URL(string: "https://mars.nasa.gov/rss/api/?feed=raw_images&category=mars2020&feedtype=json&num=100&page=0&order=sol+desc")!
15    public static let curiosityURL = URL(string: "https://mars.nasa.gov/api/v1/raw_image_items/?order=sol%20desc,date_taken%20desc&per_page=100&page=0&condition_1=msl:mission")!
16
17    /// Newest full frames from both rovers, `limit` per rover. A rover whose feed fails is left out.
18    public static func latest(limit: Int = 12, session: URLSession = .shared) async throws -> [MarsImage] {
19        async let p = try? decodePerseverance(await session.data(from: perseveranceURL).0)
20        async let c = try? decodeCuriosity(await session.data(from: curiosityURL).0)
21        let (pi, ci) = await (p, c)
22        guard pi != nil || ci != nil else { throw URLError(.cannotLoadFromNetwork) }
23        return Array((pi ?? []).prefix(limit)) + Array((ci ?? []).prefix(limit))
24    }
25
26    // MARK: Perseverance
27
28    private struct M2020: Decodable {
29        struct Image: Decodable {
30            struct Files: Decodable { var medium: URL; var large: URL }
31            struct Camera: Decodable { var instrument: String }
32            var imageid: String
33            var sol: Int
34            var sample_type: String
35            var date_taken_utc: String
36            var image_files: Files
37            var camera: Camera
38            var credit: String
39        }
40        var images: [Image]
41    }
42
43    static func decodePerseverance(_ data: Data) throws -> [MarsImage] {
44        try JSONDecoder().decode(M2020.self, from: data).images
45            .filter { $0.sample_type.lowercased() == "full" && $0.camera.instrument != "SKYCAM" && !$0.camera.instrument.hasSuffix("_RIGHT") }
46            .compactMap { i in
47                guard let taken = parseUTC(i.date_taken_utc) else { return nil }
48                return MarsImage(id: i.imageid, rover: "Perseverance", sol: i.sol, camera: cameraName(i.camera.instrument), taken: taken, thumbnail: i.image_files.medium, image: i.image_files.large, credit: i.credit)
49            }
50            .sorted { $0.taken > $1.taken }
51    }
52
53    // MARK: Curiosity
54
55    private struct MSL: Decodable {
56        struct Item: Decodable {
57            struct Extended: Decodable { var sample_type: String }
58            var imageid: String
59            var sol: Int
60            var instrument: String
61            var url: URL
62            var date_taken: String
63            var extended: Extended
64        }
65        var items: [Item]
66    }
67
68    static func decodeCuriosity(_ data: Data) throws -> [MarsImage] {
69        try JSONDecoder().decode(MSL.self, from: data).items
70            .filter { $0.extended.sample_type == "full" }
71            .compactMap { i in
72                guard let taken = parseUTC(i.date_taken) else { return nil }
73                return MarsImage(id: i.imageid, rover: "Curiosity", sol: i.sol, camera: cameraName(i.instrument), taken: taken, thumbnail: i.url, image: i.url, credit: "NASA/JPL-Caltech")
74            }
75            .sorted { $0.taken > $1.taken }
76    }
77
78    /// ISO 8601 with or without fractional seconds and zone; a missing zone is UTC.
79    static func parseUTC(_ s: String) -> Date? {
80        var s = s
81        if !s.hasSuffix("Z"), !s.contains("+") { s += "Z" }
82        if let d = try? Date(s, strategy: .iso8601) { return d }
83        return try? Date(s, strategy: Date.ISO8601FormatStyle(includingFractionalSeconds: true))
84    }
85
86    static func cameraName(_ code: String) -> String {
87        switch code {
88        case let c where c.hasPrefix("MCZ"): "Mastcam-Z"
89        case let c where c.hasPrefix("NAVCAM"), let c where c.hasPrefix("NAV_"): "Navcam"
90        case let c where c.contains("HAZ"): c.hasPrefix("R") ? "Rear Hazcam" : "Front Hazcam"
91        case "MAHLI": "MAHLI"
92        case let c where c.hasPrefix("MAST"): "Mastcam"
93        case "CHEMCAM_RMI": "ChemCam"
94        case let c where c.hasPrefix("SHERLOC"): "SHERLOC"
95        case let c where c.hasPrefix("SUPERCAM"): "SuperCam"
96        default: code
97        }
98    }
99}