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.
- package/LICENSE +21 -0
- package/README.md +163 -0
- package/dist-server/cli.js +1276 -0
- package/dist-server/mcp.js +1600 -0
- package/package.json +49 -0
- package/src/core/catalog.ts +134 -0
- package/src/core/export.ts +454 -0
- package/src/core/geodesic.ts +223 -0
- package/src/core/index.ts +53 -0
- package/src/core/multi-messenger.ts +77 -0
- package/src/core/noise-curve.ts +162 -0
- package/src/core/qnm.ts +97 -0
- package/src/core/types.ts +86 -0
- package/src/core/vec3.ts +65 -0
- package/src/core/waveform.ts +156 -0
|
@@ -0,0 +1,1600 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// server/mcp.ts
|
|
4
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
|
|
8
|
+
// src/core/vec3.ts
|
|
9
|
+
var Vec3 = class _Vec3 {
|
|
10
|
+
constructor(x = 0, y = 0, z2 = 0) {
|
|
11
|
+
this.x = x;
|
|
12
|
+
this.y = y;
|
|
13
|
+
this.z = z2;
|
|
14
|
+
}
|
|
15
|
+
clone() {
|
|
16
|
+
return new _Vec3(this.x, this.y, this.z);
|
|
17
|
+
}
|
|
18
|
+
copy(v) {
|
|
19
|
+
this.x = v.x;
|
|
20
|
+
this.y = v.y;
|
|
21
|
+
this.z = v.z;
|
|
22
|
+
return this;
|
|
23
|
+
}
|
|
24
|
+
get length() {
|
|
25
|
+
return Math.sqrt(this.x * this.x + this.y * this.y + this.z * this.z);
|
|
26
|
+
}
|
|
27
|
+
get lengthSq() {
|
|
28
|
+
return this.x * this.x + this.y * this.y + this.z * this.z;
|
|
29
|
+
}
|
|
30
|
+
normalize() {
|
|
31
|
+
const l = this.length;
|
|
32
|
+
if (l > 0) {
|
|
33
|
+
this.x /= l;
|
|
34
|
+
this.y /= l;
|
|
35
|
+
this.z /= l;
|
|
36
|
+
}
|
|
37
|
+
return this;
|
|
38
|
+
}
|
|
39
|
+
multiplyScalar(s) {
|
|
40
|
+
this.x *= s;
|
|
41
|
+
this.y *= s;
|
|
42
|
+
this.z *= s;
|
|
43
|
+
return this;
|
|
44
|
+
}
|
|
45
|
+
addScaledVector(v, s) {
|
|
46
|
+
this.x += v.x * s;
|
|
47
|
+
this.y += v.y * s;
|
|
48
|
+
this.z += v.z * s;
|
|
49
|
+
return this;
|
|
50
|
+
}
|
|
51
|
+
dot(v) {
|
|
52
|
+
return this.x * v.x + this.y * v.y + this.z * v.z;
|
|
53
|
+
}
|
|
54
|
+
crossVectors(a, b) {
|
|
55
|
+
this.x = a.y * b.z - a.z * b.y;
|
|
56
|
+
this.y = a.z * b.x - a.x * b.z;
|
|
57
|
+
this.z = a.x * b.y - a.y * b.x;
|
|
58
|
+
return this;
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// src/core/catalog.ts
|
|
63
|
+
var GWOSC_API = "https://gwosc.org/eventapi/json/allevents/";
|
|
64
|
+
function seededRandom(seed) {
|
|
65
|
+
let s = seed;
|
|
66
|
+
return () => {
|
|
67
|
+
s = (s * 16807 + 0) % 2147483647;
|
|
68
|
+
return s / 2147483647;
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
async function fetchEventCatalog() {
|
|
72
|
+
const res = await fetch(GWOSC_API);
|
|
73
|
+
if (!res.ok) throw new Error(`GWOSC API returned ${res.status}`);
|
|
74
|
+
const data = await res.json();
|
|
75
|
+
const catalogPriority = {
|
|
76
|
+
"O1_O2-Preliminary": 1,
|
|
77
|
+
"Initial_LIGO_Virgo": 1,
|
|
78
|
+
"GWTC-1-marginal": 2,
|
|
79
|
+
"GWTC-1-confident": 3,
|
|
80
|
+
"GWTC-2": 4,
|
|
81
|
+
"GWTC-2.1-marginal": 5,
|
|
82
|
+
"GWTC-2.1-auxiliary": 5,
|
|
83
|
+
"GWTC-2.1-confident": 6,
|
|
84
|
+
"GWTC-3-marginal": 7,
|
|
85
|
+
"GWTC-3-confident": 8,
|
|
86
|
+
"O3_Discovery_Papers": 8,
|
|
87
|
+
"O3_IMBH_marginal": 7,
|
|
88
|
+
"IAS-O3a": 5,
|
|
89
|
+
"GWTC-4.0": 9,
|
|
90
|
+
"O4_Discovery_Papers": 9
|
|
91
|
+
};
|
|
92
|
+
const deduped = /* @__PURE__ */ new Map();
|
|
93
|
+
for (const [, entry] of Object.entries(data.events)) {
|
|
94
|
+
const e = entry;
|
|
95
|
+
const name = e.commonName ?? "";
|
|
96
|
+
if (!name) continue;
|
|
97
|
+
const existing = deduped.get(name);
|
|
98
|
+
if (existing) {
|
|
99
|
+
const existingPri = catalogPriority[existing["catalog.shortName"] ?? ""] ?? 0;
|
|
100
|
+
const newPri = catalogPriority[e["catalog.shortName"] ?? ""] ?? 0;
|
|
101
|
+
if (newPri > existingPri || newPri === existingPri && !existing.mass_1_source && e.mass_1_source) {
|
|
102
|
+
deduped.set(name, e);
|
|
103
|
+
}
|
|
104
|
+
} else {
|
|
105
|
+
deduped.set(name, e);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const events = [];
|
|
109
|
+
for (const [, e] of deduped) {
|
|
110
|
+
if (!e.mass_1_source || !e.mass_2_source) continue;
|
|
111
|
+
const gps = e.GPS ?? 0;
|
|
112
|
+
const distance = e.luminosity_distance ?? 0;
|
|
113
|
+
const rng = seededRandom(Math.floor(gps));
|
|
114
|
+
const ra = rng() * 2 * Math.PI;
|
|
115
|
+
const dec = Math.asin(2 * rng() - 1);
|
|
116
|
+
const r = distance;
|
|
117
|
+
const x = r * Math.cos(dec) * Math.cos(ra);
|
|
118
|
+
const y = r * Math.cos(dec) * Math.sin(ra);
|
|
119
|
+
const z2 = r * Math.sin(dec);
|
|
120
|
+
const event = {
|
|
121
|
+
commonName: e.commonName ?? "",
|
|
122
|
+
GPS: gps,
|
|
123
|
+
mass_1_source: e.mass_1_source,
|
|
124
|
+
mass_1_source_lower: e.mass_1_source_lower ?? 0,
|
|
125
|
+
mass_1_source_upper: e.mass_1_source_upper ?? 0,
|
|
126
|
+
mass_2_source: e.mass_2_source,
|
|
127
|
+
mass_2_source_lower: e.mass_2_source_lower ?? 0,
|
|
128
|
+
mass_2_source_upper: e.mass_2_source_upper ?? 0,
|
|
129
|
+
luminosity_distance: distance,
|
|
130
|
+
luminosity_distance_lower: e.luminosity_distance_lower ?? 0,
|
|
131
|
+
luminosity_distance_upper: e.luminosity_distance_upper ?? 0,
|
|
132
|
+
redshift: e.redshift ?? 0,
|
|
133
|
+
chi_eff: e.chi_eff ?? 0,
|
|
134
|
+
network_matched_filter_snr: e.network_matched_filter_snr ?? 0,
|
|
135
|
+
far: e.far ?? 0,
|
|
136
|
+
catalog_shortName: e["catalog.shortName"] ?? "",
|
|
137
|
+
total_mass_source: e.total_mass_source ?? 0,
|
|
138
|
+
chirp_mass_source: e.chirp_mass_source ?? 0,
|
|
139
|
+
chirp_mass_source_lower: e.chirp_mass_source_lower ?? 0,
|
|
140
|
+
chirp_mass_source_upper: e.chirp_mass_source_upper ?? 0,
|
|
141
|
+
final_mass_source: e.final_mass_source ?? 0,
|
|
142
|
+
final_mass_source_lower: e.final_mass_source_lower ?? 0,
|
|
143
|
+
final_mass_source_upper: e.final_mass_source_upper ?? 0,
|
|
144
|
+
p_astro: e.p_astro ?? 0,
|
|
145
|
+
mapPosition: { x, y, z: z2 }
|
|
146
|
+
};
|
|
147
|
+
events.push(event);
|
|
148
|
+
}
|
|
149
|
+
events.sort(
|
|
150
|
+
(a, b) => b.network_matched_filter_snr - a.network_matched_filter_snr
|
|
151
|
+
);
|
|
152
|
+
return events;
|
|
153
|
+
}
|
|
154
|
+
function classifyEvent(event) {
|
|
155
|
+
const total = event.mass_1_source + event.mass_2_source;
|
|
156
|
+
if (total < 5) return "BNS";
|
|
157
|
+
if (event.mass_2_source < 3) return "NSBH";
|
|
158
|
+
return "BBH";
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// src/core/waveform.ts
|
|
162
|
+
function generateWaveform(event) {
|
|
163
|
+
const m1 = event.mass_1_source;
|
|
164
|
+
const m2 = event.mass_2_source;
|
|
165
|
+
const totalMass = m1 + m2;
|
|
166
|
+
const chirpMass = Math.pow(m1 * m2, 3 / 5) / Math.pow(totalMass, 1 / 5);
|
|
167
|
+
const sampleRate = 512;
|
|
168
|
+
const duration = Math.min(4, Math.max(1.5, 120 / chirpMass));
|
|
169
|
+
const numSamples = Math.floor(duration * sampleRate);
|
|
170
|
+
const mergerIndex = Math.floor(numSamples * 0.75);
|
|
171
|
+
const hPlus = new Array(numSamples);
|
|
172
|
+
const hCross = new Array(numSamples);
|
|
173
|
+
const fRingdown = 32e3 / totalMass;
|
|
174
|
+
const tauRingdown = totalMass / 5e3;
|
|
175
|
+
for (let i = 0; i < numSamples; i++) {
|
|
176
|
+
const t = i / sampleRate;
|
|
177
|
+
const tMerger = mergerIndex / sampleRate;
|
|
178
|
+
let amplitude;
|
|
179
|
+
let phase;
|
|
180
|
+
if (i < mergerIndex) {
|
|
181
|
+
const tau = Math.max(tMerger - t, 1e-3);
|
|
182
|
+
const freqFactor = chirpMass / 30;
|
|
183
|
+
amplitude = 0.3 * Math.pow(0.5 / tau, 1 / 4);
|
|
184
|
+
phase = -2 * Math.PI * 20 * Math.pow(0.5, 3 / 8) * (8 / 5) * Math.pow(tau, 5 / 8) * freqFactor;
|
|
185
|
+
amplitude = Math.min(amplitude, 1);
|
|
186
|
+
} else {
|
|
187
|
+
const tPost = t - tMerger;
|
|
188
|
+
amplitude = Math.exp(-tPost / tauRingdown);
|
|
189
|
+
phase = 2 * Math.PI * fRingdown * tPost;
|
|
190
|
+
}
|
|
191
|
+
hPlus[i] = amplitude * Math.cos(phase);
|
|
192
|
+
hCross[i] = amplitude * Math.sin(phase);
|
|
193
|
+
}
|
|
194
|
+
let maxAmp = 0;
|
|
195
|
+
for (let i = 0; i < numSamples; i++) {
|
|
196
|
+
const a = Math.sqrt(hPlus[i] ** 2 + hCross[i] ** 2);
|
|
197
|
+
if (a > maxAmp) maxAmp = a;
|
|
198
|
+
}
|
|
199
|
+
if (maxAmp > 0) {
|
|
200
|
+
for (let i = 0; i < numSamples; i++) {
|
|
201
|
+
hPlus[i] /= maxAmp;
|
|
202
|
+
hCross[i] /= maxAmp;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return {
|
|
206
|
+
eventName: event.commonName,
|
|
207
|
+
sampleRate,
|
|
208
|
+
hPlus,
|
|
209
|
+
hCross,
|
|
210
|
+
duration,
|
|
211
|
+
peakIndex: mergerIndex
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
function generateCustomWaveform(params) {
|
|
215
|
+
const { m1, m2, chi1, chi2, inclination } = params;
|
|
216
|
+
const totalMass = m1 + m2;
|
|
217
|
+
const chirpMass = Math.pow(m1 * m2, 3 / 5) / Math.pow(totalMass, 1 / 5);
|
|
218
|
+
const chiEff = (m1 * chi1 + m2 * chi2) / totalMass;
|
|
219
|
+
const sampleRate = 512;
|
|
220
|
+
const duration = Math.min(4, Math.max(1.5, 120 / chirpMass));
|
|
221
|
+
const numSamples = Math.floor(duration * sampleRate);
|
|
222
|
+
const mergerIndex = Math.floor(numSamples * 0.75);
|
|
223
|
+
const hPlus = new Array(numSamples);
|
|
224
|
+
const hCross = new Array(numSamples);
|
|
225
|
+
const fRingdown = 32e3 / totalMass * (1 + 0.15 * Math.abs(chiEff));
|
|
226
|
+
const tauRingdown = totalMass / 5e3;
|
|
227
|
+
const cosInc = Math.cos(inclination);
|
|
228
|
+
const ampPlus = (1 + cosInc * cosInc) / 2;
|
|
229
|
+
const ampCross = cosInc;
|
|
230
|
+
for (let i = 0; i < numSamples; i++) {
|
|
231
|
+
const t = i / sampleRate;
|
|
232
|
+
const tMerger = mergerIndex / sampleRate;
|
|
233
|
+
let amplitude;
|
|
234
|
+
let phase;
|
|
235
|
+
if (i < mergerIndex) {
|
|
236
|
+
const tau = Math.max(tMerger - t, 1e-3);
|
|
237
|
+
const freqFactor = chirpMass / 30;
|
|
238
|
+
amplitude = 0.3 * Math.pow(0.5 / tau, 1 / 4);
|
|
239
|
+
phase = -2 * Math.PI * 20 * Math.pow(0.5, 3 / 8) * (8 / 5) * Math.pow(tau, 5 / 8) * freqFactor;
|
|
240
|
+
amplitude = Math.min(amplitude, 1);
|
|
241
|
+
} else {
|
|
242
|
+
const tPost = t - tMerger;
|
|
243
|
+
amplitude = Math.exp(-tPost / tauRingdown);
|
|
244
|
+
phase = 2 * Math.PI * fRingdown * tPost;
|
|
245
|
+
}
|
|
246
|
+
hPlus[i] = amplitude * ampPlus * Math.cos(phase);
|
|
247
|
+
hCross[i] = amplitude * ampCross * Math.sin(phase);
|
|
248
|
+
}
|
|
249
|
+
let maxAmp = 0;
|
|
250
|
+
for (let i = 0; i < numSamples; i++) {
|
|
251
|
+
const a = Math.sqrt(hPlus[i] ** 2 + hCross[i] ** 2);
|
|
252
|
+
if (a > maxAmp) maxAmp = a;
|
|
253
|
+
}
|
|
254
|
+
if (maxAmp > 0) {
|
|
255
|
+
for (let i = 0; i < numSamples; i++) {
|
|
256
|
+
hPlus[i] /= maxAmp;
|
|
257
|
+
hCross[i] /= maxAmp;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
const name = `Custom (${m1.toFixed(0)}+${m2.toFixed(0)} M\u2609)`;
|
|
261
|
+
return {
|
|
262
|
+
eventName: name,
|
|
263
|
+
sampleRate,
|
|
264
|
+
hPlus,
|
|
265
|
+
hCross,
|
|
266
|
+
duration,
|
|
267
|
+
peakIndex: mergerIndex
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// src/core/qnm.ts
|
|
272
|
+
var BERTI_COEFFS = {
|
|
273
|
+
"2,2,0": [1.5251, -1.1568, 0.1292, 0.7, 1.4187, -0.499],
|
|
274
|
+
"2,2,1": [1.3673, -1.026, 0.1628, 0.3562, 2.342, -0.2467]
|
|
275
|
+
};
|
|
276
|
+
var MSUN_KG = 1989e27;
|
|
277
|
+
var G = 6674e-14;
|
|
278
|
+
var C = 2998e5;
|
|
279
|
+
var MSUN_SEC = G * MSUN_KG / (C * C * C);
|
|
280
|
+
function estimateFinalSpin(m1, m2, chi1 = 0, chi2 = 0) {
|
|
281
|
+
const totalMass = m1 + m2;
|
|
282
|
+
const eta = m1 * m2 / (totalMass * totalMass);
|
|
283
|
+
const chiEff = (m1 * chi1 + m2 * chi2) / totalMass;
|
|
284
|
+
const aFinal = Math.sqrt(12) * eta - 3.871 * eta * eta + 4.028 * eta * eta * eta + chiEff * eta * (2 - 1.25 * eta);
|
|
285
|
+
return Math.min(Math.max(aFinal, 0), 0.998);
|
|
286
|
+
}
|
|
287
|
+
function estimateFinalMass(m1, m2) {
|
|
288
|
+
const totalMass = m1 + m2;
|
|
289
|
+
const eta = m1 * m2 / (totalMass * totalMass);
|
|
290
|
+
const erad = 0.0559745 * eta + 0.1469 * eta * eta;
|
|
291
|
+
return totalMass * (1 - erad);
|
|
292
|
+
}
|
|
293
|
+
function computeQNMModes(m1, m2, chi1 = 0, chi2 = 0, modes = ["2,2,0", "2,2,1"]) {
|
|
294
|
+
const finalMass = estimateFinalMass(m1, m2);
|
|
295
|
+
const finalSpin = estimateFinalSpin(m1, m2, chi1, chi2);
|
|
296
|
+
const mfSec = finalMass * MSUN_SEC;
|
|
297
|
+
const results = [];
|
|
298
|
+
for (const modeKey of modes) {
|
|
299
|
+
const coeffs = BERTI_COEFFS[modeKey];
|
|
300
|
+
if (!coeffs) continue;
|
|
301
|
+
const [f1, f2, f3, q1, q2, q3] = coeffs;
|
|
302
|
+
const omegaHat = f1 + f2 * Math.pow(1 - finalSpin, f3);
|
|
303
|
+
const Q = q1 + q2 * Math.pow(1 - finalSpin, q3);
|
|
304
|
+
const frequency = omegaHat / (2 * Math.PI * mfSec);
|
|
305
|
+
const dampingTime = Q / (Math.PI * frequency);
|
|
306
|
+
const [l, m, n] = modeKey.split(",").map(Number);
|
|
307
|
+
results.push({
|
|
308
|
+
l,
|
|
309
|
+
m,
|
|
310
|
+
n,
|
|
311
|
+
frequency,
|
|
312
|
+
dampingTime,
|
|
313
|
+
qualityFactor: Q,
|
|
314
|
+
label: `(${l},${m},${n})`
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
return results;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// src/core/noise-curve.ts
|
|
321
|
+
function fftInPlace(re, im) {
|
|
322
|
+
const N = re.length;
|
|
323
|
+
for (let i = 1, j = 0; i < N; i++) {
|
|
324
|
+
let bit = N >> 1;
|
|
325
|
+
while (j & bit) {
|
|
326
|
+
j ^= bit;
|
|
327
|
+
bit >>= 1;
|
|
328
|
+
}
|
|
329
|
+
j ^= bit;
|
|
330
|
+
if (i < j) {
|
|
331
|
+
[re[i], re[j]] = [re[j], re[i]];
|
|
332
|
+
[im[i], im[j]] = [im[j], im[i]];
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
for (let len = 2; len <= N; len *= 2) {
|
|
336
|
+
const halfLen = len / 2;
|
|
337
|
+
const angle = -2 * Math.PI / len;
|
|
338
|
+
const wRe = Math.cos(angle);
|
|
339
|
+
const wIm = Math.sin(angle);
|
|
340
|
+
for (let i = 0; i < N; i += len) {
|
|
341
|
+
let curRe = 1;
|
|
342
|
+
let curIm = 0;
|
|
343
|
+
for (let j = 0; j < halfLen; j++) {
|
|
344
|
+
const a = i + j;
|
|
345
|
+
const b = a + halfLen;
|
|
346
|
+
const tRe = curRe * re[b] - curIm * im[b];
|
|
347
|
+
const tIm = curRe * im[b] + curIm * re[b];
|
|
348
|
+
re[b] = re[a] - tRe;
|
|
349
|
+
im[b] = im[a] - tIm;
|
|
350
|
+
re[a] += tRe;
|
|
351
|
+
im[a] += tIm;
|
|
352
|
+
const nextRe = curRe * wRe - curIm * wIm;
|
|
353
|
+
curIm = curRe * wIm + curIm * wRe;
|
|
354
|
+
curRe = nextRe;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
function nextPow2(n) {
|
|
360
|
+
let p = 1;
|
|
361
|
+
while (p < n) p *= 2;
|
|
362
|
+
return p;
|
|
363
|
+
}
|
|
364
|
+
function computeCharacteristicStrain(waveform) {
|
|
365
|
+
const minN = 2048;
|
|
366
|
+
const N = nextPow2(Math.max(waveform.hPlus.length, minN));
|
|
367
|
+
const dt = 1 / waveform.sampleRate;
|
|
368
|
+
const re = new Float64Array(N);
|
|
369
|
+
const im = new Float64Array(N);
|
|
370
|
+
for (let i = 0; i < waveform.hPlus.length; i++) {
|
|
371
|
+
re[i] = waveform.hPlus[i];
|
|
372
|
+
}
|
|
373
|
+
fftInPlace(re, im);
|
|
374
|
+
const halfN = N / 2;
|
|
375
|
+
const df = 1 / (N * dt);
|
|
376
|
+
const frequencies = new Float64Array(halfN);
|
|
377
|
+
const hc = new Float64Array(halfN);
|
|
378
|
+
for (let k = 0; k < halfN; k++) {
|
|
379
|
+
const f = k * df;
|
|
380
|
+
frequencies[k] = f;
|
|
381
|
+
const mag = Math.sqrt(re[k] * re[k] + im[k] * im[k]) * dt;
|
|
382
|
+
hc[k] = 2 * f * mag;
|
|
383
|
+
}
|
|
384
|
+
return { frequencies, hc };
|
|
385
|
+
}
|
|
386
|
+
var ALIGO_DATA = [
|
|
387
|
+
[10, 1e-20],
|
|
388
|
+
[11, 65e-22],
|
|
389
|
+
[12, 42e-22],
|
|
390
|
+
[13, 29e-22],
|
|
391
|
+
[14, 21e-22],
|
|
392
|
+
[15, 16e-22],
|
|
393
|
+
[16, 13e-22],
|
|
394
|
+
[17, 11e-22],
|
|
395
|
+
[18, 9e-22],
|
|
396
|
+
[19, 78e-23],
|
|
397
|
+
[20, 68e-23],
|
|
398
|
+
[22, 53e-23],
|
|
399
|
+
[24, 43e-23],
|
|
400
|
+
[26, 36e-23],
|
|
401
|
+
[28, 31e-23],
|
|
402
|
+
[30, 27e-23],
|
|
403
|
+
[33, 23e-23],
|
|
404
|
+
[36, 2e-22],
|
|
405
|
+
[40, 17e-23],
|
|
406
|
+
[45, 14e-23],
|
|
407
|
+
[50, 12e-23],
|
|
408
|
+
[55, 105e-24],
|
|
409
|
+
[60, 95e-24],
|
|
410
|
+
[65, 87e-24],
|
|
411
|
+
[70, 8e-23],
|
|
412
|
+
[75, 75e-24],
|
|
413
|
+
[80, 7e-23],
|
|
414
|
+
[85, 66e-24],
|
|
415
|
+
[90, 63e-24],
|
|
416
|
+
[95, 6e-23],
|
|
417
|
+
[100, 57e-24],
|
|
418
|
+
[110, 52e-24],
|
|
419
|
+
[120, 48e-24],
|
|
420
|
+
[130, 45e-24],
|
|
421
|
+
[140, 42e-24],
|
|
422
|
+
[150, 4e-23],
|
|
423
|
+
[160, 39e-24],
|
|
424
|
+
[170, 38e-24],
|
|
425
|
+
[180, 37e-24],
|
|
426
|
+
[190, 36e-24],
|
|
427
|
+
[200, 36e-24],
|
|
428
|
+
[220, 36e-24],
|
|
429
|
+
[240, 37e-24],
|
|
430
|
+
[260, 38e-24],
|
|
431
|
+
[280, 4e-23],
|
|
432
|
+
[300, 42e-24],
|
|
433
|
+
[320, 45e-24],
|
|
434
|
+
[340, 48e-24],
|
|
435
|
+
[360, 52e-24],
|
|
436
|
+
[380, 56e-24],
|
|
437
|
+
[400, 6e-23],
|
|
438
|
+
[430, 68e-24],
|
|
439
|
+
[460, 77e-24],
|
|
440
|
+
[500, 9e-23],
|
|
441
|
+
[550, 11e-23],
|
|
442
|
+
[600, 13e-23],
|
|
443
|
+
[650, 15e-23],
|
|
444
|
+
[700, 18e-23],
|
|
445
|
+
[750, 21e-23],
|
|
446
|
+
[800, 25e-23],
|
|
447
|
+
[850, 29e-23],
|
|
448
|
+
[900, 34e-23],
|
|
449
|
+
[950, 4e-22],
|
|
450
|
+
[1e3, 46e-23],
|
|
451
|
+
[1100, 62e-23],
|
|
452
|
+
[1200, 82e-23],
|
|
453
|
+
[1300, 11e-22],
|
|
454
|
+
[1400, 14e-22],
|
|
455
|
+
[1500, 18e-22],
|
|
456
|
+
[1600, 23e-22],
|
|
457
|
+
[1700, 3e-21],
|
|
458
|
+
[1800, 38e-22],
|
|
459
|
+
[1900, 48e-22],
|
|
460
|
+
[2e3, 6e-21],
|
|
461
|
+
[2200, 95e-22],
|
|
462
|
+
[2400, 15e-21],
|
|
463
|
+
[2600, 23e-21],
|
|
464
|
+
[2800, 35e-21],
|
|
465
|
+
[3e3, 55e-21],
|
|
466
|
+
[3500, 15e-20],
|
|
467
|
+
[4e3, 45e-20],
|
|
468
|
+
[4500, 13e-19],
|
|
469
|
+
[5e3, 4e-18]
|
|
470
|
+
];
|
|
471
|
+
function interpolateALIGO_ASD(f) {
|
|
472
|
+
if (f <= ALIGO_DATA[0][0]) return ALIGO_DATA[0][1];
|
|
473
|
+
if (f >= ALIGO_DATA[ALIGO_DATA.length - 1][0]) return ALIGO_DATA[ALIGO_DATA.length - 1][1];
|
|
474
|
+
for (let i = 0; i < ALIGO_DATA.length - 1; i++) {
|
|
475
|
+
const [f0, a0] = ALIGO_DATA[i];
|
|
476
|
+
const [f1, a1] = ALIGO_DATA[i + 1];
|
|
477
|
+
if (f >= f0 && f <= f1) {
|
|
478
|
+
const t = Math.log(f / f0) / Math.log(f1 / f0);
|
|
479
|
+
return Math.exp(Math.log(a0) + t * Math.log(a1 / a0));
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
return ALIGO_DATA[ALIGO_DATA.length - 1][1];
|
|
483
|
+
}
|
|
484
|
+
function computeOptimalSNR(strain) {
|
|
485
|
+
const fMin = 10;
|
|
486
|
+
const fMax = 5e3;
|
|
487
|
+
let rhoSq = 0;
|
|
488
|
+
for (let k = 1; k < strain.frequencies.length - 1; k++) {
|
|
489
|
+
const f = strain.frequencies[k];
|
|
490
|
+
if (f < fMin || f > fMax || strain.hc[k] <= 0) continue;
|
|
491
|
+
const asd = interpolateALIGO_ASD(f);
|
|
492
|
+
const hn = Math.sqrt(f) * asd;
|
|
493
|
+
const ratio = strain.hc[k] / hn;
|
|
494
|
+
const df = strain.frequencies[k + 1] - strain.frequencies[k];
|
|
495
|
+
rhoSq += ratio * ratio * (df / f);
|
|
496
|
+
}
|
|
497
|
+
return Math.sqrt(rhoSq);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// src/core/geodesic.ts
|
|
501
|
+
var MAX_STEPS = 5e3;
|
|
502
|
+
var MAX_STEPS_MASSIVE = 2e4;
|
|
503
|
+
var MAX_DIST = 200;
|
|
504
|
+
function timelikeVeff(r, rs, L) {
|
|
505
|
+
if (r <= rs) return 0;
|
|
506
|
+
return (1 - rs / r) * (1 + L * L / (r * r));
|
|
507
|
+
}
|
|
508
|
+
function integrateGeodesic(startPos, startVel, rs, stepSize = 0.04) {
|
|
509
|
+
const points = [];
|
|
510
|
+
const vel = startVel.clone().normalize();
|
|
511
|
+
const pos = startPos.clone();
|
|
512
|
+
const Lvec = new Vec3().crossVectors(pos, vel);
|
|
513
|
+
const L2 = Lvec.lengthSq;
|
|
514
|
+
const rHorizon = rs;
|
|
515
|
+
const halfRs = 1.5 * rs;
|
|
516
|
+
points.push({ x: pos.x, y: pos.y, z: pos.z });
|
|
517
|
+
let outcome = "orbiting";
|
|
518
|
+
const tmpPos = new Vec3();
|
|
519
|
+
const k1v = new Vec3();
|
|
520
|
+
const k2v = new Vec3();
|
|
521
|
+
const k3v = new Vec3();
|
|
522
|
+
const k4v = new Vec3();
|
|
523
|
+
for (let i = 0; i < MAX_STEPS; i++) {
|
|
524
|
+
const r = pos.length;
|
|
525
|
+
if (r <= rHorizon * 1.01) {
|
|
526
|
+
outcome = "captured";
|
|
527
|
+
break;
|
|
528
|
+
}
|
|
529
|
+
if (r > MAX_DIST) {
|
|
530
|
+
outcome = "scattered";
|
|
531
|
+
break;
|
|
532
|
+
}
|
|
533
|
+
const accel = (r5, p, out) => {
|
|
534
|
+
const factor = -halfRs * L2 / r5;
|
|
535
|
+
out.copy(p).multiplyScalar(factor);
|
|
536
|
+
};
|
|
537
|
+
const dt = stepSize;
|
|
538
|
+
const r1 = pos.length;
|
|
539
|
+
const r1_5 = r1 * r1 * r1 * r1 * r1;
|
|
540
|
+
accel(r1_5, pos, k1v);
|
|
541
|
+
tmpPos.copy(pos).addScaledVector(vel, dt * 0.5);
|
|
542
|
+
const r2 = tmpPos.length;
|
|
543
|
+
const r2_5 = r2 * r2 * r2 * r2 * r2;
|
|
544
|
+
accel(r2_5, tmpPos, k2v);
|
|
545
|
+
tmpPos.copy(pos).addScaledVector(vel, dt * 0.5).addScaledVector(k1v, dt * dt * 0.25);
|
|
546
|
+
const r3 = tmpPos.length;
|
|
547
|
+
const r3_5 = r3 * r3 * r3 * r3 * r3;
|
|
548
|
+
accel(r3_5, tmpPos, k3v);
|
|
549
|
+
tmpPos.copy(pos).addScaledVector(vel, dt).addScaledVector(k2v, dt * dt * 0.5);
|
|
550
|
+
const r4 = tmpPos.length;
|
|
551
|
+
const r4_5 = r4 * r4 * r4 * r4 * r4;
|
|
552
|
+
accel(r4_5, tmpPos, k4v);
|
|
553
|
+
pos.addScaledVector(vel, dt);
|
|
554
|
+
vel.addScaledVector(k1v, dt / 6);
|
|
555
|
+
vel.addScaledVector(k2v, dt / 3);
|
|
556
|
+
vel.addScaledVector(k3v, dt / 3);
|
|
557
|
+
vel.addScaledVector(k4v, dt / 6);
|
|
558
|
+
points.push({ x: pos.x, y: pos.y, z: pos.z });
|
|
559
|
+
}
|
|
560
|
+
return { points, outcome, L: Math.sqrt(L2), particleType: "photon" };
|
|
561
|
+
}
|
|
562
|
+
function integrateTimelikeGeodesic(startPos, startVel, rs, energy, stepSize = 0.03) {
|
|
563
|
+
const points = [];
|
|
564
|
+
const pos = startPos.clone();
|
|
565
|
+
const r0 = pos.length;
|
|
566
|
+
const velDir = startVel.clone().normalize();
|
|
567
|
+
const Lvec = new Vec3().crossVectors(pos, velDir);
|
|
568
|
+
const L = Lvec.length;
|
|
569
|
+
const L2 = L * L;
|
|
570
|
+
const veff0 = timelikeVeff(r0, rs, L);
|
|
571
|
+
const drdt2 = energy - veff0;
|
|
572
|
+
const vTangential = L / r0;
|
|
573
|
+
const vRadial = Math.sqrt(Math.max(0, drdt2));
|
|
574
|
+
const rHat = pos.clone().normalize();
|
|
575
|
+
const tangentDir = new Vec3().crossVectors(Lvec.clone().normalize(), rHat);
|
|
576
|
+
const radialSign = velDir.dot(rHat) < 0 ? -1 : 1;
|
|
577
|
+
const vel = tangentDir.multiplyScalar(vTangential).addScaledVector(rHat, radialSign * vRadial);
|
|
578
|
+
const rHorizon = rs;
|
|
579
|
+
const halfRs = 0.5 * rs;
|
|
580
|
+
const threeHalfRs = 1.5 * rs;
|
|
581
|
+
points.push({ x: pos.x, y: pos.y, z: pos.z });
|
|
582
|
+
let outcome = "orbiting";
|
|
583
|
+
let prevR = r0;
|
|
584
|
+
let radialTurns = 0;
|
|
585
|
+
let increasing = vel.dot(pos.clone().normalize()) > 0;
|
|
586
|
+
const tmpPos = new Vec3();
|
|
587
|
+
const k1v = new Vec3();
|
|
588
|
+
const k2v = new Vec3();
|
|
589
|
+
const k3v = new Vec3();
|
|
590
|
+
const k4v = new Vec3();
|
|
591
|
+
const accel = (p, out) => {
|
|
592
|
+
const r = p.length;
|
|
593
|
+
const r3 = r * r * r;
|
|
594
|
+
const r5 = r3 * r * r;
|
|
595
|
+
const factor = -halfRs / r3 - threeHalfRs * L2 / r5;
|
|
596
|
+
out.copy(p).multiplyScalar(factor);
|
|
597
|
+
};
|
|
598
|
+
for (let i = 0; i < MAX_STEPS_MASSIVE; i++) {
|
|
599
|
+
const r = pos.length;
|
|
600
|
+
if (r <= rHorizon * 1.01) {
|
|
601
|
+
outcome = "captured";
|
|
602
|
+
break;
|
|
603
|
+
}
|
|
604
|
+
if (r > MAX_DIST) {
|
|
605
|
+
outcome = "scattered";
|
|
606
|
+
break;
|
|
607
|
+
}
|
|
608
|
+
const nowIncreasing = r > prevR;
|
|
609
|
+
if (i > 5 && nowIncreasing !== increasing) {
|
|
610
|
+
radialTurns++;
|
|
611
|
+
increasing = nowIncreasing;
|
|
612
|
+
if (radialTurns >= 6) {
|
|
613
|
+
outcome = "bound";
|
|
614
|
+
break;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
prevR = r;
|
|
618
|
+
const dt = stepSize;
|
|
619
|
+
accel(pos, k1v);
|
|
620
|
+
tmpPos.copy(pos).addScaledVector(vel, dt * 0.5);
|
|
621
|
+
accel(tmpPos, k2v);
|
|
622
|
+
tmpPos.copy(pos).addScaledVector(vel, dt * 0.5).addScaledVector(k1v, dt * dt * 0.25);
|
|
623
|
+
accel(tmpPos, k3v);
|
|
624
|
+
tmpPos.copy(pos).addScaledVector(vel, dt).addScaledVector(k2v, dt * dt * 0.5);
|
|
625
|
+
accel(tmpPos, k4v);
|
|
626
|
+
pos.addScaledVector(vel, dt);
|
|
627
|
+
vel.addScaledVector(k1v, dt / 6);
|
|
628
|
+
vel.addScaledVector(k2v, dt / 3);
|
|
629
|
+
vel.addScaledVector(k3v, dt / 3);
|
|
630
|
+
vel.addScaledVector(k4v, dt / 6);
|
|
631
|
+
points.push({ x: pos.x, y: pos.y, z: pos.z });
|
|
632
|
+
}
|
|
633
|
+
return { points, outcome, L, particleType: "particle" };
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// src/core/multi-messenger.ts
|
|
637
|
+
var MM_DATA = {
|
|
638
|
+
GW170817: {
|
|
639
|
+
eventName: "GW170817",
|
|
640
|
+
emCounterpart: "GRB 170817A / AT 2017gfo (kilonova)",
|
|
641
|
+
emCounterparts: [
|
|
642
|
+
{
|
|
643
|
+
name: "GRB 170817A",
|
|
644
|
+
type: "GRB",
|
|
645
|
+
delaySeconds: 1.7,
|
|
646
|
+
observatory: "Fermi GBM / INTEGRAL SPI-ACS",
|
|
647
|
+
description: "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."
|
|
648
|
+
},
|
|
649
|
+
{
|
|
650
|
+
name: "AT 2017gfo",
|
|
651
|
+
type: "kilonova",
|
|
652
|
+
delaySeconds: 11 * 3600,
|
|
653
|
+
observatory: "Swope Telescope (Las Campanas)",
|
|
654
|
+
description: "The first kilonova observed with a known gravitational-wave source. Its rapid reddening revealed freshly synthesized heavy elements forged by the r-process."
|
|
655
|
+
},
|
|
656
|
+
{
|
|
657
|
+
name: "CXO J130948.0\u2013233120",
|
|
658
|
+
type: "X-ray",
|
|
659
|
+
delaySeconds: 9 * 86400,
|
|
660
|
+
observatory: "Chandra X-ray Observatory",
|
|
661
|
+
description: "X-ray emission appeared days after the merger, produced by the interaction of the relativistic jet with the surrounding interstellar medium."
|
|
662
|
+
},
|
|
663
|
+
{
|
|
664
|
+
name: "VLA J130948.0\u2013233120",
|
|
665
|
+
type: "radio",
|
|
666
|
+
delaySeconds: 16 * 86400,
|
|
667
|
+
observatory: "Karl G. Jansky VLA",
|
|
668
|
+
description: "Radio afterglow from the expanding cocoon of material around the jet, confirming the merger launched a structured relativistic outflow."
|
|
669
|
+
}
|
|
670
|
+
],
|
|
671
|
+
hostGalaxy: "NGC 4993, 40 Mpc",
|
|
672
|
+
hostGalaxyDistanceMpc: 40,
|
|
673
|
+
h0Measurement: "70 +12/\u22128 km/s/Mpc",
|
|
674
|
+
ejectaMass: "~0.05 M\u2609",
|
|
675
|
+
grbDelay: "1.7 s"
|
|
676
|
+
}
|
|
677
|
+
};
|
|
678
|
+
function getMultiMessengerData(commonName) {
|
|
679
|
+
return MM_DATA[commonName] ?? null;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// src/core/export.ts
|
|
683
|
+
var CATALOG_PAPERS = {
|
|
684
|
+
"GWTC-1": {
|
|
685
|
+
key: "LIGOScientific:2018mvr",
|
|
686
|
+
entry: `@article{LIGOScientific:2018mvr,
|
|
687
|
+
author = "{LIGO Scientific Collaboration and Virgo Collaboration}",
|
|
688
|
+
title = "{GWTC-1: A Gravitational-Wave Transient Catalog of Compact Binary Mergers Observed by LIGO and Virgo during the First and Second Observing Runs}",
|
|
689
|
+
journal = "Phys. Rev. X",
|
|
690
|
+
volume = "9",
|
|
691
|
+
pages = "031040",
|
|
692
|
+
year = "2019",
|
|
693
|
+
doi = "10.1103/PhysRevX.9.031040",
|
|
694
|
+
eprint = "1811.12907",
|
|
695
|
+
archivePrefix = "arXiv"
|
|
696
|
+
}`
|
|
697
|
+
},
|
|
698
|
+
"GWTC-2": {
|
|
699
|
+
key: "LIGOScientific:2020ibl",
|
|
700
|
+
entry: `@article{LIGOScientific:2020ibl,
|
|
701
|
+
author = "{LIGO Scientific Collaboration and Virgo Collaboration}",
|
|
702
|
+
title = "{GWTC-2: Compact Binary Coalescences Observed by LIGO and Virgo During the First Half of the Third Observing Run}",
|
|
703
|
+
journal = "Phys. Rev. X",
|
|
704
|
+
volume = "11",
|
|
705
|
+
pages = "021053",
|
|
706
|
+
year = "2021",
|
|
707
|
+
doi = "10.1103/PhysRevX.11.021053",
|
|
708
|
+
eprint = "2010.14527",
|
|
709
|
+
archivePrefix = "arXiv"
|
|
710
|
+
}`
|
|
711
|
+
},
|
|
712
|
+
"GWTC-2.1": {
|
|
713
|
+
key: "LIGOScientific:2021usb",
|
|
714
|
+
entry: `@article{LIGOScientific:2021usb,
|
|
715
|
+
author = "{LIGO Scientific Collaboration and Virgo Collaboration}",
|
|
716
|
+
title = "{GWTC-2.1: Deep Extended Catalog of Compact Binary Coalescences Observed by LIGO and Virgo During the First Half of the Third Observing Run}",
|
|
717
|
+
journal = "Phys. Rev. D",
|
|
718
|
+
volume = "109",
|
|
719
|
+
pages = "022001",
|
|
720
|
+
year = "2024",
|
|
721
|
+
doi = "10.1103/PhysRevD.109.022001",
|
|
722
|
+
eprint = "2108.01045",
|
|
723
|
+
archivePrefix = "arXiv"
|
|
724
|
+
}`
|
|
725
|
+
},
|
|
726
|
+
"GWTC-3": {
|
|
727
|
+
key: "LIGOScientific:2021djp",
|
|
728
|
+
entry: `@article{LIGOScientific:2021djp,
|
|
729
|
+
author = "{LIGO Scientific Collaboration and Virgo Collaboration and KAGRA Collaboration}",
|
|
730
|
+
title = "{GWTC-3: Compact Binary Coalescences Observed by LIGO and Virgo During the Second Part of the Third Observing Run}",
|
|
731
|
+
journal = "Phys. Rev. X",
|
|
732
|
+
volume = "13",
|
|
733
|
+
pages = "041039",
|
|
734
|
+
year = "2023",
|
|
735
|
+
doi = "10.1103/PhysRevX.13.041039",
|
|
736
|
+
eprint = "2111.03606",
|
|
737
|
+
archivePrefix = "arXiv"
|
|
738
|
+
}`
|
|
739
|
+
}
|
|
740
|
+
};
|
|
741
|
+
function generateParametersJSON(event) {
|
|
742
|
+
const type = classifyEvent(event);
|
|
743
|
+
const energyRadiated = event.total_mass_source - event.final_mass_source;
|
|
744
|
+
const params = {
|
|
745
|
+
event: event.commonName,
|
|
746
|
+
catalog: event.catalog_shortName,
|
|
747
|
+
type,
|
|
748
|
+
gps_time: event.GPS,
|
|
749
|
+
mass_1_source: {
|
|
750
|
+
value: event.mass_1_source,
|
|
751
|
+
lower: event.mass_1_source_lower,
|
|
752
|
+
upper: event.mass_1_source_upper,
|
|
753
|
+
unit: "M_sun"
|
|
754
|
+
},
|
|
755
|
+
mass_2_source: {
|
|
756
|
+
value: event.mass_2_source,
|
|
757
|
+
lower: event.mass_2_source_lower,
|
|
758
|
+
upper: event.mass_2_source_upper,
|
|
759
|
+
unit: "M_sun"
|
|
760
|
+
},
|
|
761
|
+
total_mass_source: { value: event.total_mass_source, unit: "M_sun" },
|
|
762
|
+
chirp_mass_source: {
|
|
763
|
+
value: event.chirp_mass_source,
|
|
764
|
+
lower: event.chirp_mass_source_lower,
|
|
765
|
+
upper: event.chirp_mass_source_upper,
|
|
766
|
+
unit: "M_sun"
|
|
767
|
+
},
|
|
768
|
+
final_mass_source: {
|
|
769
|
+
value: event.final_mass_source,
|
|
770
|
+
lower: event.final_mass_source_lower,
|
|
771
|
+
upper: event.final_mass_source_upper,
|
|
772
|
+
unit: "M_sun"
|
|
773
|
+
},
|
|
774
|
+
energy_radiated: { value: energyRadiated, unit: "M_sun_c2" },
|
|
775
|
+
luminosity_distance: {
|
|
776
|
+
value: event.luminosity_distance,
|
|
777
|
+
lower: event.luminosity_distance_lower,
|
|
778
|
+
upper: event.luminosity_distance_upper,
|
|
779
|
+
unit: "Mpc"
|
|
780
|
+
},
|
|
781
|
+
redshift: event.redshift,
|
|
782
|
+
chi_eff: event.chi_eff,
|
|
783
|
+
network_snr: event.network_matched_filter_snr,
|
|
784
|
+
false_alarm_rate: event.far,
|
|
785
|
+
p_astro: event.p_astro,
|
|
786
|
+
source: "GWOSC (https://gwosc.org)",
|
|
787
|
+
exported_by: "WarpLab (https://warplab.app)"
|
|
788
|
+
};
|
|
789
|
+
return JSON.stringify(params, null, 2);
|
|
790
|
+
}
|
|
791
|
+
function generateParametersCSV(event) {
|
|
792
|
+
const type = classifyEvent(event);
|
|
793
|
+
const energyRadiated = event.total_mass_source - event.final_mass_source;
|
|
794
|
+
const headers = ["parameter", "value", "lower_90", "upper_90", "unit"];
|
|
795
|
+
const rows = [
|
|
796
|
+
["event_name", event.commonName, "", "", ""],
|
|
797
|
+
["catalog", event.catalog_shortName, "", "", ""],
|
|
798
|
+
["type", type, "", "", ""],
|
|
799
|
+
["gps_time", event.GPS, "", "", "s"],
|
|
800
|
+
["mass_1_source", event.mass_1_source, event.mass_1_source_lower, event.mass_1_source_upper, "M_sun"],
|
|
801
|
+
["mass_2_source", event.mass_2_source, event.mass_2_source_lower, event.mass_2_source_upper, "M_sun"],
|
|
802
|
+
["total_mass_source", event.total_mass_source, "", "", "M_sun"],
|
|
803
|
+
["chirp_mass_source", event.chirp_mass_source, event.chirp_mass_source_lower, event.chirp_mass_source_upper, "M_sun"],
|
|
804
|
+
["final_mass_source", event.final_mass_source, event.final_mass_source_lower, event.final_mass_source_upper, "M_sun"],
|
|
805
|
+
["energy_radiated", energyRadiated, "", "", "M_sun_c2"],
|
|
806
|
+
["luminosity_distance", event.luminosity_distance, event.luminosity_distance_lower, event.luminosity_distance_upper, "Mpc"],
|
|
807
|
+
["redshift", event.redshift, "", "", ""],
|
|
808
|
+
["chi_eff", event.chi_eff, "", "", ""],
|
|
809
|
+
["network_snr", event.network_matched_filter_snr, "", "", ""],
|
|
810
|
+
["false_alarm_rate", event.far, "", "", "Hz"],
|
|
811
|
+
["p_astro", event.p_astro, "", "", ""]
|
|
812
|
+
];
|
|
813
|
+
return [headers.join(","), ...rows.map((r) => r.join(","))].join("\n");
|
|
814
|
+
}
|
|
815
|
+
function generateWaveformCSV(waveform) {
|
|
816
|
+
const headers = ["time_s", "h_plus", "h_cross"];
|
|
817
|
+
const lines = [headers.join(",")];
|
|
818
|
+
const dt = 1 / waveform.sampleRate;
|
|
819
|
+
for (let i = 0; i < waveform.hPlus.length; i++) {
|
|
820
|
+
const t = (i * dt).toFixed(6);
|
|
821
|
+
lines.push(`${t},${waveform.hPlus[i].toExponential(8)},${waveform.hCross[i].toExponential(8)}`);
|
|
822
|
+
}
|
|
823
|
+
return lines.join("\n");
|
|
824
|
+
}
|
|
825
|
+
function generateBibTeX(event) {
|
|
826
|
+
const entries = [];
|
|
827
|
+
entries.push(`@misc{GWOSC,
|
|
828
|
+
author = "{LIGO Scientific Collaboration and Virgo Collaboration and KAGRA Collaboration}",
|
|
829
|
+
title = "{Gravitational Wave Open Science Center}",
|
|
830
|
+
howpublished = "\\url{https://gwosc.org}",
|
|
831
|
+
year = "2023",
|
|
832
|
+
note = "Event: ${event.commonName}"
|
|
833
|
+
}`);
|
|
834
|
+
const catalog = event.catalog_shortName;
|
|
835
|
+
const paper = CATALOG_PAPERS[catalog];
|
|
836
|
+
if (paper) {
|
|
837
|
+
entries.push(paper.entry);
|
|
838
|
+
}
|
|
839
|
+
entries.push(`@misc{WarpLab,
|
|
840
|
+
author = "{Canton, Daniel}",
|
|
841
|
+
title = "{WarpLab: Interactive Gravitational Wave Visualizer}",
|
|
842
|
+
howpublished = "\\url{https://warplab.app}",
|
|
843
|
+
year = "2025"
|
|
844
|
+
}`);
|
|
845
|
+
return entries.join("\n\n");
|
|
846
|
+
}
|
|
847
|
+
function generateNotebook(event) {
|
|
848
|
+
const type = classifyEvent(event);
|
|
849
|
+
const gps = event.GPS;
|
|
850
|
+
const eventName = event.commonName;
|
|
851
|
+
const notebook = {
|
|
852
|
+
nbformat: 4,
|
|
853
|
+
nbformat_minor: 5,
|
|
854
|
+
metadata: {
|
|
855
|
+
kernelspec: {
|
|
856
|
+
display_name: "Python 3",
|
|
857
|
+
language: "python",
|
|
858
|
+
name: "python3"
|
|
859
|
+
},
|
|
860
|
+
language_info: {
|
|
861
|
+
name: "python",
|
|
862
|
+
version: "3.10.0"
|
|
863
|
+
}
|
|
864
|
+
},
|
|
865
|
+
cells: [
|
|
866
|
+
{
|
|
867
|
+
cell_type: "markdown",
|
|
868
|
+
metadata: {},
|
|
869
|
+
source: [
|
|
870
|
+
`# ${eventName} \u2014 Gravitational Wave Analysis
|
|
871
|
+
`,
|
|
872
|
+
`
|
|
873
|
+
`,
|
|
874
|
+
`**Type:** ${type}
|
|
875
|
+
`,
|
|
876
|
+
`**Masses:** ${event.mass_1_source.toFixed(1)} + ${event.mass_2_source.toFixed(1)} M\u2609
|
|
877
|
+
`,
|
|
878
|
+
`**Distance:** ${event.luminosity_distance.toFixed(0)} Mpc
|
|
879
|
+
`,
|
|
880
|
+
`**Catalog:** ${event.catalog_shortName}
|
|
881
|
+
`,
|
|
882
|
+
`**GPS Time:** ${gps}
|
|
883
|
+
`,
|
|
884
|
+
`
|
|
885
|
+
`,
|
|
886
|
+
`This notebook fetches real detector strain from [GWOSC](https://gwosc.org) and reproduces the spectrogram and template overlay.
|
|
887
|
+
`,
|
|
888
|
+
`
|
|
889
|
+
`,
|
|
890
|
+
`*Exported from [WarpLab](https://warplab.app)*`
|
|
891
|
+
]
|
|
892
|
+
},
|
|
893
|
+
{
|
|
894
|
+
cell_type: "markdown",
|
|
895
|
+
metadata: {},
|
|
896
|
+
source: ["## 1. Setup\n", "Install required packages if needed."]
|
|
897
|
+
},
|
|
898
|
+
{
|
|
899
|
+
cell_type: "code",
|
|
900
|
+
metadata: {},
|
|
901
|
+
source: [
|
|
902
|
+
`# Install dependencies (uncomment if needed)
|
|
903
|
+
`,
|
|
904
|
+
`# !pip install gwosc gwpy matplotlib numpy
|
|
905
|
+
`,
|
|
906
|
+
`
|
|
907
|
+
`,
|
|
908
|
+
`import numpy as np
|
|
909
|
+
`,
|
|
910
|
+
`import matplotlib.pyplot as plt
|
|
911
|
+
`,
|
|
912
|
+
`from gwpy.timeseries import TimeSeries
|
|
913
|
+
`,
|
|
914
|
+
`from gwosc.datasets import event_gps
|
|
915
|
+
`,
|
|
916
|
+
`
|
|
917
|
+
`,
|
|
918
|
+
`EVENT = "${eventName}"
|
|
919
|
+
`,
|
|
920
|
+
`GPS = ${gps}
|
|
921
|
+
`,
|
|
922
|
+
`DETECTOR = "H1" # Change to "L1" or "V1" for other detectors`
|
|
923
|
+
],
|
|
924
|
+
execution_count: null,
|
|
925
|
+
outputs: []
|
|
926
|
+
},
|
|
927
|
+
{
|
|
928
|
+
cell_type: "markdown",
|
|
929
|
+
metadata: {},
|
|
930
|
+
source: [
|
|
931
|
+
"## 2. Fetch strain data from GWOSC\n",
|
|
932
|
+
"Download 32 seconds of strain centered on the event."
|
|
933
|
+
]
|
|
934
|
+
},
|
|
935
|
+
{
|
|
936
|
+
cell_type: "code",
|
|
937
|
+
metadata: {},
|
|
938
|
+
source: [
|
|
939
|
+
`# Fetch 32s of strain data centered on the event
|
|
940
|
+
`,
|
|
941
|
+
`strain = TimeSeries.fetch_open_data(
|
|
942
|
+
`,
|
|
943
|
+
` DETECTOR, GPS - 16, GPS + 16,
|
|
944
|
+
`,
|
|
945
|
+
` cache=True
|
|
946
|
+
`,
|
|
947
|
+
`)
|
|
948
|
+
`,
|
|
949
|
+
`print(f"Sample rate: {strain.sample_rate}")
|
|
950
|
+
`,
|
|
951
|
+
`print(f"Duration: {strain.duration}")`
|
|
952
|
+
],
|
|
953
|
+
execution_count: null,
|
|
954
|
+
outputs: []
|
|
955
|
+
},
|
|
956
|
+
{
|
|
957
|
+
cell_type: "markdown",
|
|
958
|
+
metadata: {},
|
|
959
|
+
source: [
|
|
960
|
+
"## 3. Q-transform spectrogram\n",
|
|
961
|
+
"Compute and plot the time-frequency spectrogram using a Q-transform."
|
|
962
|
+
]
|
|
963
|
+
},
|
|
964
|
+
{
|
|
965
|
+
cell_type: "code",
|
|
966
|
+
metadata: {},
|
|
967
|
+
source: [
|
|
968
|
+
`# Compute Q-transform spectrogram
|
|
969
|
+
`,
|
|
970
|
+
`dt = 1 # seconds around merger to plot
|
|
971
|
+
`,
|
|
972
|
+
`qgram = strain.q_transform(
|
|
973
|
+
`,
|
|
974
|
+
` outseg=(GPS - dt, GPS + dt),
|
|
975
|
+
`,
|
|
976
|
+
` qrange=(4, 64),
|
|
977
|
+
`,
|
|
978
|
+
` frange=(20, 1024),
|
|
979
|
+
`,
|
|
980
|
+
` logf=True
|
|
981
|
+
`,
|
|
982
|
+
`)
|
|
983
|
+
`,
|
|
984
|
+
`
|
|
985
|
+
`,
|
|
986
|
+
`fig, ax = plt.subplots(figsize=(10, 5))
|
|
987
|
+
`,
|
|
988
|
+
`ax.imshow(qgram.T, origin="lower", aspect="auto",
|
|
989
|
+
`,
|
|
990
|
+
` extent=[qgram.x0.value, (qgram.x0 + qgram.dx * qgram.shape[0]).value,
|
|
991
|
+
`,
|
|
992
|
+
` qgram.y0.value, (qgram.y0 + qgram.dy * qgram.shape[1]).value])
|
|
993
|
+
`,
|
|
994
|
+
`ax.set_xlabel("Time [s]")
|
|
995
|
+
`,
|
|
996
|
+
`ax.set_ylabel("Frequency [Hz]")
|
|
997
|
+
`,
|
|
998
|
+
`ax.set_title(f"{EVENT} \u2014 Q-transform ({DETECTOR})")
|
|
999
|
+
`,
|
|
1000
|
+
`ax.set_yscale("log")
|
|
1001
|
+
`,
|
|
1002
|
+
`plt.colorbar(ax.images[0], label="Normalized energy")
|
|
1003
|
+
`,
|
|
1004
|
+
`plt.tight_layout()
|
|
1005
|
+
`,
|
|
1006
|
+
`plt.show()`
|
|
1007
|
+
],
|
|
1008
|
+
execution_count: null,
|
|
1009
|
+
outputs: []
|
|
1010
|
+
},
|
|
1011
|
+
{
|
|
1012
|
+
cell_type: "markdown",
|
|
1013
|
+
metadata: {},
|
|
1014
|
+
source: [
|
|
1015
|
+
"## 4. Whitened strain and template overlay\n",
|
|
1016
|
+
"Bandpass and whiten the data, then overlay the included waveform template."
|
|
1017
|
+
]
|
|
1018
|
+
},
|
|
1019
|
+
{
|
|
1020
|
+
cell_type: "code",
|
|
1021
|
+
metadata: {},
|
|
1022
|
+
source: [
|
|
1023
|
+
`# Bandpass filter and whiten
|
|
1024
|
+
`,
|
|
1025
|
+
`white = strain.whiten(4, 2).bandpass(30, 400)
|
|
1026
|
+
`,
|
|
1027
|
+
`
|
|
1028
|
+
`,
|
|
1029
|
+
`# Load the template waveform from the exported CSV
|
|
1030
|
+
`,
|
|
1031
|
+
`import os
|
|
1032
|
+
`,
|
|
1033
|
+
`template_file = os.path.join(
|
|
1034
|
+
`,
|
|
1035
|
+
` os.path.dirname(os.path.abspath("__file__")),
|
|
1036
|
+
`,
|
|
1037
|
+
` "waveform_template.csv"
|
|
1038
|
+
`,
|
|
1039
|
+
`)
|
|
1040
|
+
`,
|
|
1041
|
+
`
|
|
1042
|
+
`,
|
|
1043
|
+
`fig, ax = plt.subplots(figsize=(10, 4))
|
|
1044
|
+
`,
|
|
1045
|
+
`
|
|
1046
|
+
`,
|
|
1047
|
+
`# Plot whitened strain around merger
|
|
1048
|
+
`,
|
|
1049
|
+
`t = white.times.value - GPS
|
|
1050
|
+
`,
|
|
1051
|
+
`mask = (t > -0.5) & (t < 0.2)
|
|
1052
|
+
`,
|
|
1053
|
+
`ax.plot(t[mask], white.value[mask], label=f"{DETECTOR} whitened", alpha=0.8)
|
|
1054
|
+
`,
|
|
1055
|
+
`
|
|
1056
|
+
`,
|
|
1057
|
+
`# Overlay template if available
|
|
1058
|
+
`,
|
|
1059
|
+
`if os.path.exists(template_file):
|
|
1060
|
+
`,
|
|
1061
|
+
` template = np.genfromtxt(template_file, delimiter=",",
|
|
1062
|
+
`,
|
|
1063
|
+
` names=True, dtype=None, encoding="utf-8")
|
|
1064
|
+
`,
|
|
1065
|
+
` t_templ = template["time_s"]
|
|
1066
|
+
`,
|
|
1067
|
+
` h_templ = template["h_plus"]
|
|
1068
|
+
`,
|
|
1069
|
+
` # Center template on t=0 at peak
|
|
1070
|
+
`,
|
|
1071
|
+
` peak_idx = np.argmax(np.abs(h_templ))
|
|
1072
|
+
`,
|
|
1073
|
+
` t_templ = t_templ - t_templ[peak_idx]
|
|
1074
|
+
`,
|
|
1075
|
+
` # Scale template to match whitened strain amplitude
|
|
1076
|
+
`,
|
|
1077
|
+
` scale = np.max(np.abs(white.value[mask])) / np.max(np.abs(h_templ))
|
|
1078
|
+
`,
|
|
1079
|
+
` ax.plot(t_templ, h_templ * scale, "--", label="Template (h+)",
|
|
1080
|
+
`,
|
|
1081
|
+
` alpha=0.7, color="tab:orange")
|
|
1082
|
+
`,
|
|
1083
|
+
`
|
|
1084
|
+
`,
|
|
1085
|
+
`ax.set_xlabel("Time relative to merger [s]")
|
|
1086
|
+
`,
|
|
1087
|
+
`ax.set_ylabel("Strain (whitened)")
|
|
1088
|
+
`,
|
|
1089
|
+
`ax.set_title(f"{EVENT} \u2014 Whitened strain with template overlay")
|
|
1090
|
+
`,
|
|
1091
|
+
`ax.legend()
|
|
1092
|
+
`,
|
|
1093
|
+
`plt.tight_layout()
|
|
1094
|
+
`,
|
|
1095
|
+
`plt.show()`
|
|
1096
|
+
],
|
|
1097
|
+
execution_count: null,
|
|
1098
|
+
outputs: []
|
|
1099
|
+
},
|
|
1100
|
+
{
|
|
1101
|
+
cell_type: "markdown",
|
|
1102
|
+
metadata: {},
|
|
1103
|
+
source: [
|
|
1104
|
+
"## 5. Event parameters\n",
|
|
1105
|
+
"Summary of parameters from the GWTC catalog."
|
|
1106
|
+
]
|
|
1107
|
+
},
|
|
1108
|
+
{
|
|
1109
|
+
cell_type: "code",
|
|
1110
|
+
metadata: {},
|
|
1111
|
+
source: [
|
|
1112
|
+
`# Event parameters from ${event.catalog_shortName}
|
|
1113
|
+
`,
|
|
1114
|
+
`params = {
|
|
1115
|
+
`,
|
|
1116
|
+
` "Event": "${eventName}",
|
|
1117
|
+
`,
|
|
1118
|
+
` "Type": "${type}",
|
|
1119
|
+
`,
|
|
1120
|
+
` "m1 [M\u2609]": ${event.mass_1_source.toFixed(2)},
|
|
1121
|
+
`,
|
|
1122
|
+
` "m2 [M\u2609]": ${event.mass_2_source.toFixed(2)},
|
|
1123
|
+
`,
|
|
1124
|
+
` "M_total [M\u2609]": ${event.total_mass_source.toFixed(2)},
|
|
1125
|
+
`,
|
|
1126
|
+
` "M_chirp [M\u2609]": ${event.chirp_mass_source.toFixed(2)},
|
|
1127
|
+
`,
|
|
1128
|
+
` "M_final [M\u2609]": ${event.final_mass_source.toFixed(2)},
|
|
1129
|
+
`,
|
|
1130
|
+
` "Distance [Mpc]": ${event.luminosity_distance.toFixed(1)},
|
|
1131
|
+
`,
|
|
1132
|
+
` "Redshift": ${event.redshift.toFixed(4)},
|
|
1133
|
+
`,
|
|
1134
|
+
` "\u03C7_eff": ${event.chi_eff.toFixed(3)},
|
|
1135
|
+
`,
|
|
1136
|
+
` "SNR": ${event.network_matched_filter_snr.toFixed(1)},
|
|
1137
|
+
`,
|
|
1138
|
+
` "p_astro": ${event.p_astro.toFixed(4)},
|
|
1139
|
+
`,
|
|
1140
|
+
`}
|
|
1141
|
+
`,
|
|
1142
|
+
`
|
|
1143
|
+
`,
|
|
1144
|
+
`for k, v in params.items():
|
|
1145
|
+
`,
|
|
1146
|
+
` print(f"{k:20s} {v}")`
|
|
1147
|
+
],
|
|
1148
|
+
execution_count: null,
|
|
1149
|
+
outputs: []
|
|
1150
|
+
}
|
|
1151
|
+
]
|
|
1152
|
+
};
|
|
1153
|
+
return JSON.stringify(notebook, null, 1);
|
|
1154
|
+
}
|
|
1155
|
+
function generateREADME(event) {
|
|
1156
|
+
const type = classifyEvent(event);
|
|
1157
|
+
return `# ${event.commonName} \u2014 Data Export
|
|
1158
|
+
|
|
1159
|
+
Type: ${type}
|
|
1160
|
+
Masses: ${event.mass_1_source.toFixed(1)} + ${event.mass_2_source.toFixed(1)} M\u2609
|
|
1161
|
+
Distance: ${event.luminosity_distance.toFixed(0)} Mpc
|
|
1162
|
+
Catalog: ${event.catalog_shortName}
|
|
1163
|
+
|
|
1164
|
+
## Files
|
|
1165
|
+
|
|
1166
|
+
- **parameters.json** \u2014 Full event parameters with uncertainties (JSON)
|
|
1167
|
+
- **parameters.csv** \u2014 Same parameters in tabular CSV format
|
|
1168
|
+
- **waveform_template.csv** \u2014 Synthetic IMRPhenom waveform: h+(t) and h\xD7(t) arrays
|
|
1169
|
+
- **notebook.ipynb** \u2014 Jupyter notebook that fetches real strain from GWOSC and reproduces the analysis
|
|
1170
|
+
- **CITATION.bib** \u2014 BibTeX citations for GWOSC, the catalog paper, and WarpLab
|
|
1171
|
+
|
|
1172
|
+
## Using the notebook
|
|
1173
|
+
|
|
1174
|
+
1. Install dependencies: \`pip install gwosc gwpy matplotlib numpy\`
|
|
1175
|
+
2. Open \`notebook.ipynb\` in JupyterLab or VS Code
|
|
1176
|
+
3. Run all cells \u2014 it will download real detector strain from GWOSC
|
|
1177
|
+
4. The notebook produces a Q-transform spectrogram and a whitened strain plot with the template overlay
|
|
1178
|
+
|
|
1179
|
+
## Data source
|
|
1180
|
+
|
|
1181
|
+
All parameters are from the Gravitational Wave Open Science Center (GWOSC):
|
|
1182
|
+
https://gwosc.org
|
|
1183
|
+
|
|
1184
|
+
The waveform template is a simplified IMRPhenom analytical approximation generated by WarpLab.
|
|
1185
|
+
It is NOT a full numerical relativity waveform. For research use, fetch real strain from GWOSC.
|
|
1186
|
+
|
|
1187
|
+
## Citation
|
|
1188
|
+
|
|
1189
|
+
If you use this data in academic work, please cite the sources in CITATION.bib.
|
|
1190
|
+
|
|
1191
|
+
---
|
|
1192
|
+
Exported from WarpLab (https://warplab.app)
|
|
1193
|
+
`;
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
// server/mcp.ts
|
|
1197
|
+
var server = new McpServer({
|
|
1198
|
+
name: "warplab",
|
|
1199
|
+
version: "1.0.0"
|
|
1200
|
+
});
|
|
1201
|
+
var catalogCache = null;
|
|
1202
|
+
async function getCatalog() {
|
|
1203
|
+
if (!catalogCache) catalogCache = await fetchEventCatalog();
|
|
1204
|
+
return catalogCache;
|
|
1205
|
+
}
|
|
1206
|
+
function findEvent(catalog, name) {
|
|
1207
|
+
const lower = name.toLowerCase();
|
|
1208
|
+
return catalog.find((e) => e.commonName.toLowerCase() === lower);
|
|
1209
|
+
}
|
|
1210
|
+
server.tool(
|
|
1211
|
+
"search_events",
|
|
1212
|
+
"Search and filter the gravitational wave event catalog from GWOSC",
|
|
1213
|
+
{
|
|
1214
|
+
type: z.enum(["BBH", "BNS", "NSBH"]).optional().describe("Filter by event type"),
|
|
1215
|
+
mass_min: z.number().optional().describe("Minimum total mass in solar masses"),
|
|
1216
|
+
mass_max: z.number().optional().describe("Maximum total mass in solar masses"),
|
|
1217
|
+
distance_max: z.number().optional().describe("Maximum luminosity distance in Mpc"),
|
|
1218
|
+
snr_min: z.number().optional().describe("Minimum network SNR"),
|
|
1219
|
+
catalog: z.string().optional().describe("Filter by catalog (e.g. GWTC-3-confident)"),
|
|
1220
|
+
limit: z.number().optional().default(20).describe("Max results (default 20)")
|
|
1221
|
+
},
|
|
1222
|
+
async (params) => {
|
|
1223
|
+
const catalog = await getCatalog();
|
|
1224
|
+
let results = catalog;
|
|
1225
|
+
if (params.type) {
|
|
1226
|
+
results = results.filter((e) => classifyEvent(e) === params.type);
|
|
1227
|
+
}
|
|
1228
|
+
if (params.mass_min != null) {
|
|
1229
|
+
results = results.filter((e) => e.total_mass_source >= params.mass_min);
|
|
1230
|
+
}
|
|
1231
|
+
if (params.mass_max != null) {
|
|
1232
|
+
results = results.filter((e) => e.total_mass_source <= params.mass_max);
|
|
1233
|
+
}
|
|
1234
|
+
if (params.distance_max != null) {
|
|
1235
|
+
results = results.filter((e) => e.luminosity_distance <= params.distance_max);
|
|
1236
|
+
}
|
|
1237
|
+
if (params.snr_min != null) {
|
|
1238
|
+
results = results.filter((e) => e.network_matched_filter_snr >= params.snr_min);
|
|
1239
|
+
}
|
|
1240
|
+
if (params.catalog) {
|
|
1241
|
+
const cat = params.catalog.toLowerCase();
|
|
1242
|
+
results = results.filter((e) => e.catalog_shortName.toLowerCase().includes(cat));
|
|
1243
|
+
}
|
|
1244
|
+
const limited = results.slice(0, params.limit);
|
|
1245
|
+
const summary = limited.map((e) => ({
|
|
1246
|
+
name: e.commonName,
|
|
1247
|
+
type: classifyEvent(e),
|
|
1248
|
+
m1: e.mass_1_source,
|
|
1249
|
+
m2: e.mass_2_source,
|
|
1250
|
+
total_mass: e.total_mass_source,
|
|
1251
|
+
distance_Mpc: e.luminosity_distance,
|
|
1252
|
+
snr: e.network_matched_filter_snr,
|
|
1253
|
+
catalog: e.catalog_shortName
|
|
1254
|
+
}));
|
|
1255
|
+
return {
|
|
1256
|
+
content: [{
|
|
1257
|
+
type: "text",
|
|
1258
|
+
text: `Found ${results.length} events (showing ${limited.length}):
|
|
1259
|
+
|
|
1260
|
+
${JSON.stringify(summary, null, 2)}`
|
|
1261
|
+
}]
|
|
1262
|
+
};
|
|
1263
|
+
}
|
|
1264
|
+
);
|
|
1265
|
+
server.tool(
|
|
1266
|
+
"get_event",
|
|
1267
|
+
"Get full parameters for a specific gravitational wave event",
|
|
1268
|
+
{
|
|
1269
|
+
name: z.string().describe("Event name (e.g. GW150914, GW170817)")
|
|
1270
|
+
},
|
|
1271
|
+
async (params) => {
|
|
1272
|
+
const catalog = await getCatalog();
|
|
1273
|
+
const event = findEvent(catalog, params.name);
|
|
1274
|
+
if (!event) {
|
|
1275
|
+
return { content: [{ type: "text", text: `Event "${params.name}" not found in catalog.` }] };
|
|
1276
|
+
}
|
|
1277
|
+
const mm = getMultiMessengerData(event.commonName);
|
|
1278
|
+
const result = {
|
|
1279
|
+
name: event.commonName,
|
|
1280
|
+
type: classifyEvent(event),
|
|
1281
|
+
catalog: event.catalog_shortName,
|
|
1282
|
+
gps_time: event.GPS,
|
|
1283
|
+
mass_1: { value: event.mass_1_source, lower: event.mass_1_source_lower, upper: event.mass_1_source_upper, unit: "M_sun" },
|
|
1284
|
+
mass_2: { value: event.mass_2_source, lower: event.mass_2_source_lower, upper: event.mass_2_source_upper, unit: "M_sun" },
|
|
1285
|
+
total_mass: event.total_mass_source,
|
|
1286
|
+
chirp_mass: { value: event.chirp_mass_source, lower: event.chirp_mass_source_lower, upper: event.chirp_mass_source_upper, unit: "M_sun" },
|
|
1287
|
+
final_mass: { value: event.final_mass_source, lower: event.final_mass_source_lower, upper: event.final_mass_source_upper, unit: "M_sun" },
|
|
1288
|
+
distance: { value: event.luminosity_distance, lower: event.luminosity_distance_lower, upper: event.luminosity_distance_upper, unit: "Mpc" },
|
|
1289
|
+
redshift: event.redshift,
|
|
1290
|
+
chi_eff: event.chi_eff,
|
|
1291
|
+
snr: event.network_matched_filter_snr,
|
|
1292
|
+
false_alarm_rate: event.far,
|
|
1293
|
+
p_astro: event.p_astro
|
|
1294
|
+
};
|
|
1295
|
+
if (mm) {
|
|
1296
|
+
result.multi_messenger = {
|
|
1297
|
+
em_counterparts: mm.emCounterparts.map((c) => ({
|
|
1298
|
+
name: c.name,
|
|
1299
|
+
type: c.type,
|
|
1300
|
+
delay_seconds: c.delaySeconds,
|
|
1301
|
+
observatory: c.observatory
|
|
1302
|
+
})),
|
|
1303
|
+
host_galaxy: mm.hostGalaxy,
|
|
1304
|
+
h0_measurement: mm.h0Measurement,
|
|
1305
|
+
ejecta_mass: mm.ejectaMass
|
|
1306
|
+
};
|
|
1307
|
+
}
|
|
1308
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
1309
|
+
}
|
|
1310
|
+
);
|
|
1311
|
+
server.tool(
|
|
1312
|
+
"generate_waveform",
|
|
1313
|
+
"Generate a synthetic gravitational waveform (h+ and hx time series)",
|
|
1314
|
+
{
|
|
1315
|
+
event_name: z.string().optional().describe("Generate waveform for a catalog event"),
|
|
1316
|
+
m1: z.number().optional().describe("Primary mass in solar masses (for custom waveform)"),
|
|
1317
|
+
m2: z.number().optional().describe("Secondary mass in solar masses (for custom waveform)"),
|
|
1318
|
+
chi1: z.number().optional().default(0).describe("Primary spin (-1 to 1)"),
|
|
1319
|
+
chi2: z.number().optional().default(0).describe("Secondary spin (-1 to 1)"),
|
|
1320
|
+
distance: z.number().optional().default(100).describe("Distance in Mpc"),
|
|
1321
|
+
inclination: z.number().optional().default(0).describe("Inclination in radians"),
|
|
1322
|
+
format: z.enum(["json", "csv"]).optional().default("json").describe("Output format")
|
|
1323
|
+
},
|
|
1324
|
+
async (params) => {
|
|
1325
|
+
let waveform;
|
|
1326
|
+
if (params.event_name) {
|
|
1327
|
+
const catalog = await getCatalog();
|
|
1328
|
+
const event = findEvent(catalog, params.event_name);
|
|
1329
|
+
if (!event) {
|
|
1330
|
+
return { content: [{ type: "text", text: `Event "${params.event_name}" not found.` }] };
|
|
1331
|
+
}
|
|
1332
|
+
waveform = generateWaveform(event);
|
|
1333
|
+
} else if (params.m1 != null && params.m2 != null) {
|
|
1334
|
+
waveform = generateCustomWaveform({
|
|
1335
|
+
m1: params.m1,
|
|
1336
|
+
m2: params.m2,
|
|
1337
|
+
chi1: params.chi1 ?? 0,
|
|
1338
|
+
chi2: params.chi2 ?? 0,
|
|
1339
|
+
distance: params.distance ?? 100,
|
|
1340
|
+
inclination: params.inclination ?? 0
|
|
1341
|
+
});
|
|
1342
|
+
} else {
|
|
1343
|
+
return { content: [{ type: "text", text: "Provide either event_name or m1+m2 for custom waveform." }] };
|
|
1344
|
+
}
|
|
1345
|
+
if (params.format === "csv") {
|
|
1346
|
+
return { content: [{ type: "text", text: generateWaveformCSV(waveform) }] };
|
|
1347
|
+
}
|
|
1348
|
+
const step = Math.max(1, Math.floor(waveform.hPlus.length / 200));
|
|
1349
|
+
const sampled = {
|
|
1350
|
+
event: waveform.eventName,
|
|
1351
|
+
sample_rate: waveform.sampleRate,
|
|
1352
|
+
duration: waveform.duration,
|
|
1353
|
+
peak_index: waveform.peakIndex,
|
|
1354
|
+
num_samples: waveform.hPlus.length,
|
|
1355
|
+
note: step > 1 ? `Downsampled by ${step}x for display. Use format=csv for full data.` : void 0,
|
|
1356
|
+
h_plus: waveform.hPlus.filter((_, i) => i % step === 0).map((v) => +v.toFixed(6)),
|
|
1357
|
+
h_cross: waveform.hCross.filter((_, i) => i % step === 0).map((v) => +v.toFixed(6))
|
|
1358
|
+
};
|
|
1359
|
+
return { content: [{ type: "text", text: JSON.stringify(sampled, null, 2) }] };
|
|
1360
|
+
}
|
|
1361
|
+
);
|
|
1362
|
+
server.tool(
|
|
1363
|
+
"compute_qnm",
|
|
1364
|
+
"Compute quasi-normal mode frequencies for a black hole merger remnant",
|
|
1365
|
+
{
|
|
1366
|
+
m1: z.number().describe("Primary mass in solar masses"),
|
|
1367
|
+
m2: z.number().describe("Secondary mass in solar masses"),
|
|
1368
|
+
chi1: z.number().optional().default(0).describe("Primary spin"),
|
|
1369
|
+
chi2: z.number().optional().default(0).describe("Secondary spin"),
|
|
1370
|
+
modes: z.array(z.string()).optional().default(["2,2,0", "2,2,1"]).describe("QNM modes to compute (e.g. ['2,2,0'])")
|
|
1371
|
+
},
|
|
1372
|
+
async (params) => {
|
|
1373
|
+
const results = computeQNMModes(params.m1, params.m2, params.chi1, params.chi2, params.modes);
|
|
1374
|
+
return {
|
|
1375
|
+
content: [{
|
|
1376
|
+
type: "text",
|
|
1377
|
+
text: JSON.stringify({
|
|
1378
|
+
input: { m1: params.m1, m2: params.m2, chi1: params.chi1, chi2: params.chi2 },
|
|
1379
|
+
modes: results.map((m) => ({
|
|
1380
|
+
mode: m.label,
|
|
1381
|
+
frequency_Hz: +m.frequency.toFixed(2),
|
|
1382
|
+
damping_time_ms: +(m.dampingTime * 1e3).toFixed(3),
|
|
1383
|
+
quality_factor: +m.qualityFactor.toFixed(2)
|
|
1384
|
+
}))
|
|
1385
|
+
}, null, 2)
|
|
1386
|
+
}]
|
|
1387
|
+
};
|
|
1388
|
+
}
|
|
1389
|
+
);
|
|
1390
|
+
server.tool(
|
|
1391
|
+
"compute_snr",
|
|
1392
|
+
"Compute optimal matched-filter SNR against aLIGO design sensitivity",
|
|
1393
|
+
{
|
|
1394
|
+
event_name: z.string().optional().describe("Compute SNR for a catalog event"),
|
|
1395
|
+
m1: z.number().optional().describe("Primary mass (for custom)"),
|
|
1396
|
+
m2: z.number().optional().describe("Secondary mass (for custom)"),
|
|
1397
|
+
distance: z.number().optional().default(100).describe("Distance in Mpc (for custom)")
|
|
1398
|
+
},
|
|
1399
|
+
async (params) => {
|
|
1400
|
+
let waveform;
|
|
1401
|
+
if (params.event_name) {
|
|
1402
|
+
const catalog = await getCatalog();
|
|
1403
|
+
const event = findEvent(catalog, params.event_name);
|
|
1404
|
+
if (!event) {
|
|
1405
|
+
return { content: [{ type: "text", text: `Event "${params.event_name}" not found.` }] };
|
|
1406
|
+
}
|
|
1407
|
+
waveform = generateWaveform(event);
|
|
1408
|
+
} else if (params.m1 != null && params.m2 != null) {
|
|
1409
|
+
waveform = generateCustomWaveform({
|
|
1410
|
+
m1: params.m1,
|
|
1411
|
+
m2: params.m2,
|
|
1412
|
+
chi1: 0,
|
|
1413
|
+
chi2: 0,
|
|
1414
|
+
distance: params.distance ?? 100,
|
|
1415
|
+
inclination: 0
|
|
1416
|
+
});
|
|
1417
|
+
} else {
|
|
1418
|
+
return { content: [{ type: "text", text: "Provide event_name or m1+m2." }] };
|
|
1419
|
+
}
|
|
1420
|
+
const strain = computeCharacteristicStrain(waveform);
|
|
1421
|
+
const snr = computeOptimalSNR(strain);
|
|
1422
|
+
return {
|
|
1423
|
+
content: [{
|
|
1424
|
+
type: "text",
|
|
1425
|
+
text: JSON.stringify({
|
|
1426
|
+
event: waveform.eventName,
|
|
1427
|
+
optimal_snr: +snr.toFixed(2),
|
|
1428
|
+
note: "SNR computed against aLIGO O4 design sensitivity (10-5000 Hz band)"
|
|
1429
|
+
}, null, 2)
|
|
1430
|
+
}]
|
|
1431
|
+
};
|
|
1432
|
+
}
|
|
1433
|
+
);
|
|
1434
|
+
server.tool(
|
|
1435
|
+
"get_population_stats",
|
|
1436
|
+
"Get population statistics from the gravitational wave catalog",
|
|
1437
|
+
{
|
|
1438
|
+
stat: z.enum(["mass", "chirp_mass", "spin", "distance", "type_counts"]).describe("Which statistic to compute")
|
|
1439
|
+
},
|
|
1440
|
+
async (params) => {
|
|
1441
|
+
const catalog = await getCatalog();
|
|
1442
|
+
if (params.stat === "type_counts") {
|
|
1443
|
+
const counts = { BBH: 0, BNS: 0, NSBH: 0 };
|
|
1444
|
+
for (const e of catalog) {
|
|
1445
|
+
const t = classifyEvent(e);
|
|
1446
|
+
if (t in counts) counts[t]++;
|
|
1447
|
+
}
|
|
1448
|
+
return { content: [{ type: "text", text: JSON.stringify({ total: catalog.length, ...counts }, null, 2) }] };
|
|
1449
|
+
}
|
|
1450
|
+
if (params.stat === "mass") {
|
|
1451
|
+
const masses = catalog.map((e) => ({ name: e.commonName, m1: e.mass_1_source, m2: e.mass_2_source, total: e.total_mass_source }));
|
|
1452
|
+
const totalMasses = masses.map((m) => m.total).filter((m) => m > 0);
|
|
1453
|
+
return {
|
|
1454
|
+
content: [{
|
|
1455
|
+
type: "text",
|
|
1456
|
+
text: JSON.stringify({
|
|
1457
|
+
count: totalMasses.length,
|
|
1458
|
+
min_total_mass: +Math.min(...totalMasses).toFixed(1),
|
|
1459
|
+
max_total_mass: +Math.max(...totalMasses).toFixed(1),
|
|
1460
|
+
median_total_mass: +totalMasses.sort((a, b) => a - b)[Math.floor(totalMasses.length / 2)].toFixed(1),
|
|
1461
|
+
heaviest_events: masses.sort((a, b) => b.total - a.total).slice(0, 5)
|
|
1462
|
+
}, null, 2)
|
|
1463
|
+
}]
|
|
1464
|
+
};
|
|
1465
|
+
}
|
|
1466
|
+
if (params.stat === "chirp_mass") {
|
|
1467
|
+
const mc = catalog.map((e) => e.chirp_mass_source).filter((m) => m > 0);
|
|
1468
|
+
mc.sort((a, b) => a - b);
|
|
1469
|
+
return {
|
|
1470
|
+
content: [{
|
|
1471
|
+
type: "text",
|
|
1472
|
+
text: JSON.stringify({
|
|
1473
|
+
count: mc.length,
|
|
1474
|
+
min: +mc[0].toFixed(2),
|
|
1475
|
+
max: +mc[mc.length - 1].toFixed(2),
|
|
1476
|
+
median: +mc[Math.floor(mc.length / 2)].toFixed(2),
|
|
1477
|
+
p10: +mc[Math.floor(mc.length * 0.1)].toFixed(2),
|
|
1478
|
+
p90: +mc[Math.floor(mc.length * 0.9)].toFixed(2)
|
|
1479
|
+
}, null, 2)
|
|
1480
|
+
}]
|
|
1481
|
+
};
|
|
1482
|
+
}
|
|
1483
|
+
if (params.stat === "spin") {
|
|
1484
|
+
const spins = catalog.map((e) => e.chi_eff).filter((s) => s !== 0 || true);
|
|
1485
|
+
spins.sort((a, b) => a - b);
|
|
1486
|
+
return {
|
|
1487
|
+
content: [{
|
|
1488
|
+
type: "text",
|
|
1489
|
+
text: JSON.stringify({
|
|
1490
|
+
count: spins.length,
|
|
1491
|
+
min_chi_eff: +spins[0].toFixed(3),
|
|
1492
|
+
max_chi_eff: +spins[spins.length - 1].toFixed(3),
|
|
1493
|
+
median_chi_eff: +spins[Math.floor(spins.length / 2)].toFixed(3),
|
|
1494
|
+
note: "chi_eff is the mass-weighted effective spin parameter"
|
|
1495
|
+
}, null, 2)
|
|
1496
|
+
}]
|
|
1497
|
+
};
|
|
1498
|
+
}
|
|
1499
|
+
if (params.stat === "distance") {
|
|
1500
|
+
const dists = catalog.map((e) => e.luminosity_distance).filter((d) => d > 0);
|
|
1501
|
+
dists.sort((a, b) => a - b);
|
|
1502
|
+
return {
|
|
1503
|
+
content: [{
|
|
1504
|
+
type: "text",
|
|
1505
|
+
text: JSON.stringify({
|
|
1506
|
+
count: dists.length,
|
|
1507
|
+
min_Mpc: +dists[0].toFixed(0),
|
|
1508
|
+
max_Mpc: +dists[dists.length - 1].toFixed(0),
|
|
1509
|
+
median_Mpc: +dists[Math.floor(dists.length / 2)].toFixed(0),
|
|
1510
|
+
nearest_events: catalog.filter((e) => e.luminosity_distance > 0).sort((a, b) => a.luminosity_distance - b.luminosity_distance).slice(0, 5).map((e) => ({ name: e.commonName, distance_Mpc: e.luminosity_distance }))
|
|
1511
|
+
}, null, 2)
|
|
1512
|
+
}]
|
|
1513
|
+
};
|
|
1514
|
+
}
|
|
1515
|
+
return { content: [{ type: "text", text: "Unknown stat type." }] };
|
|
1516
|
+
}
|
|
1517
|
+
);
|
|
1518
|
+
server.tool(
|
|
1519
|
+
"integrate_geodesic",
|
|
1520
|
+
"Trace a photon or particle geodesic around a Schwarzschild black hole",
|
|
1521
|
+
{
|
|
1522
|
+
start_r: z.number().describe("Starting radial distance (in units of Schwarzschild radius)"),
|
|
1523
|
+
start_angle: z.number().optional().default(0).describe("Starting angle in radians"),
|
|
1524
|
+
velocity_angle: z.number().optional().default(Math.PI / 2).describe("Initial velocity direction angle"),
|
|
1525
|
+
schwarzschild_radius: z.number().optional().default(2).describe("Schwarzschild radius"),
|
|
1526
|
+
particle_type: z.enum(["photon", "particle"]).optional().default("photon"),
|
|
1527
|
+
energy: z.number().optional().default(1).describe("Specific energy for massive particles"),
|
|
1528
|
+
max_points: z.number().optional().default(200).describe("Max output points")
|
|
1529
|
+
},
|
|
1530
|
+
async (params) => {
|
|
1531
|
+
const rs = params.schwarzschild_radius ?? 2;
|
|
1532
|
+
const r0 = params.start_r * rs;
|
|
1533
|
+
const angle = params.start_angle ?? 0;
|
|
1534
|
+
const startPos = new Vec3(r0 * Math.cos(angle), 0, r0 * Math.sin(angle));
|
|
1535
|
+
const va = params.velocity_angle ?? Math.PI / 2;
|
|
1536
|
+
const startVel = new Vec3(Math.cos(va), 0, Math.sin(va));
|
|
1537
|
+
let result;
|
|
1538
|
+
if (params.particle_type === "particle") {
|
|
1539
|
+
result = integrateTimelikeGeodesic(startPos, startVel, rs, params.energy ?? 1);
|
|
1540
|
+
} else {
|
|
1541
|
+
result = integrateGeodesic(startPos, startVel, rs);
|
|
1542
|
+
}
|
|
1543
|
+
const step = Math.max(1, Math.floor(result.points.length / (params.max_points ?? 200)));
|
|
1544
|
+
const points = result.points.filter((_, i) => i % step === 0).map((p) => ({ x: +p.x.toFixed(4), y: +p.y.toFixed(4), z: +p.z.toFixed(4) }));
|
|
1545
|
+
return {
|
|
1546
|
+
content: [{
|
|
1547
|
+
type: "text",
|
|
1548
|
+
text: JSON.stringify({
|
|
1549
|
+
outcome: result.outcome,
|
|
1550
|
+
angular_momentum: +result.L.toFixed(4),
|
|
1551
|
+
particle_type: result.particleType,
|
|
1552
|
+
num_points: result.points.length,
|
|
1553
|
+
points_shown: points.length,
|
|
1554
|
+
trajectory: points
|
|
1555
|
+
}, null, 2)
|
|
1556
|
+
}]
|
|
1557
|
+
};
|
|
1558
|
+
}
|
|
1559
|
+
);
|
|
1560
|
+
server.tool(
|
|
1561
|
+
"export_event",
|
|
1562
|
+
"Generate event data in various formats (JSON, CSV, BibTeX, Jupyter notebook)",
|
|
1563
|
+
{
|
|
1564
|
+
event_name: z.string().describe("Event name"),
|
|
1565
|
+
format: z.enum(["json", "csv", "bibtex", "notebook", "readme", "waveform_csv"]).describe("Export format")
|
|
1566
|
+
},
|
|
1567
|
+
async (params) => {
|
|
1568
|
+
const catalog = await getCatalog();
|
|
1569
|
+
const event = findEvent(catalog, params.event_name);
|
|
1570
|
+
if (!event) {
|
|
1571
|
+
return { content: [{ type: "text", text: `Event "${params.event_name}" not found.` }] };
|
|
1572
|
+
}
|
|
1573
|
+
let output;
|
|
1574
|
+
switch (params.format) {
|
|
1575
|
+
case "json":
|
|
1576
|
+
output = generateParametersJSON(event);
|
|
1577
|
+
break;
|
|
1578
|
+
case "csv":
|
|
1579
|
+
output = generateParametersCSV(event);
|
|
1580
|
+
break;
|
|
1581
|
+
case "bibtex":
|
|
1582
|
+
output = generateBibTeX(event);
|
|
1583
|
+
break;
|
|
1584
|
+
case "notebook":
|
|
1585
|
+
output = generateNotebook(event);
|
|
1586
|
+
break;
|
|
1587
|
+
case "readme":
|
|
1588
|
+
output = generateREADME(event);
|
|
1589
|
+
break;
|
|
1590
|
+
case "waveform_csv": {
|
|
1591
|
+
const wf = generateWaveform(event);
|
|
1592
|
+
output = generateWaveformCSV(wf);
|
|
1593
|
+
break;
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
return { content: [{ type: "text", text: output }] };
|
|
1597
|
+
}
|
|
1598
|
+
);
|
|
1599
|
+
var transport = new StdioServerTransport();
|
|
1600
|
+
await server.connect(transport);
|