Intro to Waveforms

This tutorial demonstrates how to use PyART’s waveform class

%matplotlib inline
%config InlineBackend.figure_format = 'retina'

import numpy as np
import matplotlib.pyplot as plt
from matplotlib import rcParams
import seaborn as sns

# logging configuration, default INFO level
from PyART.logging_config import setup_logging
setup_logging()

# Set up Seaborn aesthetics
sns.set_context('talk') 
sns.set_theme(font_scale=1.2)
sns.set_style('ticks')

# Update matplotlib rcParams
rcParams.update(
    {
        'text.usetex': False,
        'font.family': 'stixgeneral',
        'mathtext.fontset': 'stix',
        'axes.grid': True,
        'grid.linestyle': ':',
        'grid.color': '#bbbbbb',
        'axes.linewidth': 1,
    }
)

We now create an empty Waveform object and inspect its attributes. This class is inherited by all waveform catalogs and models, meaning that they will all have these attributes and methods.
For more information, see the Waveform class documentation.

from PyART.waveform import Waveform

# Create an empty Waveform object
waveform = Waveform()

attributes = [attr for attr in dir(waveform) if not attr.startswith('_') and not callable(getattr(waveform, attr))]
methods = [method for method in dir(waveform) if not method.startswith('_') and callable(getattr(waveform, method))]

print("Attributes of the Waveform object:")
for attr in attributes:
    print(f"- {attr}")

print("\nMethods of the Waveform object:")
for method in methods:
    print(f"- {method}")
Attributes of the Waveform object:
- domain
- dothlm
- dyn
- f
- hc
- hlm
- hp
- kind
- psi4lm
- t
- t_psi4
- u
- units

Methods of the Waveform object:
- compute_dothlm
- compute_hphc
- compute_psi4lm
- cut
- dynamics_from_hlm
- ej_from_hlm
- find_max
- integrate_data
- interpolate_hlm
- phase_shift
- to_SI
- to_frequency
- to_geom
- to_time

We now load a waveform from the SXS catalog and plot its modes \(h_{\ell m}\).
They are stored as a dictionary of the form: hlm[(l,m)]. Each dictionary entry has the amplitude: A and phase p of the mode, as well as the real and imag part of \(h_{\ell,m}\).

Note that, by default, the modes are not interpolated to a uniform time grid. Therefore, before plotting, we:

  • Interpolate to a uniform time grid with waveform.interpolate_hlm

  • Shift the time array so that the (2,2) mode peaks at \(t=0M\), using the find_max method.

from PyART.catalogs import sxs

# Utility function to check if an array is uniformly spaced
def is_uniform(arr, rtol=1e-10, atol=1e-12):
    delta = np.diff(arr)
    return np.allclose(delta, delta[0], rtol=rtol, atol=atol)

sxs_waveform = sxs.Waveform_SXS(ID='0180',
                                download=True,
                                ignore_deprecation=True,
                                downloads=["hlm", "metadata", "horizons"],
                                load=["hlm", "metadata", "horizons"]
                                )


# verify that the time array is uniform
print("Is the time array uniform?", is_uniform(sxs_waveform.u))

# interpolate to uniform time gri, dT = 1M
t_interp, hlm_interp = sxs_waveform.interpolate_hlm(1)

# overwrite time array and modes in the waveform object
sxs_waveform._t = t_interp
sxs_waveform._u = t_interp
sxs_waveform._hlm = hlm_interp

# verify that the time array is uniform
print("Is the time array uniform?", is_uniform(sxs_waveform.u))

t_max, _, _, _, mrg_idx = sxs_waveform.find_max(return_idx=True, mode=(2,2))

fig, ax = plt.subplots()
modes = [(2, 2), (2, 1), (3, 3), (4, 4), (2, -2), (2, -1), (3, -3), (4, -4)]
for mode in modes:
    ax.semilogy(sxs_waveform.u-t_max, sxs_waveform.hlm[mode]['A'], label=f'{mode}')

# legend, out of box
ax.legend(ncol=1, bbox_to_anchor=(1.05, 1), loc='upper left')
ax.axvline(0, color='k', linestyle='--', alpha=0.5)
ax.set_xlabel('t/M')
ax.set_ylabel(r'$h_{\ell m}/\nu$')
plt.show()
2026-06-17 15:27:05 The path ../dat/SXS/SXS_BBH_0180 does not exist or contains no 'Lev*' directory.
2026-06-17 15:27:05 Downloading the simulation from the SXS catalog.
2026-06-17 15:27:06 Setting the download (cache) directory to ../dat/SXS/
2026-06-17 15:27:17 Loaded SXS simulation SXS:BBH:0180.
2026-06-17 15:27:30 Saved hlm data.
2026-06-17 15:27:34 Saved horizons data.
2026-06-17 15:27:34 Saved metadata.
Is the time array uniform? False
Is the time array uniform? True
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
File /opt/hostedtoolcache/Python/3.11.15/x64/lib/python3.11/site-packages/PIL/ImageFile.py:663, in _save(im, fp, tile, bufsize)
    662 try:
--> 663     fh = fp.fileno()
    664     fp.flush()

AttributeError: '_idat' object has no attribute 'fileno'

During handling of the above exception, another exception occurred:

KeyboardInterrupt                         Traceback (most recent call last)
Cell In[3], line 42
     38 ax.legend(ncol=1, bbox_to_anchor=(1.05, 1), loc='upper left')
     39 ax.axvline(0, color='k', linestyle='--', alpha=0.5)
     40 ax.set_xlabel('t/M')
     41 ax.set_ylabel(r'$h_{\ell m}/\nu$')
---> 42 plt.show()

File /opt/hostedtoolcache/Python/3.11.15/x64/lib/python3.11/site-packages/matplotlib/pyplot.py:646, in show(*args, **kwargs)
    602 """
    603 Display all open figures.
    604 
   (...)    643 explicitly there.
    644 """
    645 _warn_if_gui_out_of_main_thread()
--> 646 return _get_backend_mod().show(*args, **kwargs)

File /opt/hostedtoolcache/Python/3.11.15/x64/lib/python3.11/site-packages/matplotlib_inline/backend_inline.py:90, in show(close, block)
     88 try:
     89     for figure_manager in Gcf.get_all_fig_managers():
---> 90         display(
     91             figure_manager.canvas.figure,
     92             metadata=_fetch_figure_metadata(figure_manager.canvas.figure),
     93         )
     94 finally:
     95     show._to_draw = []

File /opt/hostedtoolcache/Python/3.11.15/x64/lib/python3.11/site-packages/decorator/__init__.py:247, in decorate.<locals>.fun(*args, **kw)
    245 if not kwsyntax:
    246     args, kw = fix(args, kw, sig)
--> 247 return caller(func, *(extras + args), **kw)

File /opt/hostedtoolcache/Python/3.11.15/x64/lib/python3.11/site-packages/matplotlib/backend_bases.py:2281, in FigureCanvasBase.print_figure(self, filename, dpi, facecolor, edgecolor, orientation, format, bbox_inches, pad_inches, bbox_extra_artists, backend, **kwargs)
   2277 try:
   2278     # _get_renderer may change the figure dpi (as vector formats
   2279     # force the figure dpi to 72), so we need to set it again here.
   2280     with cbook._setattr_cm(self.figure, dpi=dpi):
-> 2281         result = print_method(
   2282             filename,
   2283             facecolor=facecolor,
   2284             edgecolor=edgecolor,
   2285             orientation=orientation,
   2286             bbox_inches_restore=_bbox_inches_restore,
   2287             **kwargs)
   2288 finally:
   2289     if bbox_inches and restore_bbox:

File /opt/hostedtoolcache/Python/3.11.15/x64/lib/python3.11/site-packages/matplotlib/backend_bases.py:2138, in FigureCanvasBase._switch_canvas_and_return_print_method.<locals>.<lambda>(*args, **kwargs)
   2134     optional_kws = {  # Passed by print_figure for other renderers.
   2135         "dpi", "facecolor", "edgecolor", "orientation",
   2136         "bbox_inches_restore"}
   2137     skip = optional_kws - {*inspect.signature(meth).parameters}
-> 2138     print_method = functools.wraps(meth)(lambda *args, **kwargs: meth(
   2139         *args, **{k: v for k, v in kwargs.items() if k not in skip}))
   2140 else:  # Let third-parties do as they see fit.
   2141     print_method = meth

File /opt/hostedtoolcache/Python/3.11.15/x64/lib/python3.11/site-packages/matplotlib/backends/backend_agg.py:537, in FigureCanvasAgg.print_png(self, filename_or_obj, metadata, pil_kwargs)
    490 def print_png(self, filename_or_obj, *, metadata=None, pil_kwargs=None):
    491     """
    492     Write the figure to a PNG file.
    493 
   (...)    535         *metadata*, including the default 'Software' key.
    536     """
--> 537     self._print_pil(filename_or_obj, "png", pil_kwargs, metadata)

File /opt/hostedtoolcache/Python/3.11.15/x64/lib/python3.11/site-packages/matplotlib/backends/backend_agg.py:486, in FigureCanvasAgg._print_pil(self, filename_or_obj, fmt, pil_kwargs, metadata)
    481 """
    482 Draw the canvas, then save it using `.image.imsave` (to which
    483 *pil_kwargs* and *metadata* are forwarded).
    484 """
    485 FigureCanvasAgg.draw(self)
--> 486 mpl.image.imsave(
    487     filename_or_obj, self.buffer_rgba(), format=fmt, origin="upper",
    488     dpi=self.figure.dpi, metadata=metadata, pil_kwargs=pil_kwargs)

File /opt/hostedtoolcache/Python/3.11.15/x64/lib/python3.11/site-packages/matplotlib/image.py:1722, in imsave(fname, arr, vmin, vmax, cmap, format, origin, dpi, metadata, pil_kwargs)
   1720 pil_kwargs.setdefault("format", format)
   1721 pil_kwargs.setdefault("dpi", (dpi, dpi))
-> 1722 image.save(fname, **pil_kwargs)

File /opt/hostedtoolcache/Python/3.11.15/x64/lib/python3.11/site-packages/PIL/Image.py:2713, in Image.save(self, fp, format, **params)
   2710     fp = cast(IO[bytes], fp)
   2712 try:
-> 2713     save_handler(self, fp, filename)
   2714 except Exception:
   2715     if open_fp:

File /opt/hostedtoolcache/Python/3.11.15/x64/lib/python3.11/site-packages/PIL/PngImagePlugin.py:1507, in _save(im, fp, filename, chunk, save_all)
   1505 if single_im:
   1506     _apply_encoderinfo(single_im, im.encoderinfo)
-> 1507     ImageFile._save(
   1508         single_im,
   1509         cast(IO[bytes], _idat(fp, chunk)),
   1510         [ImageFile._Tile("zip", (0, 0) + single_im.size, 0, rawmode)],
   1511     )
   1513 if info:
   1514     for info_chunk in info.chunks:

File /opt/hostedtoolcache/Python/3.11.15/x64/lib/python3.11/site-packages/PIL/ImageFile.py:667, in _save(im, fp, tile, bufsize)
    665     _encode_tile(im, fp, tile, bufsize, fh)
    666 except (AttributeError, io.UnsupportedOperation) as exc:
--> 667     _encode_tile(im, fp, tile, bufsize, None, exc)
    668 if hasattr(fp, "flush"):
    669     fp.flush()

File /opt/hostedtoolcache/Python/3.11.15/x64/lib/python3.11/site-packages/PIL/ImageFile.py:693, in _encode_tile(im, fp, tile, bufsize, fh, exc)
    690 if exc:
    691     # compress to Python file-compatible object
    692     while True:
--> 693         errcode, data = encoder.encode(bufsize)[1:]
    694         fp.write(data)
    695         if errcode:

KeyboardInterrupt: 

We now use the modes to compute the energy and angular momentum radiated as a function of time. To do so we use the ej_from_hlm method of the Waveform class.

Note that this method requires knowledge of the initial ADM mass and angular momentum of the system, which are stored as attributes of the Waveform object (and automatically loaded, for most catalogs).

M_adm_0 = sxs_waveform.metadata['E0byM']
J_adm_0 = sxs_waveform.metadata['Jz0']
m1 = sxs_waveform.metadata['m1']
m2 = sxs_waveform.metadata['m2']

eb, e, jorb = sxs_waveform.ej_from_hlm(M_adm_0, J_adm_0, m1, m2, modes=modes)

# identify merger
e_mrg = eb[mrg_idx]
j_mrg = jorb[mrg_idx]

fig, ax = plt.subplots()
ax.plot(jorb, eb)
ax.set_xlabel(r'$J/M^2$')
ax.set_ylabel(r'$E_b/M$')
ax.scatter(j_mrg, e_mrg, color='k', label='Merger')
ax.set_title('Binding energy vs Angular Momentum')
plt.show()

We will now look at the dynamics. This is not available for all catalogs/models. In the case of SXS waveforms, we do load Horizon data when we download the waveform and can access it.

# plot the mass / spin evolution
fig, axs = plt.subplots(2, 1, figsize=(12, 8), sharex=True)
axs[0].semilogy(sxs_waveform.dyn['t'], abs(sxs_waveform.dyn['m1']-m1), label=r'$m_1$')
axs[0].semilogy(sxs_waveform.dyn['t'], abs(sxs_waveform.dyn['m2']-m2), label=r'$m_2$', linestyle='--')
axs[1].semilogy(sxs_waveform.dyn['t'], sxs_waveform.dyn['chi1'], label=r'S_1')
axs[1].semilogy(sxs_waveform.dyn['t'], sxs_waveform.dyn['chi2'], label=r'S_2', linestyle='--')
axs[0].set_ylabel(r'$\Delta m_i$')
axs[1].set_ylabel(r'$|S_i|$')
axs[1].set_xlabel('t/M')
axs[0].legend()
plt.show()


# plot the trajectories
xA = sxs_waveform.dyn['x1']
xB = sxs_waveform.dyn['x2']
fig, ax = plt.subplots(figsize=(8, 8))
ax.plot(xA[:, 0], xA[:, 1], label='BH 1')
ax.plot(xB[:, 0], xB[:, 1], label='BH 2')
ax.set_xlabel('x/M')
ax.set_ylabel('y/M')
ax.set_title('Black Hole Trajectories')
ax.legend()
plt.show()