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

Commit 7b2ee0839a

7b2ee0839a110c391c2bc2eeb072ba59fa38563a

parent: a11ba2e1ca

Unsigned

cmc <hello@cleberg.net> · 2026-10-18 21:26 UTC

v2: Bright satellites and Starlink trains (!31)

- Server fetches CelesTrak's visual group (155 objects, includes ISS, Tiangong, Hubble) and last-30-days; each Starlink launch whose median mean motion is above 15.45 rev/day (below about 400 km) becomes one "Starlink train (YYYY-NNN)" element set. Two requests per 2 hours, as before.
- Passes carry =featured= (ISS, Tiangong, Hubble, trains) and =displayName= ("SL-8 rocket body").
- Tonight sheet: featured passes as before, plus "Also visible tonight": the five highest visible passes of other objects, in time order.
- Daily summary uses featured passes only.
- 72 h of passes for 158 objects: 0.73 s on the Mac (release).
- Deployed; the celestrak snapshot was seeded from files fetched while testing so the app sees the new list before the next cron.

Layout: unified · split

App/Sources/TodaySheet.swift +20 −2
@@ -81,7 +81,7 @@ struct TodaySheet: View {
8181
8282 @ViewBuilder
8383 private var satellites: some View {
84 let shown = model.passes.prefix(8)
84 let shown = model.passes.filter(\.featured).prefix(8)
8585 if shown.isEmpty {
8686 Text("No passes above 10° in the next three days.").font(.subheadline).dimmed(0.6)
8787 } else {
@@ -89,7 +89,7 @@ struct TodaySheet: View {
8989 ForEach(Array(shown)) { p in
9090 VStack(alignment: .leading, spacing: 2) {
9191 HStack {
92 Text(p.name.replacingOccurrences(of: " (ZARYA)", with: "")).font(.subheadline.weight(.medium))
92 Text(p.displayName).font(.subheadline.weight(.medium))
9393 if p.visible {
9494 Text("Visible").font(.caption2.weight(.medium)).padding(.horizontal, 5).padding(.vertical, 1)
9595 .overlay(RoundedRectangle(cornerRadius: 3).stroke(lineWidth: 1))
@@ -100,11 +100,29 @@ struct TodaySheet: View {
100100 }
101101 .accessibilityElement(children: .combine)
102102 }
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 }
103111 Text("Visible means sunlit while your sky is dark. Orbits: CelesTrak.").font(.caption2).dimmed(0.6)
104112 }
105113 }
106114 }
107115
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
108126 @ViewBuilder
109127 private var weather: some View {
110128 if let w = model.spaceWeather {
PLAN.org +3 −1
@@ -395,7 +395,9 @@ Sky, System, Sun, Launches, Library core (above).
395395 2027 total eclipse (Spain, North Africa, Egypt) is a launch target.
396396- Deep Space Network Now: which DSN dishes are talking to which
397397 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.
399401- Observing log stored as plain text or Org.
400402- Mars: latest rover raw images, local time at each rover.
401403- 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 {
4545 }
4646
4747 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 })
4949 .max(by: { $0.maxAltitude < $1.maxAltitude }) {
5050 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.")
5252 }
5353
5454 if let sunset = tonight.sunset {
Packages/MoonriseKit/Sources/SkyEngine/Passes.swift +19 −1
@@ -11,7 +11,25 @@ public struct SatellitePass: Sendable, Identifiable, Equatable {
1111 public var setAzimuth: Double
1212 /// Sunlit while the observer's sky is dark: can be seen with the eye.
1313 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
1416 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
29extension 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") }
1533}
1634
1735extension SkyEngine {
@@ -92,7 +110,7 @@ extension SkyEngine {
92110 lastAz = h.azimuth
93111 } else if let c = current {
94112 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))
96114 }
97115 current = nil
98116 }
Packages/MoonriseKit/Tests/SkyEngineTests/DailySummaryTests.swift +6 −6
@@ -18,8 +18,8 @@ private let gb = Locale(identifier: "en_GB")
1818@Test func summaryForAnEvening() throws {
1919 // 18:00 CDT, 18 October 2026.
2020 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)
2323 let event = SkyEvent(kind: .meteorShower, date: date.addingTimeInterval(3 * 3600), title: "Orionids peak", detail: "", visibleHere: nil)
2424 let tomorrow = SkyEvent(kind: .moonPhase, date: date.addingTimeInterval(30 * 3600), title: "Full moon", detail: "", visibleHere: nil)
2525
@@ -29,15 +29,15 @@ private let gb = Locale(identifier: "en_GB")
2929 #expect(s.body.hasPrefix("Orionids peak. "))
3030 #expect(!s.body.contains("Full moon"))
3131 #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"))
3434 #expect(s.body.contains("Sunset 18:"))
3535}
3636
3737@Test func morningPassSaysTomorrow() throws {
3838 let date = Date(timeIntervalSince1970: 1_792_364_400)
3939 // 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)
4141 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."))
4343}
server/src/celestrak.ts +36 −10
@@ -1,8 +1,11 @@
11// CelesTrak GP data. CelesTrak updates every 2 hours and blocks IPs that fetch more
22// often or keep requesting after errors, so poll every 2 hours and stop on any non-200.
33
4/** ISS, Tiangong (CSS Tianhe) and Hubble. */
5export const SATELLITES = [25544, 48274, 20580];
4/** ISS, Tiangong (CSS Tianhe) and Hubble; all three are in the visual group. */
5export 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). */
8const TRAIN_MEAN_MOTION = 15.45;
69
710export interface OMM {
811 OBJECT_NAME: string;
@@ -23,15 +26,38 @@ export function pick(rows: Record<string, unknown>[]): OMM[] {
2326 return rows.map((r) => Object.fromEntries(KEYS.map((k) => [k, r[k]])) as unknown as OMM);
2427}
2528
26/** Fetches each satellite in turn; throws on the first non-200 so nothing more is requested. */
27export 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 */
33export 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 }
2840 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})` });
3546 }
3647 return out;
3748}
49
50async 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. */
59export 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 @@
11import assert from "node:assert/strict";
22import { test } from "node:test";
3import { pick } from "../src/celestrak.ts";
3import { pick, starlinkTrains } from "../src/celestrak.ts";
44
55test("keeps only the SGP4 fields", () => {
66 // CelesTrak GP JSON for the ISS, 2026-10-18.
@@ -14,3 +14,18 @@ test("keeps only the SGP4 fields", () => {
1414 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"]);
1515 assert.equal(o.NORAD_CAT_ID, 25544);
1616});
17
18test("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});