Commit 7b2ee0839a
Unsigned
Layout: unified · split
App/Sources/TodaySheet.swift +20 −2
| @@ -81,7 +81,7 @@ struct TodaySheet: View { | ||
| 81 | 81 | |
| 82 | 82 | @ViewBuilder |
| 83 | 83 | private var satellites: some View { |
| 84 | let shown = model.passes.prefix(8) | |
| 84 | let shown = model.passes.filter(\.featured).prefix(8) | |
| 85 | 85 | if shown.isEmpty { |
| 86 | 86 | Text("No passes above 10° in the next three days.").font(.subheadline).dimmed(0.6) |
| 87 | 87 | } else { |
| @@ -89,7 +89,7 @@ struct TodaySheet: View { | ||
| 89 | 89 | ForEach(Array(shown)) { p in |
| 90 | 90 | VStack(alignment: .leading, spacing: 2) { |
| 91 | 91 | HStack { |
| 92 | Text(p.name.replacingOccurrences(of: " (ZARYA)", with: "")).font(.subheadline.weight(.medium)) | |
| 92 | Text(p.displayName).font(.subheadline.weight(.medium)) | |
| 93 | 93 | if p.visible { |
| 94 | 94 | Text("Visible").font(.caption2.weight(.medium)).padding(.horizontal, 5).padding(.vertical, 1) |
| 95 | 95 | .overlay(RoundedRectangle(cornerRadius: 3).stroke(lineWidth: 1)) |
| @@ -100,11 +100,29 @@ struct TodaySheet: View { | ||
| 100 | 100 | } |
| 101 | 101 | .accessibilityElement(children: .combine) |
| 102 | 102 | } |
| 103 | let others = alsoVisible | |
| 104 | if !others.isEmpty { | |
| 105 | Text("Also visible tonight").font(.caption2.weight(.medium)).textCase(.uppercase).tracking(0.8).dimmed(0.6).padding(.top, 6) | |
| 106 | ForEach(others) { p in | |
| 107 | Text("\(p.displayName) · \(p.rise.formatted(date: .omitted, time: .shortened)), highest \(Int(p.maxAltitude.rounded()))°") | |
| 108 | .font(.footnote.monospacedDigit()).dimmed(0.8) | |
| 109 | } | |
| 110 | } | |
| 103 | 111 | Text("Visible means sunlit while your sky is dark. Orbits: CelesTrak.").font(.caption2).dimmed(0.6) |
| 104 | 112 | } |
| 105 | 113 | } |
| 106 | 114 | } |
| 107 | 115 | |
| 116 | /// The five highest passes tonight of other bright satellites (rocket bodies, old satellites), in time order. | |
| 117 | private var alsoVisible: [SatellitePass] { | |
| 118 | guard let t = model.tonight else { return [] } | |
| 119 | return Array(model.passes | |
| 120 | .filter { !$0.featured && $0.visible && $0.rise >= t.start && $0.rise <= t.end && $0.maxAltitude >= 30 } | |
| 121 | .sorted { $0.maxAltitude > $1.maxAltitude } | |
| 122 | .prefix(5)) | |
| 123 | .sorted { $0.rise < $1.rise } | |
| 124 | } | |
| 125 | ||
| 108 | 126 | @ViewBuilder |
| 109 | 127 | private var weather: some View { |
| 110 | 128 | if let w = model.spaceWeather { |
PLAN.org +3 −1
| @@ -395,7 +395,9 @@ Sky, System, Sun, Launches, Library core (above). | ||
| 395 | 395 | 2027 total eclipse (Spain, North Africa, Egypt) is a launch target. |
| 396 | 396 | - Deep Space Network Now: which DSN dishes are talking to which |
| 397 | 397 | spacecraft. |
| 398 | - More satellites: Tiangong, Hubble, Starlink trains, bright passes. | |
| 398 | - More satellites: Tiangong, Hubble, Starlink trains, bright passes. Done: | |
| 399 | CelesTrak visual group plus one element set per Starlink launch still | |
| 400 | below about 400 km. | |
| 399 | 401 | - Observing log stored as plain text or Org. |
| 400 | 402 | - Mars: latest rover raw images, local time at each rover. |
| 401 | 403 | - Library: exoplanets, rockets to scale, exploration timeline, scale |
Packages/MoonriseKit/Sources/SkyEngine/DailySummary.swift +2 −2
| @@ -45,10 +45,10 @@ public struct DailySummary: Sendable, Equatable { | ||
| 45 | 45 | } |
| 46 | 46 | |
| 47 | 47 | if let pass = passes |
| 48 | .filter({ $0.visible && $0.rise >= tonight.start.addingTimeInterval(-3600) && $0.rise <= tonight.end }) | |
| 48 | .filter({ $0.visible && $0.featured && $0.rise >= tonight.start.addingTimeInterval(-3600) && $0.rise <= tonight.end }) | |
| 49 | 49 | .max(by: { $0.maxAltitude < $1.maxAltitude }) { |
| 50 | 50 | let when = pass.rise.formatted(time) + (calendar.isDate(pass.rise, inSameDayAs: date) ? "" : " tomorrow") |
| 51 | lines.append("\(pass.name) passes at \(when), \(Int(pass.maxAltitude.rounded()))° high.") | |
| 51 | lines.append("\(pass.displayName) passes at \(when), \(Int(pass.maxAltitude.rounded()))° high.") | |
| 52 | 52 | } |
| 53 | 53 | |
| 54 | 54 | if let sunset = tonight.sunset { |
Packages/MoonriseKit/Sources/SkyEngine/Passes.swift +19 −1
| @@ -11,7 +11,25 @@ public struct SatellitePass: Sendable, Identifiable, Equatable { | ||
| 11 | 11 | public var setAzimuth: Double |
| 12 | 12 | /// Sunlit while the observer's sky is dark: can be seen with the eye. |
| 13 | 13 | public var visible: Bool |
| 14 | /// ISS, Tiangong, Hubble or a Starlink train, as opposed to the rest of CelesTrak's visual group. | |
| 15 | public var featured = false | |
| 14 | 16 | public var id: String { "\(name)-\(Int(rise.timeIntervalSince1970))" } |
| 17 | ||
| 18 | /// "ISS" for "ISS (ZARYA)", "Tiangong" for "CSS (TIANHE)", "SL-8 rocket body" for "SL-8 R/B". | |
| 19 | public var displayName: String { | |
| 20 | switch name { | |
| 21 | case "ISS (ZARYA)": "ISS" | |
| 22 | case "CSS (TIANHE)": "Tiangong" | |
| 23 | case "HST": "Hubble" | |
| 24 | default: name.replacingOccurrences(of: " R/B", with: " rocket body") | |
| 25 | } | |
| 26 | } | |
| 27 | } | |
| 28 | ||
| 29 | extension OrbitalElements { | |
| 30 | public static let featuredIDs: Set<Int> = [25544, 48274, 20580] | |
| 31 | ||
| 32 | public var isFeatured: Bool { Self.featuredIDs.contains(NORAD_CAT_ID) || OBJECT_NAME.hasPrefix("Starlink train") } | |
| 15 | 33 | } |
| 16 | 34 | |
| 17 | 35 | extension SkyEngine { |
| @@ -92,7 +110,7 @@ extension SkyEngine { | ||
| 92 | 110 | lastAz = h.azimuth |
| 93 | 111 | } else if let c = current { |
| 94 | 112 | if c.maxAlt >= minAltitude { |
| 95 | out.append(SatellitePass(name: elements.OBJECT_NAME, rise: c.rise, set: t, peak: c.peak, maxAltitude: c.maxAlt, riseAzimuth: c.riseAz, setAzimuth: lastAz, visible: c.visible)) | |
| 113 | out.append(SatellitePass(name: elements.OBJECT_NAME, rise: c.rise, set: t, peak: c.peak, maxAltitude: c.maxAlt, riseAzimuth: c.riseAz, setAzimuth: lastAz, visible: c.visible, featured: elements.isFeatured)) | |
| 96 | 114 | } |
| 97 | 115 | current = nil |
| 98 | 116 | } |
Packages/MoonriseKit/Tests/SkyEngineTests/DailySummaryTests.swift +6 −6
| @@ -18,8 +18,8 @@ private let gb = Locale(identifier: "en_GB") | ||
| 18 | 18 | @Test func summaryForAnEvening() throws { |
| 19 | 19 | // 18:00 CDT, 18 October 2026. |
| 20 | 20 | let date = Date(timeIntervalSince1970: 1_792_364_400) |
| 21 | let pass = SatellitePass(name: "ISS (ZARYA)", rise: date.addingTimeInterval(5400), set: date.addingTimeInterval(5760), peak: date.addingTimeInterval(5580), maxAltitude: 54.4, riseAzimuth: 240, setAzimuth: 60, visible: true) | |
| 22 | let faint = SatellitePass(name: "HST", rise: date.addingTimeInterval(7200), set: date.addingTimeInterval(7500), peak: date.addingTimeInterval(7350), maxAltitude: 70, riseAzimuth: 200, setAzimuth: 80, visible: false) | |
| 21 | let pass = SatellitePass(name: "ISS (ZARYA)", rise: date.addingTimeInterval(5400), set: date.addingTimeInterval(5760), peak: date.addingTimeInterval(5580), maxAltitude: 54.4, riseAzimuth: 240, setAzimuth: 60, visible: true, featured: true) | |
| 22 | let faint = SatellitePass(name: "HST", rise: date.addingTimeInterval(7200), set: date.addingTimeInterval(7500), peak: date.addingTimeInterval(7350), maxAltitude: 70, riseAzimuth: 200, setAzimuth: 80, visible: false, featured: true) | |
| 23 | 23 | let event = SkyEvent(kind: .meteorShower, date: date.addingTimeInterval(3 * 3600), title: "Orionids peak", detail: "", visibleHere: nil) |
| 24 | 24 | let tomorrow = SkyEvent(kind: .moonPhase, date: date.addingTimeInterval(30 * 3600), title: "Full moon", detail: "", visibleHere: nil) |
| 25 | 25 | |
| @@ -29,15 +29,15 @@ private let gb = Locale(identifier: "en_GB") | ||
| 29 | 29 | #expect(s.body.hasPrefix("Orionids peak. ")) |
| 30 | 30 | #expect(!s.body.contains("Full moon")) |
| 31 | 31 | #expect(s.body.contains("Up tonight: ")) |
| 32 | #expect(s.body.contains("ISS (ZARYA) passes at 19:30, 54° high.")) | |
| 33 | #expect(!s.body.contains("HST")) | |
| 32 | #expect(s.body.contains("ISS passes at 19:30, 54° high.")) | |
| 33 | #expect(!s.body.contains("Hubble")) | |
| 34 | 34 | #expect(s.body.contains("Sunset 18:")) |
| 35 | 35 | } |
| 36 | 36 | |
| 37 | 37 | @Test func morningPassSaysTomorrow() throws { |
| 38 | 38 | let date = Date(timeIntervalSince1970: 1_792_364_400) |
| 39 | 39 | // 05:37 CDT on 19 October, before dawn. |
| 40 | let pass = SatellitePass(name: "ISS (ZARYA)", rise: date.addingTimeInterval(41_820), set: date.addingTimeInterval(42_180), peak: date.addingTimeInterval(42_000), maxAltitude: 40, riseAzimuth: 240, setAzimuth: 60, visible: true) | |
| 40 | let pass = SatellitePass(name: "ISS (ZARYA)", rise: date.addingTimeInterval(41_820), set: date.addingTimeInterval(42_180), peak: date.addingTimeInterval(42_000), maxAltitude: 40, riseAzimuth: 240, setAzimuth: 60, visible: true, featured: true) | |
| 41 | 41 | let s = try DailySummary.make(at: date, observer: summaryObserver, passes: [pass], events: [], locale: gb, timeZone: central) |
| 42 | #expect(s.body.contains("ISS (ZARYA) passes at 5:37 tomorrow, 40° high.")) | |
| 42 | #expect(s.body.contains("ISS passes at 5:37 tomorrow, 40° high.")) | |
| 43 | 43 | } |
server/src/celestrak.ts +36 −10
| @@ -1,8 +1,11 @@ | ||
| 1 | 1 | // CelesTrak GP data. CelesTrak updates every 2 hours and blocks IPs that fetch more |
| 2 | 2 | // often or keep requesting after errors, so poll every 2 hours and stop on any non-200. |
| 3 | 3 | |
| 4 | /** ISS, Tiangong (CSS Tianhe) and Hubble. */ | |
| 5 | export const SATELLITES = [25544, 48274, 20580]; | |
| 4 | /** ISS, Tiangong (CSS Tianhe) and Hubble; all three are in the visual group. */ | |
| 5 | export const FEATURED = [25544, 48274, 20580]; | |
| 6 | ||
| 7 | /** Median mean motion above which a Starlink launch is still low enough to be seen as a train (about 400 km). */ | |
| 8 | const TRAIN_MEAN_MOTION = 15.45; | |
| 6 | 9 | |
| 7 | 10 | export interface OMM { |
| 8 | 11 | OBJECT_NAME: string; |
| @@ -23,15 +26,38 @@ export function pick(rows: Record<string, unknown>[]): OMM[] { | ||
| 23 | 26 | return rows.map((r) => Object.fromEntries(KEYS.map((k) => [k, r[k]])) as unknown as OMM); |
| 24 | 27 | } |
| 25 | 28 | |
| 26 | /** Fetches each satellite in turn; throws on the first non-200 so nothing more is requested. */ | |
| 27 | export async function fetchElements(): Promise<OMM[]> { | |
| 29 | /** | |
| 30 | * One element set per recent Starlink launch still low enough to be seen as a train, | |
| 31 | * the satellite with the median mean motion, named "Starlink train (2026-232)". | |
| 32 | */ | |
| 33 | export function starlinkTrains(rows: Record<string, unknown>[]): OMM[] { | |
| 34 | const groups = new Map<string, Record<string, unknown>[]>(); | |
| 35 | for (const r of rows) { | |
| 36 | if (!String(r.OBJECT_NAME).startsWith("STARLINK")) continue; | |
| 37 | const launch = String(r.OBJECT_ID).slice(0, 8); | |
| 38 | groups.set(launch, [...(groups.get(launch) ?? []), r]); | |
| 39 | } | |
| 28 | 40 | const out: OMM[] = []; |
| 29 | for (const id of SATELLITES) { | |
| 30 | const res = await fetch(`https://celestrak.org/NORAD/elements/gp.php?CATNR=${id}&FORMAT=JSON`, { | |
| 31 | headers: { "user-agent": "moonrise-server (sh.krz.moonrise)" }, | |
| 32 | }); | |
| 33 | if (res.status !== 200) throw new Error(`CelesTrak ${res.status} for ${id}`); | |
| 34 | out.push(...pick((await res.json()) as Record<string, unknown>[])); | |
| 41 | for (const [launch, sats] of groups) { | |
| 42 | const sorted = [...sats].sort((a, b) => Number(a.MEAN_MOTION) - Number(b.MEAN_MOTION)); | |
| 43 | const median = sorted[Math.floor(sorted.length / 2)]; | |
| 44 | if (Number(median.MEAN_MOTION) <= TRAIN_MEAN_MOTION) continue; | |
| 45 | out.push({ ...pick([median])[0], OBJECT_NAME: `Starlink train (${launch})` }); | |
| 35 | 46 | } |
| 36 | 47 | return out; |
| 37 | 48 | } |
| 49 | ||
| 50 | async function group(name: string): Promise<Record<string, unknown>[]> { | |
| 51 | const res = await fetch(`https://celestrak.org/NORAD/elements/gp.php?GROUP=${name}&FORMAT=JSON`, { | |
| 52 | headers: { "user-agent": "moonrise-server (sh.krz.moonrise)" }, | |
| 53 | }); | |
| 54 | if (res.status !== 200) throw new Error(`CelesTrak ${res.status} for ${name}`); | |
| 55 | return (await res.json()) as Record<string, unknown>[]; | |
| 56 | } | |
| 57 | ||
| 58 | /** The visual group (about 150 bright objects) and Starlink trains; throws on the first non-200 so nothing more is requested. */ | |
| 59 | export async function fetchElements(): Promise<OMM[]> { | |
| 60 | const visual = pick(await group("visual")); | |
| 61 | const recent = await group("last-30-days"); | |
| 62 | return [...visual, ...starlinkTrains(recent)]; | |
| 63 | } | |
server/test/celestrak.test.ts +16 −1
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | import assert from "node:assert/strict"; |
| 2 | 2 | import { test } from "node:test"; |
| 3 | import { pick } from "../src/celestrak.ts"; | |
| 3 | import { pick, starlinkTrains } from "../src/celestrak.ts"; | |
| 4 | 4 | |
| 5 | 5 | test("keeps only the SGP4 fields", () => { |
| 6 | 6 | // CelesTrak GP JSON for the ISS, 2026-10-18. |
| @@ -14,3 +14,18 @@ test("keeps only the SGP4 fields", () => { | ||
| 14 | 14 | assert.deepEqual(Object.keys(o).sort(), ["ARG_OF_PERICENTER", "BSTAR", "ECCENTRICITY", "EPOCH", "INCLINATION", "MEAN_ANOMALY", "MEAN_MOTION", "NORAD_CAT_ID", "OBJECT_NAME", "RA_OF_ASC_NODE"]); |
| 15 | 15 | assert.equal(o.NORAD_CAT_ID, 25544); |
| 16 | 16 | }); |
| 17 | ||
| 18 | test("one Starlink train per low recent launch, median satellite", () => { | |
| 19 | const sat = (id: string, n: number, mm: number) => ({ | |
| 20 | OBJECT_NAME: `STARLINK-${n}`, OBJECT_ID: id, NORAD_CAT_ID: n, EPOCH: "2026-10-18T00:00:00", MEAN_MOTION: mm, | |
| 21 | ECCENTRICITY: 0.0001, INCLINATION: 53, RA_OF_ASC_NODE: 10, ARG_OF_PERICENTER: 90, MEAN_ANOMALY: 0, BSTAR: 0.001, | |
| 22 | }); | |
| 23 | const trains = starlinkTrains([ | |
| 24 | sat("2026-232A", 1, 15.9), sat("2026-232B", 2, 16.0), sat("2026-232C", 3, 15.95), | |
| 25 | sat("2026-180A", 4, 15.06), sat("2026-180B", 5, 15.07), | |
| 26 | { ...sat("2026-232D", 6, 16.1), OBJECT_NAME: "FALCON 9 R/B" }, | |
| 27 | ]); | |
| 28 | assert.equal(trains.length, 1); | |
| 29 | assert.equal(trains[0].OBJECT_NAME, "Starlink train (2026-232)"); | |
| 30 | assert.equal(trains[0].NORAD_CAT_ID, 3); | |
| 31 | }); | |