v2: Bright satellites and Starlink trains !31

merged merged by cmc on 2026-10-18 21:26 UTC · krz/moonrise:v2-bright-satellites into main

7 files changed, +102 −23

Layout: unified · split

App/Sources/TodaySheet.swift +20 −2
@@ -81,7 +81,7 @@ struct TodaySheet: View {
81 81
82 @ViewBuilder 82 @ViewBuilder
83 private var satellites: some View { 83 private var satellites: some View {
84 let shown = model.passes.prefix(8) 84 let shown = model.passes.filter(\.featured).prefix(8)
85 if shown.isEmpty { 85 if shown.isEmpty {
86 Text("No passes above 10° in the next three days.").font(.subheadline).dimmed(0.6) 86 Text("No passes above 10° in the next three days.").font(.subheadline).dimmed(0.6)
87 } else { 87 } else {
@@ -89,7 +89,7 @@ struct TodaySheet: View {
89 ForEach(Array(shown)) { p in 89 ForEach(Array(shown)) { p in
90 VStack(alignment: .leading, spacing: 2) { 90 VStack(alignment: .leading, spacing: 2) {
91 HStack { 91 HStack {
92 Text(p.name.replacingOccurrences(of: " (ZARYA)", with: "")).font(.subheadline.weight(.medium)) 92 Text(p.displayName).font(.subheadline.weight(.medium))
93 if p.visible { 93 if p.visible {
94 Text("Visible").font(.caption2.weight(.medium)).padding(.horizontal, 5).padding(.vertical, 1) 94 Text("Visible").font(.caption2.weight(.medium)).padding(.horizontal, 5).padding(.vertical, 1)
95 .overlay(RoundedRectangle(cornerRadius: 3).stroke(lineWidth: 1)) 95 .overlay(RoundedRectangle(cornerRadius: 3).stroke(lineWidth: 1))
@@ -100,11 +100,29 @@ struct TodaySheet: View {
100 } 100 }
101 .accessibilityElement(children: .combine) 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 Text("Visible means sunlit while your sky is dark. Orbits: CelesTrak.").font(.caption2).dimmed(0.6) 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 @ViewBuilder 126 @ViewBuilder
109 private var weather: some View { 127 private var weather: some View {
110 if let w = model.spaceWeather { 128 if let w = model.spaceWeather {
PLAN.org +3 −1
@@ -395,7 +395,9 @@ Sky, System, Sun, Launches, Library core (above).
395 2027 total eclipse (Spain, North Africa, Egypt) is a launch target. 395 2027 total eclipse (Spain, North Africa, Egypt) is a launch target.
396- Deep Space Network Now: which DSN dishes are talking to which 396- Deep Space Network Now: which DSN dishes are talking to which
397 spacecraft. 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- Observing log stored as plain text or Org. 401- Observing log stored as plain text or Org.
400- Mars: latest rover raw images, local time at each rover. 402- Mars: latest rover raw images, local time at each rover.
401- Library: exoplanets, rockets to scale, exploration timeline, scale 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 if let pass = passes 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 .max(by: { $0.maxAltitude < $1.maxAltitude }) { 49 .max(by: { $0.maxAltitude < $1.maxAltitude }) {
50 let when = pass.rise.formatted(time) + (calendar.isDate(pass.rise, inSameDayAs: date) ? "" : " tomorrow") 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 if let sunset = tonight.sunset { 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 public var setAzimuth: Double 11 public var setAzimuth: Double
12 /// Sunlit while the observer's sky is dark: can be seen with the eye. 12 /// Sunlit while the observer's sky is dark: can be seen with the eye.
13 public var visible: Bool 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 public var id: String { "\(name)-\(Int(rise.timeIntervalSince1970))" } 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
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") }
15} 33}
16 34
17extension SkyEngine { 35extension SkyEngine {
@@ -92,7 +110,7 @@ extension SkyEngine {
92 lastAz = h.azimuth 110 lastAz = h.azimuth
93 } else if let c = current { 111 } else if let c = current {
94 if c.maxAlt >= minAltitude { 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 current = nil 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@Test func summaryForAnEvening() throws { 18@Test func summaryForAnEvening() throws {
19 // 18:00 CDT, 18 October 2026. 19 // 18:00 CDT, 18 October 2026.
20 let date = Date(timeIntervalSince1970: 1_792_364_400) 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) 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) 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 let event = SkyEvent(kind: .meteorShower, date: date.addingTimeInterval(3 * 3600), title: "Orionids peak", detail: "", visibleHere: nil) 23 let event = SkyEvent(kind: .meteorShower, date: date.addingTimeInterval(3 * 3600), title: "Orionids peak", detail: "", visibleHere: nil)
24 let tomorrow = SkyEvent(kind: .moonPhase, date: date.addingTimeInterval(30 * 3600), title: "Full moon", detail: "", visibleHere: nil) 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 #expect(s.body.hasPrefix("Orionids peak. ")) 29 #expect(s.body.hasPrefix("Orionids peak. "))
30 #expect(!s.body.contains("Full moon")) 30 #expect(!s.body.contains("Full moon"))
31 #expect(s.body.contains("Up tonight: ")) 31 #expect(s.body.contains("Up tonight: "))
32 #expect(s.body.contains("ISS (ZARYA) passes at 19:30, 54° high.")) 32 #expect(s.body.contains("ISS passes at 19:30, 54° high."))
33 #expect(!s.body.contains("HST")) 33 #expect(!s.body.contains("Hubble"))
34 #expect(s.body.contains("Sunset 18:")) 34 #expect(s.body.contains("Sunset 18:"))
35} 35}
36 36
37@Test func morningPassSaysTomorrow() throws { 37@Test func morningPassSaysTomorrow() throws {
38 let date = Date(timeIntervalSince1970: 1_792_364_400) 38 let date = Date(timeIntervalSince1970: 1_792_364_400)
39 // 05:37 CDT on 19 October, before dawn. 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 let s = try DailySummary.make(at: date, observer: summaryObserver, passes: [pass], events: [], locale: gb, timeZone: central) 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// CelesTrak GP data. CelesTrak updates every 2 hours and blocks IPs that fetch more 1// CelesTrak GP data. CelesTrak updates every 2 hours and blocks IPs that fetch more
2// often or keep requesting after errors, so poll every 2 hours and stop on any non-200. 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. */ 4/** ISS, Tiangong (CSS Tianhe) and Hubble; all three are in the visual group. */
5export const SATELLITES = [25544, 48274, 20580]; 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;
6 9
7export interface OMM { 10export interface OMM {
8 OBJECT_NAME: string; 11 OBJECT_NAME: string;
@@ -23,15 +26,38 @@ export function pick(rows: Record<string, unknown>[]): OMM[] {
23 return rows.map((r) => Object.fromEntries(KEYS.map((k) => [k, r[k]])) as unknown as OMM); 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. */ 29/**
27export async function fetchElements(): Promise<OMM[]> { 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 }
28 const out: OMM[] = []; 40 const out: OMM[] = [];
29 for (const id of SATELLITES) { 41 for (const [launch, sats] of groups) {
30 const res = await fetch(`https://celestrak.org/NORAD/elements/gp.php?CATNR=${id}&FORMAT=JSON`, { 42 const sorted = [...sats].sort((a, b) => Number(a.MEAN_MOTION) - Number(b.MEAN_MOTION));
31 headers: { "user-agent": "moonrise-server (sh.krz.moonrise)" }, 43 const median = sorted[Math.floor(sorted.length / 2)];
32 }); 44 if (Number(median.MEAN_MOTION) <= TRAIN_MEAN_MOTION) continue;
33 if (res.status !== 200) throw new Error(`CelesTrak ${res.status} for ${id}`); 45 out.push({ ...pick([median])[0], OBJECT_NAME: `Starlink train (${launch})` });
34 out.push(...pick((await res.json()) as Record<string, unknown>[]));
35 } 46 }
36 return out; 47 return out;
37} 48}
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 @@
1import assert from "node:assert/strict"; 1import assert from "node:assert/strict";
2import { test } from "node:test"; 2import { test } from "node:test";
3import { pick } from "../src/celestrak.ts"; 3import { pick, starlinkTrains } from "../src/celestrak.ts";
4 4
5test("keeps only the SGP4 fields", () => { 5test("keeps only the SGP4 fields", () => {
6 // CelesTrak GP JSON for the ISS, 2026-10-18. 6 // CelesTrak GP JSON for the ISS, 2026-10-18.
@@ -14,3 +14,18 @@ test("keeps only the SGP4 fields", () => {
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"]); 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 assert.equal(o.NORAD_CAT_ID, 25544); 15 assert.equal(o.NORAD_CAT_ID, 25544);
16}); 16});
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});