warplab 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,223 @@
1
+ // ─── Geodesic Integration in Schwarzschild Spacetime ────────────────────
2
+ // RK4 integrator for null and timelike geodesics.
3
+ // Uses lightweight Vec3 instead of THREE.Vector3.
4
+
5
+ import { Vec3 } from "./vec3";
6
+ import type { GeodesicOutcome, GeodesicResult } from "./types";
7
+
8
+ const MAX_STEPS = 5000;
9
+ const MAX_STEPS_MASSIVE = 20000;
10
+ const MAX_DIST = 200;
11
+
12
+ /**
13
+ * Compute the timelike effective potential V_eff(r) for massive particles.
14
+ * V_eff = (1 - rs/r)(1 + L²/r²)
15
+ */
16
+ export function timelikeVeff(r: number, rs: number, L: number): number {
17
+ if (r <= rs) return 0;
18
+ return (1 - rs / r) * (1 + (L * L) / (r * r));
19
+ }
20
+
21
+ /**
22
+ * Find the ISCO radius for a given rs.
23
+ * For Schwarzschild: r_isco = 3 * rs (= 6M since rs = 2M)
24
+ */
25
+ export function iscoRadius(rs: number): number {
26
+ return 3 * rs;
27
+ }
28
+
29
+ /**
30
+ * Compute the energy of a circular orbit at radius r.
31
+ */
32
+ export function circularOrbitEnergy(r: number, rs: number, L: number): number {
33
+ return timelikeVeff(r, rs, L);
34
+ }
35
+
36
+ /**
37
+ * Integrate a null geodesic (photon) in Schwarzschild spacetime using RK4.
38
+ */
39
+ export function integrateGeodesic(
40
+ startPos: Vec3,
41
+ startVel: Vec3,
42
+ rs: number,
43
+ stepSize = 0.04,
44
+ ): GeodesicResult {
45
+ const points: { x: number; y: number; z: number }[] = [];
46
+
47
+ const vel = startVel.clone().normalize();
48
+ const pos = startPos.clone();
49
+
50
+ const Lvec = new Vec3().crossVectors(pos, vel);
51
+ const L2 = Lvec.lengthSq;
52
+
53
+ const rHorizon = rs;
54
+ const halfRs = 1.5 * rs;
55
+
56
+ points.push({ x: pos.x, y: pos.y, z: pos.z });
57
+
58
+ let outcome: GeodesicOutcome = "orbiting";
59
+
60
+ const tmpPos = new Vec3();
61
+ const k1v = new Vec3();
62
+ const k2v = new Vec3();
63
+ const k3v = new Vec3();
64
+ const k4v = new Vec3();
65
+
66
+ for (let i = 0; i < MAX_STEPS; i++) {
67
+ const r = pos.length;
68
+
69
+ if (r <= rHorizon * 1.01) {
70
+ outcome = "captured";
71
+ break;
72
+ }
73
+ if (r > MAX_DIST) {
74
+ outcome = "scattered";
75
+ break;
76
+ }
77
+
78
+ const accel = (r5: number, p: Vec3, out: Vec3) => {
79
+ const factor = -halfRs * L2 / r5;
80
+ out.copy(p).multiplyScalar(factor);
81
+ };
82
+
83
+ const dt = stepSize;
84
+
85
+ const r1 = pos.length;
86
+ const r1_5 = r1 * r1 * r1 * r1 * r1;
87
+ accel(r1_5, pos, k1v);
88
+
89
+ tmpPos.copy(pos).addScaledVector(vel, dt * 0.5);
90
+ const r2 = tmpPos.length;
91
+ const r2_5 = r2 * r2 * r2 * r2 * r2;
92
+ accel(r2_5, tmpPos, k2v);
93
+
94
+ tmpPos.copy(pos).addScaledVector(vel, dt * 0.5).addScaledVector(k1v, dt * dt * 0.25);
95
+ const r3 = tmpPos.length;
96
+ const r3_5 = r3 * r3 * r3 * r3 * r3;
97
+ accel(r3_5, tmpPos, k3v);
98
+
99
+ tmpPos.copy(pos).addScaledVector(vel, dt).addScaledVector(k2v, dt * dt * 0.5);
100
+ const r4 = tmpPos.length;
101
+ const r4_5 = r4 * r4 * r4 * r4 * r4;
102
+ accel(r4_5, tmpPos, k4v);
103
+
104
+ pos.addScaledVector(vel, dt);
105
+
106
+ vel.addScaledVector(k1v, dt / 6);
107
+ vel.addScaledVector(k2v, dt / 3);
108
+ vel.addScaledVector(k3v, dt / 3);
109
+ vel.addScaledVector(k4v, dt / 6);
110
+
111
+ points.push({ x: pos.x, y: pos.y, z: pos.z });
112
+ }
113
+
114
+ return { points, outcome, L: Math.sqrt(L2), particleType: "photon" };
115
+ }
116
+
117
+ /**
118
+ * Integrate a timelike (massive particle) geodesic in Schwarzschild spacetime.
119
+ */
120
+ export function integrateTimelikeGeodesic(
121
+ startPos: Vec3,
122
+ startVel: Vec3,
123
+ rs: number,
124
+ energy: number,
125
+ stepSize = 0.03,
126
+ ): GeodesicResult {
127
+ const points: { x: number; y: number; z: number }[] = [];
128
+
129
+ const pos = startPos.clone();
130
+ const r0 = pos.length;
131
+
132
+ const velDir = startVel.clone().normalize();
133
+ const Lvec = new Vec3().crossVectors(pos, velDir);
134
+ const L = Lvec.length;
135
+ const L2 = L * L;
136
+
137
+ const veff0 = timelikeVeff(r0, rs, L);
138
+ const drdt2 = energy - veff0;
139
+
140
+ const vTangential = L / r0;
141
+ const vRadial = Math.sqrt(Math.max(0, drdt2));
142
+
143
+ const rHat = pos.clone().normalize();
144
+ const tangentDir = new Vec3().crossVectors(Lvec.clone().normalize(), rHat);
145
+
146
+ const radialSign = velDir.dot(rHat) < 0 ? -1 : 1;
147
+
148
+ const vel = tangentDir.multiplyScalar(vTangential)
149
+ .addScaledVector(rHat, radialSign * vRadial);
150
+
151
+ const rHorizon = rs;
152
+ const halfRs = 0.5 * rs;
153
+ const threeHalfRs = 1.5 * rs;
154
+
155
+ points.push({ x: pos.x, y: pos.y, z: pos.z });
156
+
157
+ let outcome: GeodesicOutcome = "orbiting";
158
+ let prevR = r0;
159
+ let radialTurns = 0;
160
+ let increasing = vel.dot(pos.clone().normalize()) > 0;
161
+
162
+ const tmpPos = new Vec3();
163
+ const k1v = new Vec3();
164
+ const k2v = new Vec3();
165
+ const k3v = new Vec3();
166
+ const k4v = new Vec3();
167
+
168
+ const accel = (p: Vec3, out: Vec3) => {
169
+ const r = p.length;
170
+ const r3 = r * r * r;
171
+ const r5 = r3 * r * r;
172
+ const factor = -halfRs / r3 - threeHalfRs * L2 / r5;
173
+ out.copy(p).multiplyScalar(factor);
174
+ };
175
+
176
+ for (let i = 0; i < MAX_STEPS_MASSIVE; i++) {
177
+ const r = pos.length;
178
+
179
+ if (r <= rHorizon * 1.01) {
180
+ outcome = "captured";
181
+ break;
182
+ }
183
+ if (r > MAX_DIST) {
184
+ outcome = "scattered";
185
+ break;
186
+ }
187
+
188
+ const nowIncreasing = r > prevR;
189
+ if (i > 5 && nowIncreasing !== increasing) {
190
+ radialTurns++;
191
+ increasing = nowIncreasing;
192
+ if (radialTurns >= 6) {
193
+ outcome = "bound";
194
+ break;
195
+ }
196
+ }
197
+ prevR = r;
198
+
199
+ const dt = stepSize;
200
+
201
+ accel(pos, k1v);
202
+
203
+ tmpPos.copy(pos).addScaledVector(vel, dt * 0.5);
204
+ accel(tmpPos, k2v);
205
+
206
+ tmpPos.copy(pos).addScaledVector(vel, dt * 0.5).addScaledVector(k1v, dt * dt * 0.25);
207
+ accel(tmpPos, k3v);
208
+
209
+ tmpPos.copy(pos).addScaledVector(vel, dt).addScaledVector(k2v, dt * dt * 0.5);
210
+ accel(tmpPos, k4v);
211
+
212
+ pos.addScaledVector(vel, dt);
213
+
214
+ vel.addScaledVector(k1v, dt / 6);
215
+ vel.addScaledVector(k2v, dt / 3);
216
+ vel.addScaledVector(k3v, dt / 3);
217
+ vel.addScaledVector(k4v, dt / 6);
218
+
219
+ points.push({ x: pos.x, y: pos.y, z: pos.z });
220
+ }
221
+
222
+ return { points, outcome, L, particleType: "particle" };
223
+ }
@@ -0,0 +1,53 @@
1
+ // ─── WarpLab Core ───────────────────────────────────────────────────────
2
+ // Pure computation library for gravitational wave physics.
3
+ // No browser dependencies — works in Node.js, Deno, and browser.
4
+
5
+ export type {
6
+ GWEvent,
7
+ WaveformData,
8
+ QNMMode,
9
+ CharacteristicStrain,
10
+ BinaryParams,
11
+ GeodesicOutcome,
12
+ ParticleType,
13
+ GeodesicResult,
14
+ } from "./types";
15
+
16
+ export { Vec3 } from "./vec3";
17
+
18
+ export { fetchEventCatalog, classifyEvent } from "./catalog";
19
+
20
+ export { generateWaveform, generateCustomWaveform } from "./waveform";
21
+
22
+ export {
23
+ computeQNMModes,
24
+ estimateFinalSpin,
25
+ estimateFinalMass,
26
+ } from "./qnm";
27
+
28
+ export {
29
+ computeCharacteristicStrain,
30
+ computeOptimalSNR,
31
+ getALIGOCharacteristicStrain,
32
+ interpolateALIGO_ASD,
33
+ } from "./noise-curve";
34
+
35
+ export {
36
+ integrateGeodesic,
37
+ integrateTimelikeGeodesic,
38
+ timelikeVeff,
39
+ iscoRadius,
40
+ circularOrbitEnergy,
41
+ } from "./geodesic";
42
+
43
+ export type { EMCounterpart, MultiMessengerData } from "./multi-messenger";
44
+ export { getMultiMessengerData } from "./multi-messenger";
45
+
46
+ export {
47
+ generateParametersJSON,
48
+ generateParametersCSV,
49
+ generateWaveformCSV,
50
+ generateBibTeX,
51
+ generateNotebook,
52
+ generateREADME,
53
+ } from "./export";
@@ -0,0 +1,77 @@
1
+ // ─── Multi-Messenger Data ───────────────────────────────────────────────
2
+ // EM counterpart metadata for GW events with electromagnetic counterparts.
3
+
4
+ export interface EMCounterpart {
5
+ name: string;
6
+ type: "GRB" | "optical" | "X-ray" | "radio" | "kilonova";
7
+ delaySeconds: number;
8
+ observatory: string;
9
+ description: string;
10
+ }
11
+
12
+ export interface MultiMessengerData {
13
+ eventName: string;
14
+ emCounterpart: string;
15
+ emCounterparts: EMCounterpart[];
16
+ hostGalaxy: string;
17
+ hostGalaxyDistanceMpc: number;
18
+ h0Measurement: string;
19
+ ejectaMass: string;
20
+ grbDelay: string;
21
+ }
22
+
23
+ const MM_DATA: Record<string, MultiMessengerData> = {
24
+ GW170817: {
25
+ eventName: "GW170817",
26
+ emCounterpart: "GRB 170817A / AT 2017gfo (kilonova)",
27
+ emCounterparts: [
28
+ {
29
+ name: "GRB 170817A",
30
+ type: "GRB",
31
+ delaySeconds: 1.7,
32
+ observatory: "Fermi GBM / INTEGRAL SPI-ACS",
33
+ description:
34
+ "A short gamma-ray burst detected just 1.7 seconds after the gravitational wave signal, confirming the long-theorized link between neutron star mergers and short GRBs.",
35
+ },
36
+ {
37
+ name: "AT 2017gfo",
38
+ type: "kilonova",
39
+ delaySeconds: 11 * 3600,
40
+ observatory: "Swope Telescope (Las Campanas)",
41
+ description:
42
+ "The first kilonova observed with a known gravitational-wave source. Its rapid reddening revealed freshly synthesized heavy elements forged by the r-process.",
43
+ },
44
+ {
45
+ name: "CXO J130948.0\u2013233120",
46
+ type: "X-ray",
47
+ delaySeconds: 9 * 86400,
48
+ observatory: "Chandra X-ray Observatory",
49
+ description:
50
+ "X-ray emission appeared days after the merger, produced by the interaction of the relativistic jet with the surrounding interstellar medium.",
51
+ },
52
+ {
53
+ name: "VLA J130948.0\u2013233120",
54
+ type: "radio",
55
+ delaySeconds: 16 * 86400,
56
+ observatory: "Karl G. Jansky VLA",
57
+ description:
58
+ "Radio afterglow from the expanding cocoon of material around the jet, confirming the merger launched a structured relativistic outflow.",
59
+ },
60
+ ],
61
+ hostGalaxy: "NGC 4993, 40 Mpc",
62
+ hostGalaxyDistanceMpc: 40,
63
+ h0Measurement: "70 +12/\u22128 km/s/Mpc",
64
+ ejectaMass: "~0.05 M\u2609",
65
+ grbDelay: "1.7 s",
66
+ },
67
+ };
68
+
69
+ /**
70
+ * Look up multi-messenger data for a given event common name.
71
+ * Returns null if no EM counterpart data exists.
72
+ */
73
+ export function getMultiMessengerData(
74
+ commonName: string,
75
+ ): MultiMessengerData | null {
76
+ return MM_DATA[commonName] ?? null;
77
+ }
@@ -0,0 +1,162 @@
1
+ // ─── FFT, Characteristic Strain & SNR Computation ───────────────────────
2
+ // Radix-2 Cooley-Tukey FFT, characteristic strain computation, and
3
+ // aLIGO design sensitivity. Pure computation — no Canvas/DOM.
4
+
5
+ import type { WaveformData, CharacteristicStrain } from "./types";
6
+
7
+ // ─── FFT ──────────────────────────────────────────────────────────────
8
+
9
+ /** In-place radix-2 Cooley-Tukey FFT. Arrays must have length = power of 2. */
10
+ function fftInPlace(re: Float64Array, im: Float64Array): void {
11
+ const N = re.length;
12
+ for (let i = 1, j = 0; i < N; i++) {
13
+ let bit = N >> 1;
14
+ while (j & bit) {
15
+ j ^= bit;
16
+ bit >>= 1;
17
+ }
18
+ j ^= bit;
19
+ if (i < j) {
20
+ [re[i], re[j]] = [re[j], re[i]];
21
+ [im[i], im[j]] = [im[j], im[i]];
22
+ }
23
+ }
24
+ for (let len = 2; len <= N; len *= 2) {
25
+ const halfLen = len / 2;
26
+ const angle = (-2 * Math.PI) / len;
27
+ const wRe = Math.cos(angle);
28
+ const wIm = Math.sin(angle);
29
+ for (let i = 0; i < N; i += len) {
30
+ let curRe = 1;
31
+ let curIm = 0;
32
+ for (let j = 0; j < halfLen; j++) {
33
+ const a = i + j;
34
+ const b = a + halfLen;
35
+ const tRe = curRe * re[b] - curIm * im[b];
36
+ const tIm = curRe * im[b] + curIm * re[b];
37
+ re[b] = re[a] - tRe;
38
+ im[b] = im[a] - tIm;
39
+ re[a] += tRe;
40
+ im[a] += tIm;
41
+ const nextRe = curRe * wRe - curIm * wIm;
42
+ curIm = curRe * wIm + curIm * wRe;
43
+ curRe = nextRe;
44
+ }
45
+ }
46
+ }
47
+ }
48
+
49
+ /** Next power of 2 >= n */
50
+ function nextPow2(n: number): number {
51
+ let p = 1;
52
+ while (p < n) p *= 2;
53
+ return p;
54
+ }
55
+
56
+ // ─── Characteristic strain from waveform ──────────────────────────────
57
+
58
+ /**
59
+ * Compute characteristic strain h_c(f) from a time-domain waveform.
60
+ * Zero-pads to at least 2048 samples (next power of 2).
61
+ */
62
+ export function computeCharacteristicStrain(waveform: WaveformData): CharacteristicStrain {
63
+ const minN = 2048;
64
+ const N = nextPow2(Math.max(waveform.hPlus.length, minN));
65
+ const dt = 1 / waveform.sampleRate;
66
+
67
+ const re = new Float64Array(N);
68
+ const im = new Float64Array(N);
69
+ for (let i = 0; i < waveform.hPlus.length; i++) {
70
+ re[i] = waveform.hPlus[i];
71
+ }
72
+
73
+ fftInPlace(re, im);
74
+
75
+ const halfN = N / 2;
76
+ const df = 1 / (N * dt);
77
+ const frequencies = new Float64Array(halfN);
78
+ const hc = new Float64Array(halfN);
79
+
80
+ for (let k = 0; k < halfN; k++) {
81
+ const f = k * df;
82
+ frequencies[k] = f;
83
+ const mag = Math.sqrt(re[k] * re[k] + im[k] * im[k]) * dt;
84
+ hc[k] = 2 * f * mag;
85
+ }
86
+
87
+ return { frequencies, hc };
88
+ }
89
+
90
+ // ─── aLIGO design sensitivity ─────────────────────────────────────────
91
+
92
+ const ALIGO_DATA: [number, number][] = [
93
+ [10, 1e-20], [11, 6.5e-21], [12, 4.2e-21], [13, 2.9e-21],
94
+ [14, 2.1e-21], [15, 1.6e-21], [16, 1.3e-21], [17, 1.1e-21],
95
+ [18, 9.0e-22], [19, 7.8e-22], [20, 6.8e-22], [22, 5.3e-22],
96
+ [24, 4.3e-22], [26, 3.6e-22], [28, 3.1e-22], [30, 2.7e-22],
97
+ [33, 2.3e-22], [36, 2.0e-22], [40, 1.7e-22], [45, 1.4e-22],
98
+ [50, 1.2e-22], [55, 1.05e-22], [60, 9.5e-23], [65, 8.7e-23],
99
+ [70, 8.0e-23], [75, 7.5e-23], [80, 7.0e-23], [85, 6.6e-23],
100
+ [90, 6.3e-23], [95, 6.0e-23], [100, 5.7e-23], [110, 5.2e-23],
101
+ [120, 4.8e-23], [130, 4.5e-23], [140, 4.2e-23], [150, 4.0e-23],
102
+ [160, 3.9e-23], [170, 3.8e-23], [180, 3.7e-23], [190, 3.6e-23],
103
+ [200, 3.6e-23], [220, 3.6e-23], [240, 3.7e-23], [260, 3.8e-23],
104
+ [280, 4.0e-23], [300, 4.2e-23], [320, 4.5e-23], [340, 4.8e-23],
105
+ [360, 5.2e-23], [380, 5.6e-23], [400, 6.0e-23], [430, 6.8e-23],
106
+ [460, 7.7e-23], [500, 9.0e-23], [550, 1.1e-22], [600, 1.3e-22],
107
+ [650, 1.5e-22], [700, 1.8e-22], [750, 2.1e-22], [800, 2.5e-22],
108
+ [850, 2.9e-22], [900, 3.4e-22], [950, 4.0e-22], [1000, 4.6e-22],
109
+ [1100, 6.2e-22], [1200, 8.2e-22], [1300, 1.1e-21], [1400, 1.4e-21],
110
+ [1500, 1.8e-21], [1600, 2.3e-21], [1700, 3.0e-21], [1800, 3.8e-21],
111
+ [1900, 4.8e-21], [2000, 6.0e-21], [2200, 9.5e-21], [2400, 1.5e-20],
112
+ [2600, 2.3e-20], [2800, 3.5e-20], [3000, 5.5e-20], [3500, 1.5e-19],
113
+ [4000, 4.5e-19], [4500, 1.3e-18], [5000, 4.0e-18],
114
+ ];
115
+
116
+ /**
117
+ * Get the aLIGO design sensitivity as characteristic strain h_c = √(f · S_n(f)).
118
+ */
119
+ export function getALIGOCharacteristicStrain(): { frequencies: number[]; hc: number[] } {
120
+ const frequencies: number[] = [];
121
+ const hc: number[] = [];
122
+ for (const [f, asd] of ALIGO_DATA) {
123
+ frequencies.push(f);
124
+ hc.push(Math.sqrt(f) * asd);
125
+ }
126
+ return { frequencies, hc };
127
+ }
128
+
129
+ /** Log-log interpolate aLIGO ASD at an arbitrary frequency. */
130
+ export function interpolateALIGO_ASD(f: number): number {
131
+ if (f <= ALIGO_DATA[0][0]) return ALIGO_DATA[0][1];
132
+ if (f >= ALIGO_DATA[ALIGO_DATA.length - 1][0]) return ALIGO_DATA[ALIGO_DATA.length - 1][1];
133
+ for (let i = 0; i < ALIGO_DATA.length - 1; i++) {
134
+ const [f0, a0] = ALIGO_DATA[i];
135
+ const [f1, a1] = ALIGO_DATA[i + 1];
136
+ if (f >= f0 && f <= f1) {
137
+ const t = Math.log(f / f0) / Math.log(f1 / f0);
138
+ return Math.exp(Math.log(a0) + t * Math.log(a1 / a0));
139
+ }
140
+ }
141
+ return ALIGO_DATA[ALIGO_DATA.length - 1][1];
142
+ }
143
+
144
+ /**
145
+ * Compute optimal matched-filter SNR.
146
+ * rho^2 = integral( (h_c(f))^2 / (h_n(f))^2 ) d(ln f)
147
+ */
148
+ export function computeOptimalSNR(strain: CharacteristicStrain): number {
149
+ const fMin = 10;
150
+ const fMax = 5000;
151
+ let rhoSq = 0;
152
+ for (let k = 1; k < strain.frequencies.length - 1; k++) {
153
+ const f = strain.frequencies[k];
154
+ if (f < fMin || f > fMax || strain.hc[k] <= 0) continue;
155
+ const asd = interpolateALIGO_ASD(f);
156
+ const hn = Math.sqrt(f) * asd;
157
+ const ratio = strain.hc[k] / hn;
158
+ const df = strain.frequencies[k + 1] - strain.frequencies[k];
159
+ rhoSq += ratio * ratio * (df / f);
160
+ }
161
+ return Math.sqrt(rhoSq);
162
+ }
@@ -0,0 +1,97 @@
1
+ // ─── Quasi-Normal Mode (QNM) Computation ────────────────────────────
2
+ // Berti fitting coefficients for Kerr QNM frequencies and damping times.
3
+ // Reference: Berti, Cardoso & Starinets, CQG 26, 163001 (2009), Table VIII.
4
+ //
5
+ // ω = f1 + f2 * (1 - a_f)^f3 (frequency in geometric units)
6
+ // τ⁻¹ = q1 + q2 * (1 - a_f)^q3 (inverse quality factor)
7
+ // Physical frequency: f = ω / (2π M_f) in natural units → converted to Hz
8
+
9
+ import type { QNMMode } from "./types";
10
+
11
+ // Berti fitting coefficients: [f1, f2, f3, q1, q2, q3]
12
+ const BERTI_COEFFS: Record<string, [number, number, number, number, number, number]> = {
13
+ "2,2,0": [1.5251, -1.1568, 0.1292, 0.7000, 1.4187, -0.4990],
14
+ "2,2,1": [1.3673, -1.0260, 0.1628, 0.3562, 2.3420, -0.2467],
15
+ };
16
+
17
+ // Physical constants
18
+ const MSUN_KG = 1.989e30;
19
+ const G = 6.674e-11;
20
+ const C = 2.998e8;
21
+ const MSUN_SEC = (G * MSUN_KG) / (C * C * C); // ~4.926e-6 s
22
+
23
+ /**
24
+ * Estimate the final spin of the remnant BH using the Hofmann et al. (2016) fit.
25
+ * Simplified: a_f ≈ √12 η - 3.871 η² + 4.028 η³ (non-spinning limit)
26
+ */
27
+ export function estimateFinalSpin(m1: number, m2: number, chi1 = 0, chi2 = 0): number {
28
+ const totalMass = m1 + m2;
29
+ const eta = (m1 * m2) / (totalMass * totalMass);
30
+ const chiEff = (m1 * chi1 + m2 * chi2) / totalMass;
31
+
32
+ const aFinal = Math.sqrt(12) * eta - 3.871 * eta * eta + 4.028 * eta * eta * eta
33
+ + chiEff * eta * (2.0 - 1.25 * eta);
34
+
35
+ return Math.min(Math.max(aFinal, 0), 0.998);
36
+ }
37
+
38
+ /**
39
+ * Estimate the final mass of the remnant BH.
40
+ * Uses the Healy & Lousto (2017) fit for radiated energy.
41
+ */
42
+ export function estimateFinalMass(m1: number, m2: number): number {
43
+ const totalMass = m1 + m2;
44
+ const eta = (m1 * m2) / (totalMass * totalMass);
45
+ const erad = 0.0559745 * eta + 0.1469 * eta * eta;
46
+ return totalMass * (1 - erad);
47
+ }
48
+
49
+ /**
50
+ * Compute QNM modes for a binary black hole merger.
51
+ *
52
+ * @param m1 - Primary mass in solar masses
53
+ * @param m2 - Secondary mass in solar masses
54
+ * @param chi1 - Primary dimensionless spin (default 0)
55
+ * @param chi2 - Secondary dimensionless spin (default 0)
56
+ * @param modes - Which (l,m,n) modes to compute (default: fundamental + first overtone)
57
+ * @returns Array of QNMMode results
58
+ */
59
+ export function computeQNMModes(
60
+ m1: number,
61
+ m2: number,
62
+ chi1 = 0,
63
+ chi2 = 0,
64
+ modes: string[] = ["2,2,0", "2,2,1"],
65
+ ): QNMMode[] {
66
+ const finalMass = estimateFinalMass(m1, m2);
67
+ const finalSpin = estimateFinalSpin(m1, m2, chi1, chi2);
68
+
69
+ const mfSec = finalMass * MSUN_SEC;
70
+
71
+ const results: QNMMode[] = [];
72
+
73
+ for (const modeKey of modes) {
74
+ const coeffs = BERTI_COEFFS[modeKey];
75
+ if (!coeffs) continue;
76
+
77
+ const [f1, f2, f3, q1, q2, q3] = coeffs;
78
+
79
+ const omegaHat = f1 + f2 * Math.pow(1 - finalSpin, f3);
80
+ const Q = q1 + q2 * Math.pow(1 - finalSpin, q3);
81
+
82
+ const frequency = omegaHat / (2 * Math.PI * mfSec);
83
+ const dampingTime = Q / (Math.PI * frequency);
84
+
85
+ const [l, m, n] = modeKey.split(",").map(Number);
86
+
87
+ results.push({
88
+ l, m, n,
89
+ frequency,
90
+ dampingTime,
91
+ qualityFactor: Q,
92
+ label: `(${l},${m},${n})`,
93
+ });
94
+ }
95
+
96
+ return results;
97
+ }
@@ -0,0 +1,86 @@
1
+ // ─── Shared types for WarpLab core computation ─────────────────────────
2
+ // These types are used by both browser and server (MCP/CLI) entry points.
3
+
4
+ /** GWOSC event catalog entry */
5
+ export interface GWEvent {
6
+ commonName: string;
7
+ GPS: number;
8
+ mass_1_source: number;
9
+ mass_1_source_lower: number;
10
+ mass_1_source_upper: number;
11
+ mass_2_source: number;
12
+ mass_2_source_lower: number;
13
+ mass_2_source_upper: number;
14
+ luminosity_distance: number;
15
+ luminosity_distance_lower: number;
16
+ luminosity_distance_upper: number;
17
+ redshift: number;
18
+ chi_eff: number;
19
+ network_matched_filter_snr: number;
20
+ far: number;
21
+ catalog_shortName: string;
22
+ total_mass_source: number;
23
+ chirp_mass_source: number;
24
+ chirp_mass_source_lower: number;
25
+ chirp_mass_source_upper: number;
26
+ final_mass_source: number;
27
+ final_mass_source_lower: number;
28
+ final_mass_source_upper: number;
29
+ p_astro: number;
30
+ /** Derived 3D position for the universe map (ra, dec, distance → cartesian) */
31
+ mapPosition?: { x: number; y: number; z: number };
32
+ }
33
+
34
+ /** Pre-processed waveform data for an event */
35
+ export interface WaveformData {
36
+ eventName: string;
37
+ sampleRate: number;
38
+ hPlus: number[];
39
+ hCross: number[];
40
+ duration: number;
41
+ peakIndex: number;
42
+ }
43
+
44
+ /** A single QNM mode result */
45
+ export interface QNMMode {
46
+ /** Mode indices (l, m, n) */
47
+ l: number;
48
+ m: number;
49
+ n: number;
50
+ /** Oscillation frequency in Hz */
51
+ frequency: number;
52
+ /** Damping time in seconds */
53
+ dampingTime: number;
54
+ /** Quality factor Q = π f τ */
55
+ qualityFactor: number;
56
+ /** Label string e.g. "(2,2,0)" */
57
+ label: string;
58
+ }
59
+
60
+ /** Characteristic strain data from FFT */
61
+ export interface CharacteristicStrain {
62
+ /** Frequency bins in Hz */
63
+ frequencies: Float64Array;
64
+ /** h_c(f) = 2f |h̃(f)| */
65
+ hc: Float64Array;
66
+ }
67
+
68
+ /** Parameters for a custom binary merger waveform */
69
+ export interface BinaryParams {
70
+ m1: number; // solar masses (1–150)
71
+ m2: number; // solar masses (1–150)
72
+ chi1: number; // spin (-1 to +1)
73
+ chi2: number; // spin (-1 to +1)
74
+ distance: number; // Mpc
75
+ inclination: number; // radians
76
+ }
77
+
78
+ export type GeodesicOutcome = "captured" | "scattered" | "orbiting" | "bound";
79
+ export type ParticleType = "photon" | "particle";
80
+
81
+ export interface GeodesicResult {
82
+ points: { x: number; y: number; z: number }[];
83
+ outcome: GeodesicOutcome;
84
+ L: number;
85
+ particleType: ParticleType;
86
+ }