tower / files / test / celestial.test.ts
celestial.test.ts
e331ec2a 2026-09-28 Pavel Safronov | 1 | import { describe, expect, it } from 'vitest' |
| 2 | import { | |
42b4554e 2026-09-29 Pavel Safronov | 3 | STAR_FACE_SIZE, bakeStars, equatorial, moonPhase, skyFrame, sunLongitude, |
e331ec2a 2026-09-28 Pavel Safronov | 4 | } from '../src/world/celestial.js' |
| 5 | import catalog from '../src/data/bright-stars.json' | |
| 6 | ||
| 7 | const DEG = Math.PI / 180 | |
| 8 | const apply = (m: number[], v: number[]) => | |
| 9 | [0, 1, 2].map((r) => m[3 * r]! * v[0]! + m[3 * r + 1]! * v[1]! + m[3 * r + 2]! * v[2]!) | |
| 10 | const angleDeg = (a: number[], b: number[]) => | |
| 11 | Math.acos(Math.min(1, a[0]! * b[0]! + a[1]! * b[1]! + a[2]! * b[2]!)) / DEG | |
| 12 | /** Precession from J2000 to mid-2026, in ecliptic longitude: the sun's offset from the catalogue's frame. */ | |
| 13 | const P2026 = (50.29 / 3600) * 26.4 | |
| 14 | /** A J2000 unit vector from ecliptic longitude and latitude, degrees. */ | |
| 15 | const ecliptic = (lon: number, lat: number) => { | |
| 16 | const e = 23.4392911 * DEG | |
| 17 | const x = Math.cos(lat * DEG) * Math.cos(lon * DEG) | |
| 18 | const y = Math.cos(lat * DEG) * Math.sin(lon * DEG) | |
| 19 | const z = Math.sin(lat * DEG) | |
| 20 | return [x, y * Math.cos(e) - z * Math.sin(e), y * Math.sin(e) + z * Math.cos(e)] | |
| 21 | } | |
| 22 | ||
| 23 | describe('the sky behind Tower (D-075)', () => { | |
| 24 | it("puts the sun where the almanac does", () => { | |
| 25 | // 2026's March equinox, 14:46 UTC, and June solstice, 08:24 UTC. | |
| 26 | expect(sunLongitude(new Date(Date.UTC(2026, 2, 20, 14, 46))) / DEG).toBeCloseTo(0, 1) | |
| 27 | expect(sunLongitude(new Date(Date.UTC(2026, 5, 21, 8, 24))) / DEG).toBeCloseTo(90, 1) | |
| 28 | }) | |
| 29 | ||
| 30 | it('is a rotation, not a mirror', () => { | |
| 31 | const m = skyFrame(new Date(Date.UTC(2026, 8, 27))) | |
| 32 | const det = m[0]! * (m[4]! * m[8]! - m[5]! * m[7]!) | |
| 33 | - m[1]! * (m[3]! * m[8]! - m[5]! * m[6]!) | |
| 34 | + m[2]! * (m[3]! * m[7]! - m[4]! * m[6]!) | |
| 35 | expect(det).toBeCloseTo(1, 9) | |
| 36 | }) | |
| 37 | ||
| 38 | it('points the sun cap at the sun and the far cap away from it', () => { | |
| 39 | const at = new Date(Date.UTC(2026, 5, 21, 8, 24)) | |
| 40 | const m = skyFrame(at) | |
| 41 | expect(angleDeg(apply(m, [0, 0, -1]), ecliptic(90 - P2026, 0))).toBeLessThan(0.02) | |
| 42 | expect(angleDeg(apply(m, [0, 0, 1]), ecliptic(270 - P2026, 0))).toBeLessThan(0.02) | |
| 43 | }) | |
| 44 | ||
| 45 | it('keeps ecliptic north up, whatever the day', () => { | |
| 46 | for (const month of [0, 3, 6, 9]) { | |
| 47 | const m = skyFrame(new Date(Date.UTC(2026, month, 1))) | |
| 48 | expect(angleDeg(apply(m, [0, 1, 0]), ecliptic(0, 90))).toBeLessThan(1e-6) | |
| 49 | } | |
| 50 | }) | |
| 51 | ||
| 52 | it('shows Orion from the far cap in December', () => { | |
| 53 | // At the December solstice the far cap looks at RA 6h, Dec +23°: Betelgeuse is 16° off. | |
| 54 | const m = skyFrame(new Date(Date.UTC(2026, 11, 21, 20, 50))) | |
| 55 | const betelgeuse = equatorial(88.79, 7.41) | |
| 56 | expect(angleDeg(apply(m, [0, 0, 1]), betelgeuse)).toBeLessThan(17) | |
| 57 | expect(angleDeg(apply(m, [0, 0, -1]), betelgeuse)).toBeGreaterThan(163) | |
| 58 | }) | |
| 59 | }) | |
| 60 | ||
| 61 | describe('the star catalogue', () => { | |
| 62 | const stars = catalog.stars | |
| 63 | it('holds the naked-eye sky, Sirius brightest', () => { | |
| 64 | expect(stars.length / 4).toBeGreaterThan(9000) | |
| 65 | let brightest = 0 | |
| 66 | for (let i = 4; i < stars.length; i += 4) if (stars[i + 2]! < stars[brightest + 2]!) brightest = i | |
| 67 | // Sirius: RA 6h 45m 09s, Dec −16° 43′, V −1.46. | |
| 68 | expect(stars[brightest]).toBeCloseTo(101.29, 1) | |
| 69 | expect(stars[brightest + 1]).toBeCloseTo(-16.72, 1) | |
| 70 | expect(stars[brightest + 2]).toBeCloseTo(-1.46, 2) | |
| 71 | }) | |
| 72 | }) | |
| 73 | ||
| 74 | describe('the star bake', () => { | |
| 75 | const S = STAR_FACE_SIZE | |
| 76 | const texel = (t: Float32Array, face: number, cu: number, cv: number) => | |
| 77 | Array.from(t.subarray(((face * S + cv) * S + cu) * 4, ((face * S + cv) * S + cu) * 4 + 4)) | |
| 78 | ||
| 79 | it('files a star under its face and cell', () => { | |
| 80 | // RA 0, Dec 0 is +x, the middle of face 0. | |
| 81 | const t = bakeStars([0, 0, 6.5, 0.4]) | |
| 82 | const [u, v, flux, bv] = texel(t, 0, S / 2, S / 2) | |
| 83 | expect([u, v]).toEqual([0, 0]) | |
| 84 | expect(flux).toBeCloseTo(1, 6) | |
| 85 | expect(bv).toBeCloseTo(0.4, 6) | |
| 86 | }) | |
| 87 | ||
| 88 | it('bakes a star by an edge into both faces, once each', () => { | |
| 89 | // Just past 45° from +x toward +y: on face 2 (+y), in face 0's border. | |
| 90 | const t = bakeStars([45.05, 0, 1, 0]) | |
| 91 | let copies = 0 | |
| 92 | for (let i = 2; i < t.length; i += 4) if (t[i]! > 0) copies++ | |
| 93 | expect(copies).toBe(2) | |
| 94 | }) | |
| 95 | ||
| 96 | it("adds a close double's light and keeps the brighter's place", () => { | |
| 97 | const t = bakeStars([0, 0, 6.5, 1.5, 0.01, 0, 5.0, -0.1]) | |
| 98 | const lit = [] as number[][] | |
| 99 | for (let i = 0; i < t.length; i += 4) if (t[i + 2]! > 0) lit.push(Array.from(t.subarray(i, i + 4))) | |
| 100 | expect(lit).toHaveLength(1) | |
| 101 | expect(lit[0]![2]).toBeCloseTo(1 + 10 ** 0.6, 4) | |
| 102 | expect(lit[0]![3]).toBeCloseTo(-0.1, 6) | |
| 103 | }) | |
| 104 | }) | |
42b4554e 2026-09-29 Pavel Safronov | 105 | |
| 106 | describe("Earth's Moon's phase", () => { | |
| 107 | it('is new, full and half at the moments the almanac says', () => { | |
| 108 | expect(moonPhase(new Date('2000-01-06T18:14Z')).fraction).toBeLessThan(0.001) | |
| 109 | expect(moonPhase(new Date('2024-04-23T23:49Z')).fraction).toBeGreaterThan(0.999) | |
| 110 | const first = moonPhase(new Date('2024-04-15T19:13Z')) | |
| 111 | expect(first.fraction).toBeCloseTo(0.5, 1) | |
| 112 | expect(first.waxing).toBe(true) | |
| 113 | const last = moonPhase(new Date('2024-05-01T11:27Z')) | |
| 114 | expect(last.fraction).toBeCloseTo(0.5, 1) | |
| 115 | expect(last.waxing).toBe(false) | |
| 116 | }) | |
| 117 | }) |