Hierarchical BH Mergers in Dense Star Clusters

Demonstrates HierarchicalMergersInClusterPopulation (Section III.C) from Islam, Wadekar & Kritos (2026, arXiv:2603.10170).

[1]:
import warnings
warnings.filterwarnings('ignore', 'Wswiglal-redir-stdio')

from gwGenealogy.core import HierarchicalMergersInClusterPopulation
from gwGenealogy.utils import set_rcparams
import matplotlib.pyplot as plt
import numpy as np

set_rcparams()
lal.MSUN_SI != Msun

2. Hierarchical Mergers Across Generations

Population-level simulation: 1G BHs merge pairwise to build 2G, 3G, etc. Generation convention: remnant_gen = max(parent_gen) + 1.

[2]:
sim = HierarchicalMergersInClusterPopulation(
    n_samples=5000, chi_max=0.2, m_min=3, m_max=60, imf='uniform',
    pairing='random', kick_model='hlz', seed=42)
print(sim)
data = sim.simulate(verbose=True)
HierarchicalMergersInClusterPopulation(n=5000, m=[3.0,60.0], IMF=uniform, chi_max=0.2, v_esc=uniform, pairing=random, kick=hlz)
1g: 5000 BHs (m=[3.0,60.0] Msun, IMF=uniform, chi~U(0,0.2), v_esc~U(1.0,300.0))
2g: 1168 retained, m_med=60.7 Msun, chi_med=0.64
3g: 202 retained, m_med=86.6 Msun, chi_med=0.57
4g: 35 retained, m_med=100.7 Msun, chi_med=0.45
5g: 6 retained, m_med=142.9 Msun, chi_med=0.63
[3]:
fig, axes = plt.subplots(1, 2, figsize=(12, 5))
colors = ['C0', 'C1', 'C2', 'C3', 'C4']

for g in range(1, sim.max_gen + 1):
    m = data[g]['m']
    s = data[g]['spin']
    if len(m) == 0:
        continue
    axes[0].hist(m, bins=30, alpha=0.5, color=colors[g-1], label=f'{g}G ({len(m)})', density=False)
    axes[1].hist(s, bins=30, alpha=0.5, color=colors[g-1], label=f'{g}G', density=False)

axes[0].set_xlabel('Mass [$M_\\odot$]')
axes[0].set_ylabel('Density')
axes[0].set_title('Mass distribution by generation')
axes[0].legend()

axes[1].set_xlabel('Spin $\\chi$')
axes[1].set_ylabel('Density')
axes[1].set_title('Spin distribution by generation')
axes[1].legend()

plt.tight_layout()
plt.show()
../_images/notebooks_08_hierarchical_mergers_4_0.png

Pairing model comparison

[4]:
results = {}
for pairing in ['random', 'secondary_mass_power_law', 'total_mass_power_law']:
    sim_p = HierarchicalMergersInClusterPopulation(
        n_samples=50000, chi_max=0.2, pairing=pairing, kick_model='hlz', seed=42)
    results[pairing] = sim_p.simulate(verbose=False)
    counts = [len(results[pairing][g]['m']) for g in range(1, 6)]
    print(f"{pairing:30s}: {counts}")
random                        : [50000, 11798, 1974, 400, 92]
secondary_mass_power_law      : [50000, 13443, 1114, 99, 12]
total_mass_power_law          : [50000, 11972, 1607, 225, 45]
[5]:
fig, axes = plt.subplots(1, 3, figsize=(16, 5))
labels = {'random': 'Random', 'secondary_mass_power_law': 'Model B ($m_2^{6.7}$)',
          'total_mass_power_law': 'Fragione ($(m_1+m_2)^4$)'}

for ax, g in zip(axes, [2, 3, 4]):
    for pairing, col in zip(results, ['C0', 'C1', 'C2']):
        m = results[pairing][g]['m']
        if len(m) > 0:
            ax.hist(m, bins=30, alpha=0.5, color=col, label=labels[pairing], density=True)
    ax.set_xlabel('Mass [$M_\\odot$]')
    ax.set_ylabel('Density')
    ax.set_title(f'{g}G mass distribution')
    ax.legend(fontsize=8)

plt.tight_layout()
plt.show()
../_images/notebooks_08_hierarchical_mergers_7_0.png

Evolving escape velocity

Clusters lose mass over time, so \(v_{esc}\) decreases with generation. With evolve_v_esc=True, sampled escape velocities are scaled as \(v_{esc}(g) = v_{esc,\mathrm{sampled}} \cdot g^{\alpha}\) where \(\alpha = -0.35\) by default. Compare constant vs evolving \(v_{esc}\).

[6]:
n_seeds = 20
max_gen = 5
common_hier_kw = dict(n_samples=15000, chi_max=0.2, m_min=3, m_max=60,
                      imf='uniform', pairing='random', kick_model='hlz')

counts_const = np.zeros((n_seeds, max_gen))
counts_evolve = np.zeros((n_seeds, max_gen))

for s in range(n_seeds):
    d_c = HierarchicalMergersInClusterPopulation(
        evolve_v_esc=False, seed=s, **common_hier_kw).simulate()
    d_e = HierarchicalMergersInClusterPopulation(
        evolve_v_esc=True, seed=s, **common_hier_kw).simulate()
    for g in range(1, max_gen + 1):
        counts_const[s, g-1] = len(d_c[g]['m'])
        counts_evolve[s, g-1] = len(d_e[g]['m'])
    if (s + 1) % 5 == 0:
        print(f"  {s+1}/{n_seeds} seeds done")

mean_const = counts_const.mean(axis=0)
mean_evolve = counts_evolve.mean(axis=0)
std_const = counts_const.std(axis=0)
std_evolve = counts_evolve.std(axis=0)

print(f"\n{'Generation':>12s} {'constant':>12s} {'evolving':>12s} {'ratio':>8s}")
for g in range(max_gen):
    ratio = mean_evolve[g] / mean_const[g] if mean_const[g] > 0 else float('nan')
    print(f"{g+1:>10d}G {mean_const[g]:>8.0f}±{std_const[g]:<4.0f}"
          f"{mean_evolve[g]:>8.0f}±{std_evolve[g]:<4.0f} {ratio:>8.2f}")
  5/20 seeds done
  10/20 seeds done
  15/20 seeds done
  20/20 seeds done

  Generation     constant     evolving    ratio
         1G    15000±0      15000±0        1.00
         2G     3547±43      2648±35       0.75
         3G      598±30       278±23       0.46
         4G      120±13        31±6        0.26
         5G       29±6          4±2        0.15
[7]:
fig, ax = plt.subplots(figsize=(8, 5))
gens = np.arange(1, max_gen + 1)
x = np.arange(max_gen)
w = 0.35

ax.bar(x - w/2, mean_const, w, yerr=std_const, capsize=4,
       label='constant $v_{esc}$', color='C0', alpha=0.7)
ax.bar(x + w/2, mean_evolve, w, yerr=std_evolve, capsize=4,
       label='evolving $v_{esc}$', color='C3', alpha=0.7)
ax.set_xticks(x)
ax.set_xticklabels([f'{g}G' for g in gens])
ax.set_xlabel('Generation')
ax.set_ylabel('Retained BH count')
ax.set_title(f'Constant vs evolving $v_{{esc}}$ (averaged over {n_seeds} seeds)')
ax.legend()
plt.tight_layout()
plt.show()
../_images/notebooks_08_hierarchical_mergers_10_0.png

3. Paper-style generation plots

plot_generations() produces mass-spin scatter plots and mass histograms by generation, matching the figure style from Islam, Wadekar & Kritos (2026).

  • Single model: 2-panel (scatter + histogram)

  • Two-model comparison: 3-panel (scatter each + overlaid histogram with bars vs step lines)

[8]:
sim_hlz = HierarchicalMergersInClusterPopulation(
    n_samples=5000, chi_max=0.2, m_min=3, m_max=60, imf='uniform',
    pairing='random', kick_model='gwmodel', seed=42)
data_hlz = sim_hlz.simulate(verbose=True)

fig, axes = sim_hlz.plot_generations(data_hlz, label='HLZ')
plt.show()
1g: 5000 BHs (m=[3.0,60.0] Msun, IMF=uniform, chi~U(0,0.2), v_esc~U(1.0,300.0))
2g: 1425 retained, m_med=61.8 Msun, chi_med=0.65
3g: 297 retained, m_med=83.7 Msun, chi_med=0.64
4g: 53 retained, m_med=116.0 Msun, chi_med=0.67
5g: 12 retained, m_med=132.3 Msun, chi_med=0.67
/Users/tousifislam/Research/projects/stupid/gwGenealogy/gwGenealogy/core/hierarchical.py:188: UserWarning: This figure includes Axes that are not compatible with tight_layout, so results might be incorrect.
  plt.tight_layout(rect=[0, 0, 1, 0.95])
../_images/notebooks_08_hierarchical_mergers_12_2.png

HLZ vs gwModel comparison (3-panel)

[9]:
sim_gw = HierarchicalMergersInClusterPopulation(
    n_samples=50000, chi_max=0.2, m_min=3, m_max=60, imf='uniform',
    pairing='random', kick_model='gwmodel', seed=42)
data_gw = sim_gw.simulate(verbose=True)

fig, axes = sim_hlz.plot_generations(
    data_hlz, compare_data=data_gw,
    label='HLZ', compare_label='gwModel')
plt.show()
1g: 50000 BHs (m=[3.0,60.0] Msun, IMF=uniform, chi~U(0,0.2), v_esc~U(1.0,300.0))
2g: 14500 retained, m_med=62.1 Msun, chi_med=0.65
3g: 2954 retained, m_med=87.5 Msun, chi_med=0.65
4g: 582 retained, m_med=116.5 Msun, chi_med=0.63
5g: 154 retained, m_med=140.2 Msun, chi_med=0.61
../_images/notebooks_08_hierarchical_mergers_14_1.png
[ ]: