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,1276 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/core/catalog.ts
4
+ var GWOSC_API = "https://gwosc.org/eventapi/json/allevents/";
5
+ function seededRandom(seed) {
6
+ let s = seed;
7
+ return () => {
8
+ s = (s * 16807 + 0) % 2147483647;
9
+ return s / 2147483647;
10
+ };
11
+ }
12
+ async function fetchEventCatalog() {
13
+ const res = await fetch(GWOSC_API);
14
+ if (!res.ok) throw new Error(`GWOSC API returned ${res.status}`);
15
+ const data = await res.json();
16
+ const catalogPriority = {
17
+ "O1_O2-Preliminary": 1,
18
+ "Initial_LIGO_Virgo": 1,
19
+ "GWTC-1-marginal": 2,
20
+ "GWTC-1-confident": 3,
21
+ "GWTC-2": 4,
22
+ "GWTC-2.1-marginal": 5,
23
+ "GWTC-2.1-auxiliary": 5,
24
+ "GWTC-2.1-confident": 6,
25
+ "GWTC-3-marginal": 7,
26
+ "GWTC-3-confident": 8,
27
+ "O3_Discovery_Papers": 8,
28
+ "O3_IMBH_marginal": 7,
29
+ "IAS-O3a": 5,
30
+ "GWTC-4.0": 9,
31
+ "O4_Discovery_Papers": 9
32
+ };
33
+ const deduped = /* @__PURE__ */ new Map();
34
+ for (const [, entry] of Object.entries(data.events)) {
35
+ const e = entry;
36
+ const name = e.commonName ?? "";
37
+ if (!name) continue;
38
+ const existing = deduped.get(name);
39
+ if (existing) {
40
+ const existingPri = catalogPriority[existing["catalog.shortName"] ?? ""] ?? 0;
41
+ const newPri = catalogPriority[e["catalog.shortName"] ?? ""] ?? 0;
42
+ if (newPri > existingPri || newPri === existingPri && !existing.mass_1_source && e.mass_1_source) {
43
+ deduped.set(name, e);
44
+ }
45
+ } else {
46
+ deduped.set(name, e);
47
+ }
48
+ }
49
+ const events = [];
50
+ for (const [, e] of deduped) {
51
+ if (!e.mass_1_source || !e.mass_2_source) continue;
52
+ const gps = e.GPS ?? 0;
53
+ const distance = e.luminosity_distance ?? 0;
54
+ const rng = seededRandom(Math.floor(gps));
55
+ const ra = rng() * 2 * Math.PI;
56
+ const dec = Math.asin(2 * rng() - 1);
57
+ const r = distance;
58
+ const x = r * Math.cos(dec) * Math.cos(ra);
59
+ const y = r * Math.cos(dec) * Math.sin(ra);
60
+ const z = r * Math.sin(dec);
61
+ const event = {
62
+ commonName: e.commonName ?? "",
63
+ GPS: gps,
64
+ mass_1_source: e.mass_1_source,
65
+ mass_1_source_lower: e.mass_1_source_lower ?? 0,
66
+ mass_1_source_upper: e.mass_1_source_upper ?? 0,
67
+ mass_2_source: e.mass_2_source,
68
+ mass_2_source_lower: e.mass_2_source_lower ?? 0,
69
+ mass_2_source_upper: e.mass_2_source_upper ?? 0,
70
+ luminosity_distance: distance,
71
+ luminosity_distance_lower: e.luminosity_distance_lower ?? 0,
72
+ luminosity_distance_upper: e.luminosity_distance_upper ?? 0,
73
+ redshift: e.redshift ?? 0,
74
+ chi_eff: e.chi_eff ?? 0,
75
+ network_matched_filter_snr: e.network_matched_filter_snr ?? 0,
76
+ far: e.far ?? 0,
77
+ catalog_shortName: e["catalog.shortName"] ?? "",
78
+ total_mass_source: e.total_mass_source ?? 0,
79
+ chirp_mass_source: e.chirp_mass_source ?? 0,
80
+ chirp_mass_source_lower: e.chirp_mass_source_lower ?? 0,
81
+ chirp_mass_source_upper: e.chirp_mass_source_upper ?? 0,
82
+ final_mass_source: e.final_mass_source ?? 0,
83
+ final_mass_source_lower: e.final_mass_source_lower ?? 0,
84
+ final_mass_source_upper: e.final_mass_source_upper ?? 0,
85
+ p_astro: e.p_astro ?? 0,
86
+ mapPosition: { x, y, z }
87
+ };
88
+ events.push(event);
89
+ }
90
+ events.sort(
91
+ (a, b) => b.network_matched_filter_snr - a.network_matched_filter_snr
92
+ );
93
+ return events;
94
+ }
95
+ function classifyEvent(event) {
96
+ const total = event.mass_1_source + event.mass_2_source;
97
+ if (total < 5) return "BNS";
98
+ if (event.mass_2_source < 3) return "NSBH";
99
+ return "BBH";
100
+ }
101
+
102
+ // src/core/waveform.ts
103
+ function generateWaveform(event) {
104
+ const m1 = event.mass_1_source;
105
+ const m2 = event.mass_2_source;
106
+ const totalMass = m1 + m2;
107
+ const chirpMass = Math.pow(m1 * m2, 3 / 5) / Math.pow(totalMass, 1 / 5);
108
+ const sampleRate = 512;
109
+ const duration = Math.min(4, Math.max(1.5, 120 / chirpMass));
110
+ const numSamples = Math.floor(duration * sampleRate);
111
+ const mergerIndex = Math.floor(numSamples * 0.75);
112
+ const hPlus = new Array(numSamples);
113
+ const hCross = new Array(numSamples);
114
+ const fRingdown = 32e3 / totalMass;
115
+ const tauRingdown = totalMass / 5e3;
116
+ for (let i = 0; i < numSamples; i++) {
117
+ const t = i / sampleRate;
118
+ const tMerger = mergerIndex / sampleRate;
119
+ let amplitude;
120
+ let phase;
121
+ if (i < mergerIndex) {
122
+ const tau = Math.max(tMerger - t, 1e-3);
123
+ const freqFactor = chirpMass / 30;
124
+ amplitude = 0.3 * Math.pow(0.5 / tau, 1 / 4);
125
+ phase = -2 * Math.PI * 20 * Math.pow(0.5, 3 / 8) * (8 / 5) * Math.pow(tau, 5 / 8) * freqFactor;
126
+ amplitude = Math.min(amplitude, 1);
127
+ } else {
128
+ const tPost = t - tMerger;
129
+ amplitude = Math.exp(-tPost / tauRingdown);
130
+ phase = 2 * Math.PI * fRingdown * tPost;
131
+ }
132
+ hPlus[i] = amplitude * Math.cos(phase);
133
+ hCross[i] = amplitude * Math.sin(phase);
134
+ }
135
+ let maxAmp = 0;
136
+ for (let i = 0; i < numSamples; i++) {
137
+ const a = Math.sqrt(hPlus[i] ** 2 + hCross[i] ** 2);
138
+ if (a > maxAmp) maxAmp = a;
139
+ }
140
+ if (maxAmp > 0) {
141
+ for (let i = 0; i < numSamples; i++) {
142
+ hPlus[i] /= maxAmp;
143
+ hCross[i] /= maxAmp;
144
+ }
145
+ }
146
+ return {
147
+ eventName: event.commonName,
148
+ sampleRate,
149
+ hPlus,
150
+ hCross,
151
+ duration,
152
+ peakIndex: mergerIndex
153
+ };
154
+ }
155
+ function generateCustomWaveform(params) {
156
+ const { m1, m2, chi1, chi2, inclination } = params;
157
+ const totalMass = m1 + m2;
158
+ const chirpMass = Math.pow(m1 * m2, 3 / 5) / Math.pow(totalMass, 1 / 5);
159
+ const chiEff = (m1 * chi1 + m2 * chi2) / totalMass;
160
+ const sampleRate = 512;
161
+ const duration = Math.min(4, Math.max(1.5, 120 / chirpMass));
162
+ const numSamples = Math.floor(duration * sampleRate);
163
+ const mergerIndex = Math.floor(numSamples * 0.75);
164
+ const hPlus = new Array(numSamples);
165
+ const hCross = new Array(numSamples);
166
+ const fRingdown = 32e3 / totalMass * (1 + 0.15 * Math.abs(chiEff));
167
+ const tauRingdown = totalMass / 5e3;
168
+ const cosInc = Math.cos(inclination);
169
+ const ampPlus = (1 + cosInc * cosInc) / 2;
170
+ const ampCross = cosInc;
171
+ for (let i = 0; i < numSamples; i++) {
172
+ const t = i / sampleRate;
173
+ const tMerger = mergerIndex / sampleRate;
174
+ let amplitude;
175
+ let phase;
176
+ if (i < mergerIndex) {
177
+ const tau = Math.max(tMerger - t, 1e-3);
178
+ const freqFactor = chirpMass / 30;
179
+ amplitude = 0.3 * Math.pow(0.5 / tau, 1 / 4);
180
+ phase = -2 * Math.PI * 20 * Math.pow(0.5, 3 / 8) * (8 / 5) * Math.pow(tau, 5 / 8) * freqFactor;
181
+ amplitude = Math.min(amplitude, 1);
182
+ } else {
183
+ const tPost = t - tMerger;
184
+ amplitude = Math.exp(-tPost / tauRingdown);
185
+ phase = 2 * Math.PI * fRingdown * tPost;
186
+ }
187
+ hPlus[i] = amplitude * ampPlus * Math.cos(phase);
188
+ hCross[i] = amplitude * ampCross * Math.sin(phase);
189
+ }
190
+ let maxAmp = 0;
191
+ for (let i = 0; i < numSamples; i++) {
192
+ const a = Math.sqrt(hPlus[i] ** 2 + hCross[i] ** 2);
193
+ if (a > maxAmp) maxAmp = a;
194
+ }
195
+ if (maxAmp > 0) {
196
+ for (let i = 0; i < numSamples; i++) {
197
+ hPlus[i] /= maxAmp;
198
+ hCross[i] /= maxAmp;
199
+ }
200
+ }
201
+ const name = `Custom (${m1.toFixed(0)}+${m2.toFixed(0)} M\u2609)`;
202
+ return {
203
+ eventName: name,
204
+ sampleRate,
205
+ hPlus,
206
+ hCross,
207
+ duration,
208
+ peakIndex: mergerIndex
209
+ };
210
+ }
211
+
212
+ // src/core/qnm.ts
213
+ var BERTI_COEFFS = {
214
+ "2,2,0": [1.5251, -1.1568, 0.1292, 0.7, 1.4187, -0.499],
215
+ "2,2,1": [1.3673, -1.026, 0.1628, 0.3562, 2.342, -0.2467]
216
+ };
217
+ var MSUN_KG = 1989e27;
218
+ var G = 6674e-14;
219
+ var C = 2998e5;
220
+ var MSUN_SEC = G * MSUN_KG / (C * C * C);
221
+ function estimateFinalSpin(m1, m2, chi1 = 0, chi2 = 0) {
222
+ const totalMass = m1 + m2;
223
+ const eta = m1 * m2 / (totalMass * totalMass);
224
+ const chiEff = (m1 * chi1 + m2 * chi2) / totalMass;
225
+ const aFinal = Math.sqrt(12) * eta - 3.871 * eta * eta + 4.028 * eta * eta * eta + chiEff * eta * (2 - 1.25 * eta);
226
+ return Math.min(Math.max(aFinal, 0), 0.998);
227
+ }
228
+ function estimateFinalMass(m1, m2) {
229
+ const totalMass = m1 + m2;
230
+ const eta = m1 * m2 / (totalMass * totalMass);
231
+ const erad = 0.0559745 * eta + 0.1469 * eta * eta;
232
+ return totalMass * (1 - erad);
233
+ }
234
+ function computeQNMModes(m1, m2, chi1 = 0, chi2 = 0, modes = ["2,2,0", "2,2,1"]) {
235
+ const finalMass = estimateFinalMass(m1, m2);
236
+ const finalSpin = estimateFinalSpin(m1, m2, chi1, chi2);
237
+ const mfSec = finalMass * MSUN_SEC;
238
+ const results = [];
239
+ for (const modeKey of modes) {
240
+ const coeffs = BERTI_COEFFS[modeKey];
241
+ if (!coeffs) continue;
242
+ const [f1, f2, f3, q1, q2, q3] = coeffs;
243
+ const omegaHat = f1 + f2 * Math.pow(1 - finalSpin, f3);
244
+ const Q = q1 + q2 * Math.pow(1 - finalSpin, q3);
245
+ const frequency = omegaHat / (2 * Math.PI * mfSec);
246
+ const dampingTime = Q / (Math.PI * frequency);
247
+ const [l, m, n] = modeKey.split(",").map(Number);
248
+ results.push({
249
+ l,
250
+ m,
251
+ n,
252
+ frequency,
253
+ dampingTime,
254
+ qualityFactor: Q,
255
+ label: `(${l},${m},${n})`
256
+ });
257
+ }
258
+ return results;
259
+ }
260
+
261
+ // src/core/noise-curve.ts
262
+ function fftInPlace(re, im) {
263
+ const N = re.length;
264
+ for (let i = 1, j = 0; i < N; i++) {
265
+ let bit = N >> 1;
266
+ while (j & bit) {
267
+ j ^= bit;
268
+ bit >>= 1;
269
+ }
270
+ j ^= bit;
271
+ if (i < j) {
272
+ [re[i], re[j]] = [re[j], re[i]];
273
+ [im[i], im[j]] = [im[j], im[i]];
274
+ }
275
+ }
276
+ for (let len = 2; len <= N; len *= 2) {
277
+ const halfLen = len / 2;
278
+ const angle = -2 * Math.PI / len;
279
+ const wRe = Math.cos(angle);
280
+ const wIm = Math.sin(angle);
281
+ for (let i = 0; i < N; i += len) {
282
+ let curRe = 1;
283
+ let curIm = 0;
284
+ for (let j = 0; j < halfLen; j++) {
285
+ const a = i + j;
286
+ const b = a + halfLen;
287
+ const tRe = curRe * re[b] - curIm * im[b];
288
+ const tIm = curRe * im[b] + curIm * re[b];
289
+ re[b] = re[a] - tRe;
290
+ im[b] = im[a] - tIm;
291
+ re[a] += tRe;
292
+ im[a] += tIm;
293
+ const nextRe = curRe * wRe - curIm * wIm;
294
+ curIm = curRe * wIm + curIm * wRe;
295
+ curRe = nextRe;
296
+ }
297
+ }
298
+ }
299
+ }
300
+ function nextPow2(n) {
301
+ let p = 1;
302
+ while (p < n) p *= 2;
303
+ return p;
304
+ }
305
+ function computeCharacteristicStrain(waveform) {
306
+ const minN = 2048;
307
+ const N = nextPow2(Math.max(waveform.hPlus.length, minN));
308
+ const dt = 1 / waveform.sampleRate;
309
+ const re = new Float64Array(N);
310
+ const im = new Float64Array(N);
311
+ for (let i = 0; i < waveform.hPlus.length; i++) {
312
+ re[i] = waveform.hPlus[i];
313
+ }
314
+ fftInPlace(re, im);
315
+ const halfN = N / 2;
316
+ const df = 1 / (N * dt);
317
+ const frequencies = new Float64Array(halfN);
318
+ const hc = new Float64Array(halfN);
319
+ for (let k = 0; k < halfN; k++) {
320
+ const f = k * df;
321
+ frequencies[k] = f;
322
+ const mag = Math.sqrt(re[k] * re[k] + im[k] * im[k]) * dt;
323
+ hc[k] = 2 * f * mag;
324
+ }
325
+ return { frequencies, hc };
326
+ }
327
+ var ALIGO_DATA = [
328
+ [10, 1e-20],
329
+ [11, 65e-22],
330
+ [12, 42e-22],
331
+ [13, 29e-22],
332
+ [14, 21e-22],
333
+ [15, 16e-22],
334
+ [16, 13e-22],
335
+ [17, 11e-22],
336
+ [18, 9e-22],
337
+ [19, 78e-23],
338
+ [20, 68e-23],
339
+ [22, 53e-23],
340
+ [24, 43e-23],
341
+ [26, 36e-23],
342
+ [28, 31e-23],
343
+ [30, 27e-23],
344
+ [33, 23e-23],
345
+ [36, 2e-22],
346
+ [40, 17e-23],
347
+ [45, 14e-23],
348
+ [50, 12e-23],
349
+ [55, 105e-24],
350
+ [60, 95e-24],
351
+ [65, 87e-24],
352
+ [70, 8e-23],
353
+ [75, 75e-24],
354
+ [80, 7e-23],
355
+ [85, 66e-24],
356
+ [90, 63e-24],
357
+ [95, 6e-23],
358
+ [100, 57e-24],
359
+ [110, 52e-24],
360
+ [120, 48e-24],
361
+ [130, 45e-24],
362
+ [140, 42e-24],
363
+ [150, 4e-23],
364
+ [160, 39e-24],
365
+ [170, 38e-24],
366
+ [180, 37e-24],
367
+ [190, 36e-24],
368
+ [200, 36e-24],
369
+ [220, 36e-24],
370
+ [240, 37e-24],
371
+ [260, 38e-24],
372
+ [280, 4e-23],
373
+ [300, 42e-24],
374
+ [320, 45e-24],
375
+ [340, 48e-24],
376
+ [360, 52e-24],
377
+ [380, 56e-24],
378
+ [400, 6e-23],
379
+ [430, 68e-24],
380
+ [460, 77e-24],
381
+ [500, 9e-23],
382
+ [550, 11e-23],
383
+ [600, 13e-23],
384
+ [650, 15e-23],
385
+ [700, 18e-23],
386
+ [750, 21e-23],
387
+ [800, 25e-23],
388
+ [850, 29e-23],
389
+ [900, 34e-23],
390
+ [950, 4e-22],
391
+ [1e3, 46e-23],
392
+ [1100, 62e-23],
393
+ [1200, 82e-23],
394
+ [1300, 11e-22],
395
+ [1400, 14e-22],
396
+ [1500, 18e-22],
397
+ [1600, 23e-22],
398
+ [1700, 3e-21],
399
+ [1800, 38e-22],
400
+ [1900, 48e-22],
401
+ [2e3, 6e-21],
402
+ [2200, 95e-22],
403
+ [2400, 15e-21],
404
+ [2600, 23e-21],
405
+ [2800, 35e-21],
406
+ [3e3, 55e-21],
407
+ [3500, 15e-20],
408
+ [4e3, 45e-20],
409
+ [4500, 13e-19],
410
+ [5e3, 4e-18]
411
+ ];
412
+ function interpolateALIGO_ASD(f) {
413
+ if (f <= ALIGO_DATA[0][0]) return ALIGO_DATA[0][1];
414
+ if (f >= ALIGO_DATA[ALIGO_DATA.length - 1][0]) return ALIGO_DATA[ALIGO_DATA.length - 1][1];
415
+ for (let i = 0; i < ALIGO_DATA.length - 1; i++) {
416
+ const [f0, a0] = ALIGO_DATA[i];
417
+ const [f1, a1] = ALIGO_DATA[i + 1];
418
+ if (f >= f0 && f <= f1) {
419
+ const t = Math.log(f / f0) / Math.log(f1 / f0);
420
+ return Math.exp(Math.log(a0) + t * Math.log(a1 / a0));
421
+ }
422
+ }
423
+ return ALIGO_DATA[ALIGO_DATA.length - 1][1];
424
+ }
425
+ function computeOptimalSNR(strain) {
426
+ const fMin = 10;
427
+ const fMax = 5e3;
428
+ let rhoSq = 0;
429
+ for (let k = 1; k < strain.frequencies.length - 1; k++) {
430
+ const f = strain.frequencies[k];
431
+ if (f < fMin || f > fMax || strain.hc[k] <= 0) continue;
432
+ const asd = interpolateALIGO_ASD(f);
433
+ const hn = Math.sqrt(f) * asd;
434
+ const ratio = strain.hc[k] / hn;
435
+ const df = strain.frequencies[k + 1] - strain.frequencies[k];
436
+ rhoSq += ratio * ratio * (df / f);
437
+ }
438
+ return Math.sqrt(rhoSq);
439
+ }
440
+
441
+ // src/core/multi-messenger.ts
442
+ var MM_DATA = {
443
+ GW170817: {
444
+ eventName: "GW170817",
445
+ emCounterpart: "GRB 170817A / AT 2017gfo (kilonova)",
446
+ emCounterparts: [
447
+ {
448
+ name: "GRB 170817A",
449
+ type: "GRB",
450
+ delaySeconds: 1.7,
451
+ observatory: "Fermi GBM / INTEGRAL SPI-ACS",
452
+ 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."
453
+ },
454
+ {
455
+ name: "AT 2017gfo",
456
+ type: "kilonova",
457
+ delaySeconds: 11 * 3600,
458
+ observatory: "Swope Telescope (Las Campanas)",
459
+ description: "The first kilonova observed with a known gravitational-wave source. Its rapid reddening revealed freshly synthesized heavy elements forged by the r-process."
460
+ },
461
+ {
462
+ name: "CXO J130948.0\u2013233120",
463
+ type: "X-ray",
464
+ delaySeconds: 9 * 86400,
465
+ observatory: "Chandra X-ray Observatory",
466
+ description: "X-ray emission appeared days after the merger, produced by the interaction of the relativistic jet with the surrounding interstellar medium."
467
+ },
468
+ {
469
+ name: "VLA J130948.0\u2013233120",
470
+ type: "radio",
471
+ delaySeconds: 16 * 86400,
472
+ observatory: "Karl G. Jansky VLA",
473
+ description: "Radio afterglow from the expanding cocoon of material around the jet, confirming the merger launched a structured relativistic outflow."
474
+ }
475
+ ],
476
+ hostGalaxy: "NGC 4993, 40 Mpc",
477
+ hostGalaxyDistanceMpc: 40,
478
+ h0Measurement: "70 +12/\u22128 km/s/Mpc",
479
+ ejectaMass: "~0.05 M\u2609",
480
+ grbDelay: "1.7 s"
481
+ }
482
+ };
483
+
484
+ // src/core/export.ts
485
+ var CATALOG_PAPERS = {
486
+ "GWTC-1": {
487
+ key: "LIGOScientific:2018mvr",
488
+ entry: `@article{LIGOScientific:2018mvr,
489
+ author = "{LIGO Scientific Collaboration and Virgo Collaboration}",
490
+ title = "{GWTC-1: A Gravitational-Wave Transient Catalog of Compact Binary Mergers Observed by LIGO and Virgo during the First and Second Observing Runs}",
491
+ journal = "Phys. Rev. X",
492
+ volume = "9",
493
+ pages = "031040",
494
+ year = "2019",
495
+ doi = "10.1103/PhysRevX.9.031040",
496
+ eprint = "1811.12907",
497
+ archivePrefix = "arXiv"
498
+ }`
499
+ },
500
+ "GWTC-2": {
501
+ key: "LIGOScientific:2020ibl",
502
+ entry: `@article{LIGOScientific:2020ibl,
503
+ author = "{LIGO Scientific Collaboration and Virgo Collaboration}",
504
+ title = "{GWTC-2: Compact Binary Coalescences Observed by LIGO and Virgo During the First Half of the Third Observing Run}",
505
+ journal = "Phys. Rev. X",
506
+ volume = "11",
507
+ pages = "021053",
508
+ year = "2021",
509
+ doi = "10.1103/PhysRevX.11.021053",
510
+ eprint = "2010.14527",
511
+ archivePrefix = "arXiv"
512
+ }`
513
+ },
514
+ "GWTC-2.1": {
515
+ key: "LIGOScientific:2021usb",
516
+ entry: `@article{LIGOScientific:2021usb,
517
+ author = "{LIGO Scientific Collaboration and Virgo Collaboration}",
518
+ 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}",
519
+ journal = "Phys. Rev. D",
520
+ volume = "109",
521
+ pages = "022001",
522
+ year = "2024",
523
+ doi = "10.1103/PhysRevD.109.022001",
524
+ eprint = "2108.01045",
525
+ archivePrefix = "arXiv"
526
+ }`
527
+ },
528
+ "GWTC-3": {
529
+ key: "LIGOScientific:2021djp",
530
+ entry: `@article{LIGOScientific:2021djp,
531
+ author = "{LIGO Scientific Collaboration and Virgo Collaboration and KAGRA Collaboration}",
532
+ title = "{GWTC-3: Compact Binary Coalescences Observed by LIGO and Virgo During the Second Part of the Third Observing Run}",
533
+ journal = "Phys. Rev. X",
534
+ volume = "13",
535
+ pages = "041039",
536
+ year = "2023",
537
+ doi = "10.1103/PhysRevX.13.041039",
538
+ eprint = "2111.03606",
539
+ archivePrefix = "arXiv"
540
+ }`
541
+ }
542
+ };
543
+ function generateParametersJSON(event) {
544
+ const type = classifyEvent(event);
545
+ const energyRadiated = event.total_mass_source - event.final_mass_source;
546
+ const params = {
547
+ event: event.commonName,
548
+ catalog: event.catalog_shortName,
549
+ type,
550
+ gps_time: event.GPS,
551
+ mass_1_source: {
552
+ value: event.mass_1_source,
553
+ lower: event.mass_1_source_lower,
554
+ upper: event.mass_1_source_upper,
555
+ unit: "M_sun"
556
+ },
557
+ mass_2_source: {
558
+ value: event.mass_2_source,
559
+ lower: event.mass_2_source_lower,
560
+ upper: event.mass_2_source_upper,
561
+ unit: "M_sun"
562
+ },
563
+ total_mass_source: { value: event.total_mass_source, unit: "M_sun" },
564
+ chirp_mass_source: {
565
+ value: event.chirp_mass_source,
566
+ lower: event.chirp_mass_source_lower,
567
+ upper: event.chirp_mass_source_upper,
568
+ unit: "M_sun"
569
+ },
570
+ final_mass_source: {
571
+ value: event.final_mass_source,
572
+ lower: event.final_mass_source_lower,
573
+ upper: event.final_mass_source_upper,
574
+ unit: "M_sun"
575
+ },
576
+ energy_radiated: { value: energyRadiated, unit: "M_sun_c2" },
577
+ luminosity_distance: {
578
+ value: event.luminosity_distance,
579
+ lower: event.luminosity_distance_lower,
580
+ upper: event.luminosity_distance_upper,
581
+ unit: "Mpc"
582
+ },
583
+ redshift: event.redshift,
584
+ chi_eff: event.chi_eff,
585
+ network_snr: event.network_matched_filter_snr,
586
+ false_alarm_rate: event.far,
587
+ p_astro: event.p_astro,
588
+ source: "GWOSC (https://gwosc.org)",
589
+ exported_by: "WarpLab (https://warplab.app)"
590
+ };
591
+ return JSON.stringify(params, null, 2);
592
+ }
593
+ function generateParametersCSV(event) {
594
+ const type = classifyEvent(event);
595
+ const energyRadiated = event.total_mass_source - event.final_mass_source;
596
+ const headers = ["parameter", "value", "lower_90", "upper_90", "unit"];
597
+ const rows = [
598
+ ["event_name", event.commonName, "", "", ""],
599
+ ["catalog", event.catalog_shortName, "", "", ""],
600
+ ["type", type, "", "", ""],
601
+ ["gps_time", event.GPS, "", "", "s"],
602
+ ["mass_1_source", event.mass_1_source, event.mass_1_source_lower, event.mass_1_source_upper, "M_sun"],
603
+ ["mass_2_source", event.mass_2_source, event.mass_2_source_lower, event.mass_2_source_upper, "M_sun"],
604
+ ["total_mass_source", event.total_mass_source, "", "", "M_sun"],
605
+ ["chirp_mass_source", event.chirp_mass_source, event.chirp_mass_source_lower, event.chirp_mass_source_upper, "M_sun"],
606
+ ["final_mass_source", event.final_mass_source, event.final_mass_source_lower, event.final_mass_source_upper, "M_sun"],
607
+ ["energy_radiated", energyRadiated, "", "", "M_sun_c2"],
608
+ ["luminosity_distance", event.luminosity_distance, event.luminosity_distance_lower, event.luminosity_distance_upper, "Mpc"],
609
+ ["redshift", event.redshift, "", "", ""],
610
+ ["chi_eff", event.chi_eff, "", "", ""],
611
+ ["network_snr", event.network_matched_filter_snr, "", "", ""],
612
+ ["false_alarm_rate", event.far, "", "", "Hz"],
613
+ ["p_astro", event.p_astro, "", "", ""]
614
+ ];
615
+ return [headers.join(","), ...rows.map((r) => r.join(","))].join("\n");
616
+ }
617
+ function generateWaveformCSV(waveform) {
618
+ const headers = ["time_s", "h_plus", "h_cross"];
619
+ const lines = [headers.join(",")];
620
+ const dt = 1 / waveform.sampleRate;
621
+ for (let i = 0; i < waveform.hPlus.length; i++) {
622
+ const t = (i * dt).toFixed(6);
623
+ lines.push(`${t},${waveform.hPlus[i].toExponential(8)},${waveform.hCross[i].toExponential(8)}`);
624
+ }
625
+ return lines.join("\n");
626
+ }
627
+ function generateBibTeX(event) {
628
+ const entries = [];
629
+ entries.push(`@misc{GWOSC,
630
+ author = "{LIGO Scientific Collaboration and Virgo Collaboration and KAGRA Collaboration}",
631
+ title = "{Gravitational Wave Open Science Center}",
632
+ howpublished = "\\url{https://gwosc.org}",
633
+ year = "2023",
634
+ note = "Event: ${event.commonName}"
635
+ }`);
636
+ const catalog = event.catalog_shortName;
637
+ const paper = CATALOG_PAPERS[catalog];
638
+ if (paper) {
639
+ entries.push(paper.entry);
640
+ }
641
+ entries.push(`@misc{WarpLab,
642
+ author = "{Canton, Daniel}",
643
+ title = "{WarpLab: Interactive Gravitational Wave Visualizer}",
644
+ howpublished = "\\url{https://warplab.app}",
645
+ year = "2025"
646
+ }`);
647
+ return entries.join("\n\n");
648
+ }
649
+ function generateNotebook(event) {
650
+ const type = classifyEvent(event);
651
+ const gps = event.GPS;
652
+ const eventName = event.commonName;
653
+ const notebook = {
654
+ nbformat: 4,
655
+ nbformat_minor: 5,
656
+ metadata: {
657
+ kernelspec: {
658
+ display_name: "Python 3",
659
+ language: "python",
660
+ name: "python3"
661
+ },
662
+ language_info: {
663
+ name: "python",
664
+ version: "3.10.0"
665
+ }
666
+ },
667
+ cells: [
668
+ {
669
+ cell_type: "markdown",
670
+ metadata: {},
671
+ source: [
672
+ `# ${eventName} \u2014 Gravitational Wave Analysis
673
+ `,
674
+ `
675
+ `,
676
+ `**Type:** ${type}
677
+ `,
678
+ `**Masses:** ${event.mass_1_source.toFixed(1)} + ${event.mass_2_source.toFixed(1)} M\u2609
679
+ `,
680
+ `**Distance:** ${event.luminosity_distance.toFixed(0)} Mpc
681
+ `,
682
+ `**Catalog:** ${event.catalog_shortName}
683
+ `,
684
+ `**GPS Time:** ${gps}
685
+ `,
686
+ `
687
+ `,
688
+ `This notebook fetches real detector strain from [GWOSC](https://gwosc.org) and reproduces the spectrogram and template overlay.
689
+ `,
690
+ `
691
+ `,
692
+ `*Exported from [WarpLab](https://warplab.app)*`
693
+ ]
694
+ },
695
+ {
696
+ cell_type: "markdown",
697
+ metadata: {},
698
+ source: ["## 1. Setup\n", "Install required packages if needed."]
699
+ },
700
+ {
701
+ cell_type: "code",
702
+ metadata: {},
703
+ source: [
704
+ `# Install dependencies (uncomment if needed)
705
+ `,
706
+ `# !pip install gwosc gwpy matplotlib numpy
707
+ `,
708
+ `
709
+ `,
710
+ `import numpy as np
711
+ `,
712
+ `import matplotlib.pyplot as plt
713
+ `,
714
+ `from gwpy.timeseries import TimeSeries
715
+ `,
716
+ `from gwosc.datasets import event_gps
717
+ `,
718
+ `
719
+ `,
720
+ `EVENT = "${eventName}"
721
+ `,
722
+ `GPS = ${gps}
723
+ `,
724
+ `DETECTOR = "H1" # Change to "L1" or "V1" for other detectors`
725
+ ],
726
+ execution_count: null,
727
+ outputs: []
728
+ },
729
+ {
730
+ cell_type: "markdown",
731
+ metadata: {},
732
+ source: [
733
+ "## 2. Fetch strain data from GWOSC\n",
734
+ "Download 32 seconds of strain centered on the event."
735
+ ]
736
+ },
737
+ {
738
+ cell_type: "code",
739
+ metadata: {},
740
+ source: [
741
+ `# Fetch 32s of strain data centered on the event
742
+ `,
743
+ `strain = TimeSeries.fetch_open_data(
744
+ `,
745
+ ` DETECTOR, GPS - 16, GPS + 16,
746
+ `,
747
+ ` cache=True
748
+ `,
749
+ `)
750
+ `,
751
+ `print(f"Sample rate: {strain.sample_rate}")
752
+ `,
753
+ `print(f"Duration: {strain.duration}")`
754
+ ],
755
+ execution_count: null,
756
+ outputs: []
757
+ },
758
+ {
759
+ cell_type: "markdown",
760
+ metadata: {},
761
+ source: [
762
+ "## 3. Q-transform spectrogram\n",
763
+ "Compute and plot the time-frequency spectrogram using a Q-transform."
764
+ ]
765
+ },
766
+ {
767
+ cell_type: "code",
768
+ metadata: {},
769
+ source: [
770
+ `# Compute Q-transform spectrogram
771
+ `,
772
+ `dt = 1 # seconds around merger to plot
773
+ `,
774
+ `qgram = strain.q_transform(
775
+ `,
776
+ ` outseg=(GPS - dt, GPS + dt),
777
+ `,
778
+ ` qrange=(4, 64),
779
+ `,
780
+ ` frange=(20, 1024),
781
+ `,
782
+ ` logf=True
783
+ `,
784
+ `)
785
+ `,
786
+ `
787
+ `,
788
+ `fig, ax = plt.subplots(figsize=(10, 5))
789
+ `,
790
+ `ax.imshow(qgram.T, origin="lower", aspect="auto",
791
+ `,
792
+ ` extent=[qgram.x0.value, (qgram.x0 + qgram.dx * qgram.shape[0]).value,
793
+ `,
794
+ ` qgram.y0.value, (qgram.y0 + qgram.dy * qgram.shape[1]).value])
795
+ `,
796
+ `ax.set_xlabel("Time [s]")
797
+ `,
798
+ `ax.set_ylabel("Frequency [Hz]")
799
+ `,
800
+ `ax.set_title(f"{EVENT} \u2014 Q-transform ({DETECTOR})")
801
+ `,
802
+ `ax.set_yscale("log")
803
+ `,
804
+ `plt.colorbar(ax.images[0], label="Normalized energy")
805
+ `,
806
+ `plt.tight_layout()
807
+ `,
808
+ `plt.show()`
809
+ ],
810
+ execution_count: null,
811
+ outputs: []
812
+ },
813
+ {
814
+ cell_type: "markdown",
815
+ metadata: {},
816
+ source: [
817
+ "## 4. Whitened strain and template overlay\n",
818
+ "Bandpass and whiten the data, then overlay the included waveform template."
819
+ ]
820
+ },
821
+ {
822
+ cell_type: "code",
823
+ metadata: {},
824
+ source: [
825
+ `# Bandpass filter and whiten
826
+ `,
827
+ `white = strain.whiten(4, 2).bandpass(30, 400)
828
+ `,
829
+ `
830
+ `,
831
+ `# Load the template waveform from the exported CSV
832
+ `,
833
+ `import os
834
+ `,
835
+ `template_file = os.path.join(
836
+ `,
837
+ ` os.path.dirname(os.path.abspath("__file__")),
838
+ `,
839
+ ` "waveform_template.csv"
840
+ `,
841
+ `)
842
+ `,
843
+ `
844
+ `,
845
+ `fig, ax = plt.subplots(figsize=(10, 4))
846
+ `,
847
+ `
848
+ `,
849
+ `# Plot whitened strain around merger
850
+ `,
851
+ `t = white.times.value - GPS
852
+ `,
853
+ `mask = (t > -0.5) & (t < 0.2)
854
+ `,
855
+ `ax.plot(t[mask], white.value[mask], label=f"{DETECTOR} whitened", alpha=0.8)
856
+ `,
857
+ `
858
+ `,
859
+ `# Overlay template if available
860
+ `,
861
+ `if os.path.exists(template_file):
862
+ `,
863
+ ` template = np.genfromtxt(template_file, delimiter=",",
864
+ `,
865
+ ` names=True, dtype=None, encoding="utf-8")
866
+ `,
867
+ ` t_templ = template["time_s"]
868
+ `,
869
+ ` h_templ = template["h_plus"]
870
+ `,
871
+ ` # Center template on t=0 at peak
872
+ `,
873
+ ` peak_idx = np.argmax(np.abs(h_templ))
874
+ `,
875
+ ` t_templ = t_templ - t_templ[peak_idx]
876
+ `,
877
+ ` # Scale template to match whitened strain amplitude
878
+ `,
879
+ ` scale = np.max(np.abs(white.value[mask])) / np.max(np.abs(h_templ))
880
+ `,
881
+ ` ax.plot(t_templ, h_templ * scale, "--", label="Template (h+)",
882
+ `,
883
+ ` alpha=0.7, color="tab:orange")
884
+ `,
885
+ `
886
+ `,
887
+ `ax.set_xlabel("Time relative to merger [s]")
888
+ `,
889
+ `ax.set_ylabel("Strain (whitened)")
890
+ `,
891
+ `ax.set_title(f"{EVENT} \u2014 Whitened strain with template overlay")
892
+ `,
893
+ `ax.legend()
894
+ `,
895
+ `plt.tight_layout()
896
+ `,
897
+ `plt.show()`
898
+ ],
899
+ execution_count: null,
900
+ outputs: []
901
+ },
902
+ {
903
+ cell_type: "markdown",
904
+ metadata: {},
905
+ source: [
906
+ "## 5. Event parameters\n",
907
+ "Summary of parameters from the GWTC catalog."
908
+ ]
909
+ },
910
+ {
911
+ cell_type: "code",
912
+ metadata: {},
913
+ source: [
914
+ `# Event parameters from ${event.catalog_shortName}
915
+ `,
916
+ `params = {
917
+ `,
918
+ ` "Event": "${eventName}",
919
+ `,
920
+ ` "Type": "${type}",
921
+ `,
922
+ ` "m1 [M\u2609]": ${event.mass_1_source.toFixed(2)},
923
+ `,
924
+ ` "m2 [M\u2609]": ${event.mass_2_source.toFixed(2)},
925
+ `,
926
+ ` "M_total [M\u2609]": ${event.total_mass_source.toFixed(2)},
927
+ `,
928
+ ` "M_chirp [M\u2609]": ${event.chirp_mass_source.toFixed(2)},
929
+ `,
930
+ ` "M_final [M\u2609]": ${event.final_mass_source.toFixed(2)},
931
+ `,
932
+ ` "Distance [Mpc]": ${event.luminosity_distance.toFixed(1)},
933
+ `,
934
+ ` "Redshift": ${event.redshift.toFixed(4)},
935
+ `,
936
+ ` "\u03C7_eff": ${event.chi_eff.toFixed(3)},
937
+ `,
938
+ ` "SNR": ${event.network_matched_filter_snr.toFixed(1)},
939
+ `,
940
+ ` "p_astro": ${event.p_astro.toFixed(4)},
941
+ `,
942
+ `}
943
+ `,
944
+ `
945
+ `,
946
+ `for k, v in params.items():
947
+ `,
948
+ ` print(f"{k:20s} {v}")`
949
+ ],
950
+ execution_count: null,
951
+ outputs: []
952
+ }
953
+ ]
954
+ };
955
+ return JSON.stringify(notebook, null, 1);
956
+ }
957
+ function generateREADME(event) {
958
+ const type = classifyEvent(event);
959
+ return `# ${event.commonName} \u2014 Data Export
960
+
961
+ Type: ${type}
962
+ Masses: ${event.mass_1_source.toFixed(1)} + ${event.mass_2_source.toFixed(1)} M\u2609
963
+ Distance: ${event.luminosity_distance.toFixed(0)} Mpc
964
+ Catalog: ${event.catalog_shortName}
965
+
966
+ ## Files
967
+
968
+ - **parameters.json** \u2014 Full event parameters with uncertainties (JSON)
969
+ - **parameters.csv** \u2014 Same parameters in tabular CSV format
970
+ - **waveform_template.csv** \u2014 Synthetic IMRPhenom waveform: h+(t) and h\xD7(t) arrays
971
+ - **notebook.ipynb** \u2014 Jupyter notebook that fetches real strain from GWOSC and reproduces the analysis
972
+ - **CITATION.bib** \u2014 BibTeX citations for GWOSC, the catalog paper, and WarpLab
973
+
974
+ ## Using the notebook
975
+
976
+ 1. Install dependencies: \`pip install gwosc gwpy matplotlib numpy\`
977
+ 2. Open \`notebook.ipynb\` in JupyterLab or VS Code
978
+ 3. Run all cells \u2014 it will download real detector strain from GWOSC
979
+ 4. The notebook produces a Q-transform spectrogram and a whitened strain plot with the template overlay
980
+
981
+ ## Data source
982
+
983
+ All parameters are from the Gravitational Wave Open Science Center (GWOSC):
984
+ https://gwosc.org
985
+
986
+ The waveform template is a simplified IMRPhenom analytical approximation generated by WarpLab.
987
+ It is NOT a full numerical relativity waveform. For research use, fetch real strain from GWOSC.
988
+
989
+ ## Citation
990
+
991
+ If you use this data in academic work, please cite the sources in CITATION.bib.
992
+
993
+ ---
994
+ Exported from WarpLab (https://warplab.app)
995
+ `;
996
+ }
997
+
998
+ // server/cli.ts
999
+ var args = process.argv.slice(2);
1000
+ var command = args[0];
1001
+ function flag(name) {
1002
+ const idx = args.indexOf(`--${name}`);
1003
+ if (idx === -1) return void 0;
1004
+ return args[idx + 1];
1005
+ }
1006
+ function hasFlag(name) {
1007
+ return args.includes(`--${name}`);
1008
+ }
1009
+ var catalogCache = null;
1010
+ async function getCatalog() {
1011
+ if (!catalogCache) {
1012
+ process.stderr.write("Fetching catalog from GWOSC...\n");
1013
+ catalogCache = await fetchEventCatalog();
1014
+ process.stderr.write(`Loaded ${catalogCache.length} events.
1015
+ `);
1016
+ }
1017
+ return catalogCache;
1018
+ }
1019
+ function findEvent(catalog, name) {
1020
+ const lower = name.toLowerCase();
1021
+ return catalog.find((e) => e.commonName.toLowerCase() === lower);
1022
+ }
1023
+ function printJSON(obj) {
1024
+ console.log(JSON.stringify(obj, null, 2));
1025
+ }
1026
+ async function main() {
1027
+ if (!command || command === "help" || command === "--help") {
1028
+ console.log(`WarpLab CLI \u2014 Gravitational Wave Physics Tools
1029
+
1030
+ Usage: warplab <command> [options]
1031
+
1032
+ Commands:
1033
+ search Search events [--type BBH|BNS|NSBH] [--mass-min N] [--mass-max N] [--snr-min N] [--limit N]
1034
+ info Get event details <event-name>
1035
+ waveform Generate waveform <event-name> | --m1 N --m2 N [--format json|csv]
1036
+ qnm Compute QNM frequencies <event-name> | --m1 N --m2 N
1037
+ snr Compute optimal SNR <event-name> | --m1 N --m2 N
1038
+ export Export event data <event-name> [--format json|csv|bibtex|notebook|readme|waveform]
1039
+ stats Population statistics [--stat mass|chirp_mass|spin|distance|type_counts]
1040
+ help Show this help
1041
+ `);
1042
+ return;
1043
+ }
1044
+ if (command === "search") {
1045
+ const catalog = await getCatalog();
1046
+ let results = catalog;
1047
+ const type = flag("type");
1048
+ if (type) results = results.filter((e) => classifyEvent(e) === type);
1049
+ const massMin = flag("mass-min");
1050
+ if (massMin) results = results.filter((e) => e.total_mass_source >= +massMin);
1051
+ const massMax = flag("mass-max");
1052
+ if (massMax) results = results.filter((e) => e.total_mass_source <= +massMax);
1053
+ const snrMin = flag("snr-min");
1054
+ if (snrMin) results = results.filter((e) => e.network_matched_filter_snr >= +snrMin);
1055
+ const limit = +(flag("limit") ?? "20");
1056
+ const limited = results.slice(0, limit);
1057
+ if (hasFlag("table")) {
1058
+ console.log("Name Type m1 m2 Dist(Mpc) SNR");
1059
+ console.log("\u2500".repeat(65));
1060
+ for (const e of limited) {
1061
+ console.log(
1062
+ `${e.commonName.padEnd(18)}${classifyEvent(e).padEnd(6)}${e.mass_1_source.toFixed(1).padStart(6)} ${e.mass_2_source.toFixed(1).padStart(6)} ${e.luminosity_distance.toFixed(0).padStart(9)} ${e.network_matched_filter_snr.toFixed(1).padStart(5)}`
1063
+ );
1064
+ }
1065
+ console.log(`
1066
+ ${results.length} total, ${limited.length} shown`);
1067
+ } else {
1068
+ printJSON(limited.map((e) => ({
1069
+ name: e.commonName,
1070
+ type: classifyEvent(e),
1071
+ m1: e.mass_1_source,
1072
+ m2: e.mass_2_source,
1073
+ distance_Mpc: e.luminosity_distance,
1074
+ snr: e.network_matched_filter_snr
1075
+ })));
1076
+ }
1077
+ return;
1078
+ }
1079
+ if (command === "info") {
1080
+ const name = args[1];
1081
+ if (!name) {
1082
+ console.error("Usage: warplab info <event-name>");
1083
+ process.exit(1);
1084
+ }
1085
+ const catalog = await getCatalog();
1086
+ const event = findEvent(catalog, name);
1087
+ if (!event) {
1088
+ console.error(`Event "${name}" not found.`);
1089
+ process.exit(1);
1090
+ }
1091
+ printJSON({
1092
+ name: event.commonName,
1093
+ type: classifyEvent(event),
1094
+ catalog: event.catalog_shortName,
1095
+ m1: event.mass_1_source,
1096
+ m2: event.mass_2_source,
1097
+ total_mass: event.total_mass_source,
1098
+ chirp_mass: event.chirp_mass_source,
1099
+ final_mass: event.final_mass_source,
1100
+ distance_Mpc: event.luminosity_distance,
1101
+ redshift: event.redshift,
1102
+ chi_eff: event.chi_eff,
1103
+ snr: event.network_matched_filter_snr,
1104
+ p_astro: event.p_astro
1105
+ });
1106
+ return;
1107
+ }
1108
+ if (command === "waveform") {
1109
+ const format = flag("format") ?? "json";
1110
+ let waveform;
1111
+ if (args[1] && !args[1].startsWith("--")) {
1112
+ const catalog = await getCatalog();
1113
+ const event = findEvent(catalog, args[1]);
1114
+ if (!event) {
1115
+ console.error(`Event "${args[1]}" not found.`);
1116
+ process.exit(1);
1117
+ }
1118
+ waveform = generateWaveform(event);
1119
+ } else {
1120
+ const m1 = flag("m1");
1121
+ const m2 = flag("m2");
1122
+ if (!m1 || !m2) {
1123
+ console.error("Usage: warplab waveform <event> OR --m1 N --m2 N");
1124
+ process.exit(1);
1125
+ }
1126
+ waveform = generateCustomWaveform({
1127
+ m1: +m1,
1128
+ m2: +m2,
1129
+ chi1: +(flag("chi1") ?? "0"),
1130
+ chi2: +(flag("chi2") ?? "0"),
1131
+ distance: +(flag("distance") ?? "100"),
1132
+ inclination: +(flag("inclination") ?? "0")
1133
+ });
1134
+ }
1135
+ if (format === "csv") {
1136
+ console.log(generateWaveformCSV(waveform));
1137
+ } else {
1138
+ printJSON({
1139
+ event: waveform.eventName,
1140
+ sample_rate: waveform.sampleRate,
1141
+ duration: waveform.duration,
1142
+ num_samples: waveform.hPlus.length
1143
+ });
1144
+ }
1145
+ return;
1146
+ }
1147
+ if (command === "qnm") {
1148
+ let m1, m2, chi1 = 0, chi2 = 0;
1149
+ if (args[1] && !args[1].startsWith("--")) {
1150
+ const catalog = await getCatalog();
1151
+ const event = findEvent(catalog, args[1]);
1152
+ if (!event) {
1153
+ console.error(`Event "${args[1]}" not found.`);
1154
+ process.exit(1);
1155
+ }
1156
+ m1 = event.mass_1_source;
1157
+ m2 = event.mass_2_source;
1158
+ chi1 = event.chi_eff * (m1 + m2) / (2 * m1);
1159
+ } else {
1160
+ if (!flag("m1") || !flag("m2")) {
1161
+ console.error("Usage: warplab qnm <event> OR --m1 N --m2 N");
1162
+ process.exit(1);
1163
+ }
1164
+ m1 = +flag("m1");
1165
+ m2 = +flag("m2");
1166
+ chi1 = +(flag("chi1") ?? "0");
1167
+ chi2 = +(flag("chi2") ?? "0");
1168
+ }
1169
+ const modes = computeQNMModes(m1, m2, chi1, chi2);
1170
+ printJSON(modes.map((m) => ({
1171
+ mode: m.label,
1172
+ frequency_Hz: +m.frequency.toFixed(2),
1173
+ damping_time_ms: +(m.dampingTime * 1e3).toFixed(3),
1174
+ quality_factor: +m.qualityFactor.toFixed(2)
1175
+ })));
1176
+ return;
1177
+ }
1178
+ if (command === "snr") {
1179
+ let waveform;
1180
+ if (args[1] && !args[1].startsWith("--")) {
1181
+ const catalog = await getCatalog();
1182
+ const event = findEvent(catalog, args[1]);
1183
+ if (!event) {
1184
+ console.error(`Event "${args[1]}" not found.`);
1185
+ process.exit(1);
1186
+ }
1187
+ waveform = generateWaveform(event);
1188
+ } else {
1189
+ if (!flag("m1") || !flag("m2")) {
1190
+ console.error("Usage: warplab snr <event> OR --m1 N --m2 N");
1191
+ process.exit(1);
1192
+ }
1193
+ waveform = generateCustomWaveform({
1194
+ m1: +flag("m1"),
1195
+ m2: +flag("m2"),
1196
+ chi1: 0,
1197
+ chi2: 0,
1198
+ distance: +(flag("distance") ?? "100"),
1199
+ inclination: 0
1200
+ });
1201
+ }
1202
+ const strain = computeCharacteristicStrain(waveform);
1203
+ const snr = computeOptimalSNR(strain);
1204
+ printJSON({ event: waveform.eventName, optimal_snr: +snr.toFixed(2) });
1205
+ return;
1206
+ }
1207
+ if (command === "export") {
1208
+ const name = args[1];
1209
+ if (!name) {
1210
+ console.error("Usage: warplab export <event-name> [--format json|csv|bibtex|notebook|readme|waveform]");
1211
+ process.exit(1);
1212
+ }
1213
+ const catalog = await getCatalog();
1214
+ const event = findEvent(catalog, name);
1215
+ if (!event) {
1216
+ console.error(`Event "${name}" not found.`);
1217
+ process.exit(1);
1218
+ }
1219
+ const format = flag("format") ?? "json";
1220
+ switch (format) {
1221
+ case "json":
1222
+ console.log(generateParametersJSON(event));
1223
+ break;
1224
+ case "csv":
1225
+ console.log(generateParametersCSV(event));
1226
+ break;
1227
+ case "bibtex":
1228
+ console.log(generateBibTeX(event));
1229
+ break;
1230
+ case "notebook":
1231
+ console.log(generateNotebook(event));
1232
+ break;
1233
+ case "readme":
1234
+ console.log(generateREADME(event));
1235
+ break;
1236
+ case "waveform":
1237
+ console.log(generateWaveformCSV(generateWaveform(event)));
1238
+ break;
1239
+ default:
1240
+ console.error(`Unknown format: ${format}`);
1241
+ process.exit(1);
1242
+ }
1243
+ return;
1244
+ }
1245
+ if (command === "stats") {
1246
+ const stat = flag("stat") ?? "type_counts";
1247
+ const catalog = await getCatalog();
1248
+ if (stat === "type_counts") {
1249
+ const counts = { BBH: 0, BNS: 0, NSBH: 0 };
1250
+ for (const e of catalog) {
1251
+ const t = classifyEvent(e);
1252
+ if (t in counts) counts[t]++;
1253
+ }
1254
+ printJSON({ total: catalog.length, ...counts });
1255
+ } else if (stat === "mass") {
1256
+ const masses = catalog.map((e) => e.total_mass_source).filter((m) => m > 0).sort((a, b) => a - b);
1257
+ printJSON({ count: masses.length, min: +masses[0].toFixed(1), max: +masses[masses.length - 1].toFixed(1), median: +masses[Math.floor(masses.length / 2)].toFixed(1) });
1258
+ } else if (stat === "chirp_mass") {
1259
+ const mc = catalog.map((e) => e.chirp_mass_source).filter((m) => m > 0).sort((a, b) => a - b);
1260
+ printJSON({ count: mc.length, min: +mc[0].toFixed(2), max: +mc[mc.length - 1].toFixed(2), median: +mc[Math.floor(mc.length / 2)].toFixed(2) });
1261
+ } else if (stat === "spin") {
1262
+ const spins = catalog.map((e) => e.chi_eff).sort((a, b) => a - b);
1263
+ printJSON({ count: spins.length, min: +spins[0].toFixed(3), max: +spins[spins.length - 1].toFixed(3), median: +spins[Math.floor(spins.length / 2)].toFixed(3) });
1264
+ } else if (stat === "distance") {
1265
+ const dists = catalog.map((e) => e.luminosity_distance).filter((d) => d > 0).sort((a, b) => a - b);
1266
+ printJSON({ count: dists.length, min_Mpc: +dists[0].toFixed(0), max_Mpc: +dists[dists.length - 1].toFixed(0), median_Mpc: +dists[Math.floor(dists.length / 2)].toFixed(0) });
1267
+ }
1268
+ return;
1269
+ }
1270
+ console.error(`Unknown command: ${command}. Run "warplab help" for usage.`);
1271
+ process.exit(1);
1272
+ }
1273
+ main().catch((err) => {
1274
+ console.error(err);
1275
+ process.exit(1);
1276
+ });