Commit d60e2dc515
Verified · cmc
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 | |||
| 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 | extension SkyEngine { | 35 | extension 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. */ |
| 5 | export const SATELLITES = [25544, 48274, 20580]; | 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 | export interface OMM { | 10 | export 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 | /** |
| 27 | export 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 | */ | ||
| 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 | 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 | |||
| 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 | import assert from "node:assert/strict"; | 1 | import assert from "node:assert/strict"; |
| 2 | import { test } from "node:test"; | 2 | import { test } from "node:test"; |
| 3 | import { pick } from "../src/celestrak.ts"; | 3 | import { pick, starlinkTrains } from "../src/celestrak.ts"; |
| 4 | 4 | ||
| 5 | test("keeps only the SGP4 fields", () => { | 5 | test("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 | |||
| 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 | }); | ||