Coverage for src/galsbi/sham_ot/histograms.py: 100%

227 statements  

« prev     ^ index     » next       coverage.py v7.17.2, created at 2026-11-09 18:05 +0000

1# Copyright (C) 2025 ETH Zurich 

2# Institute for Particle Physics and Astrophysics 

3# Author: Silvan Fischbacher 

4# created: Tue Jun 24 2025 

5 

6 

7import pickle 

8import re 

9 

10import numpy as np 

11from cosmic_toolbox.logger import get_logger 

12from scipy.special import erf 

13 

14from galsbi.sham_ot import utils 

15 

16LOGGER = get_logger(__file__) 

17 

18 

19class HaloHistogram: 

20 """ 

21 Class to handle histograms of halos for optimal use in the SHAM-OT framework. 

22 The class saves the following histograms: 

23 

24 - 2D histogram of redshift vs halo mass for all halos, 

25 shape (n_z_bins, n_mass_bins) 

26 - 2D histogram of redshift vs halo mass for central halos, 

27 shape (n_z_bins, n_mass_bins) 

28 - 3D histogram of redshift vs halo mass vs time since last merger for subhalos, 

29 shape (n_z_bins, n_mass_bins, n_time_bins) 

30 

31 The indexing follows: hist[z_bin_index, mass_bin_index, time_bin_index], 

32 i.e. histogram[0] gives the 0th redshift bin as a histogram over log masses. 

33 """ 

34 

35 def __init__( 

36 self, 

37 z_min=None, 

38 z_max=None, 

39 min_mass=None, 

40 max_mass=6e15, 

41 min_time_since_merger=0, 

42 max_time_since_merger=6, 

43 n_bins_mass=500, 

44 n_bins_z=500, 

45 n_bins_time=100, 

46 ): 

47 """ 

48 Initializes the HaloHistogram class with specified mass and redshift ranges 

49 and the number of bins for each. 

50 Defaults for redshift and minimum mass are taken directly from the simulation 

51 if not specified. 

52 

53 :param z_min: Minimum redshift of the histogram 

54 :param z_max: Maximum redshift of the histogram 

55 :param min_mass: Minimum mass of the histogram 

56 :param max_mass: Maximum mass of the histogram (default: 6e15) 

57 :param min_time_since_merger: Minimum time since last merger (default: 0) 

58 :param max_time_since_merger: Maximum time since last merger (default: 10) 

59 :param n_bins_mass: Number of bins in the mass dimension (default: 500) 

60 :param n_bins_z: Number of bins in the redshift dimension (default: 500) 

61 :param n_bins_time: Number of bins in the time since last merger dimension 

62 (default: 100) 

63 """ 

64 self.z_min = z_min 

65 self.z_max = z_max 

66 self.min_mass = min_mass 

67 self.max_mass = max_mass 

68 self.min_time_since_merger = min_time_since_merger 

69 self.max_time_since_merger = max_time_since_merger 

70 self.n_bins_mass = n_bins_mass 

71 self.n_bins_z = n_bins_z 

72 self.n_bins_time = n_bins_time 

73 

74 # Flag to enforce initialization of bins and histograms the first time 

75 # a halo catalog is added 

76 self.no_catalogs_added = True 

77 

78 @property 

79 def min_log_mass(self): 

80 """ 

81 Returns the minimum log mass of the histogram. 

82 If min_mass is not set, it returns None. 

83 """ 

84 if self.min_mass is not None: 

85 return np.log10(self.min_mass) 

86 return None 

87 

88 @property 

89 def max_log_mass(self): 

90 """ 

91 Returns the maximum log mass of the histogram. 

92 If max_mass is not set, it returns None. 

93 """ 

94 return np.log10(self.max_mass) 

95 

96 @property 

97 def log_mass_bins(self): 

98 """ 

99 Returns the logarithmic mass bins for the histogram. 

100 If min_log_mass or max_log_mass is not set, it returns None. 

101 """ 

102 if hasattr(self, "mass_bins"): 

103 return np.log10(self.mass_bins) 

104 return None 

105 

106 def add_halo_catalog( 

107 self, 

108 halo_catalog, 

109 mass_field="m", 

110 redshift_field="z_true", 

111 is_central_field="is_halo", 

112 time_since_merger_field="delta_t", 

113 pinocchio_output_filename=None, 

114 ): 

115 """ 

116 Adds a halo catalog to the histograms. The catalog should be a recarray or 

117 dictionary-like object containing the necessary fields: 

118 - Halo mass 

119 - Redshift 

120 - Is central halo 

121 - Time since last merger 

122 

123 :param halo_catalog: A recarray or dictionary-like object containing halo data. 

124 :param mass_field: Field name for halo mass 

125 (default: 'm') 

126 :param redshift_field: Field name for redshift 

127 (default: 'z_true') 

128 :param is_central_field: Field name for central halo indicator 

129 (default: 'is_halo') 

130 :param time_since_merger_field: Field name for time since last merger 

131 (default: 'delta_t') 

132 """ 

133 

134 if self.no_catalogs_added: 

135 if self.z_min is None or self.z_max is None or self.min_mass is None: 

136 m_part, min_num_part, z_min, z_max = read_pinocchio_config_details( 

137 pinocchio_output_filename 

138 ) 

139 if self.z_min is None: 

140 self.z_min = z_min 

141 if self.z_max is None: 

142 self.z_max = z_max 

143 if self.min_mass is None: 

144 self.min_mass = 0.999 * m_part * min_num_part 

145 

146 log_mass_bins = np.linspace( 

147 self.min_log_mass, self.max_log_mass, self.n_bins_mass + 1 

148 ) 

149 self.mass_bins = np.power(10, log_mass_bins) 

150 self.z_bins = np.linspace(self.z_min, self.z_max, self.n_bins_z + 1) 

151 self.time_bins = np.linspace( 

152 self.min_time_since_merger, 

153 self.max_time_since_merger, 

154 self.n_bins_time + 1, 

155 ) 

156 

157 self.histogram_all = np.zeros((self.n_bins_z, self.n_bins_mass), dtype=int) 

158 self.histogram_central = np.zeros( 

159 (self.n_bins_z, self.n_bins_mass), dtype=int 

160 ) 

161 self.histogram_subhalo = np.zeros( 

162 (self.n_bins_z, self.n_bins_mass, self.n_bins_time), dtype=int 

163 ) 

164 self.no_catalogs_added = False 

165 

166 is_central = halo_catalog[is_central_field].astype(bool) 

167 

168 self.histogram_all += np.histogram2d( 

169 halo_catalog[redshift_field], 

170 np.log10(halo_catalog[mass_field]), 

171 bins=(self.z_bins, self.log_mass_bins), 

172 )[0].astype(int) 

173 

174 self.histogram_central += np.histogram2d( 

175 halo_catalog[redshift_field][is_central], 

176 np.log10(halo_catalog[mass_field][is_central]), 

177 bins=(self.z_bins, self.log_mass_bins), 

178 )[0].astype(int) 

179 

180 self.histogram_subhalo += np.histogramdd( 

181 ( 

182 halo_catalog[redshift_field][~is_central], 

183 np.log10(halo_catalog[mass_field][~is_central]), 

184 halo_catalog[time_since_merger_field][~is_central], 

185 ), 

186 bins=(self.z_bins, self.log_mass_bins, self.time_bins), 

187 )[0].astype(int) 

188 

189 if np.any(halo_catalog[mass_field] > self.max_mass): 

190 too_high = halo_catalog[mass_field] > self.max_mass 

191 LOGGER.warning( 

192 f"{sum(too_high)} halos have mass > {self.max_mass}. " 

193 "They are added to the heaviest mass bin" 

194 ) 

195 # Add overflow halos to the correct redshift bins in the last mass bin 

196 overflow_hist_all = np.histogram( 

197 halo_catalog[redshift_field][too_high], bins=self.z_bins 

198 )[0].astype(int) 

199 overflow_hist_central = np.histogram( 

200 halo_catalog[redshift_field][too_high & is_central], bins=self.z_bins 

201 )[0].astype(int) 

202 overflow_hist_subhalo = np.histogramdd( 

203 ( 

204 halo_catalog[redshift_field][too_high & ~is_central], 

205 halo_catalog[time_since_merger_field][too_high & ~is_central], 

206 ), 

207 bins=(self.z_bins, self.time_bins), 

208 )[0].astype(int) 

209 

210 self.histogram_all[:, -1] += overflow_hist_all 

211 self.histogram_central[:, -1] += overflow_hist_central 

212 self.histogram_subhalo[:, -1, :] += overflow_hist_subhalo 

213 

214 if np.any(halo_catalog[time_since_merger_field] > self.max_time_since_merger): 

215 too_late = ( 

216 halo_catalog[time_since_merger_field] > self.max_time_since_merger 

217 ) 

218 LOGGER.warning( 

219 f"{sum(too_late)} subhalos have time since last merger > " 

220 "{self.max_time_since_merger}. They are added to the latest time bin." 

221 ) 

222 subhalos_too_late = too_late & ~is_central 

223 if np.any(subhalos_too_late): 

224 overflow_hist_subhalo = np.histogram2d( 

225 halo_catalog[redshift_field][subhalos_too_late], 

226 np.log10(halo_catalog[mass_field][subhalos_too_late]), 

227 bins=(self.z_bins, self.log_mass_bins), 

228 )[0].astype(int) 

229 self.histogram_subhalo[:, :, -1] += overflow_hist_subhalo 

230 

231 def add_halo_histogram(self, halo_histogram): 

232 """ 

233 Adds another HaloHistogram instance to the current one. 

234 

235 :param halo_histogram: Another HaloHistogram instance to add. 

236 """ 

237 if not isinstance(halo_histogram, HaloHistogram): 

238 raise TypeError("halo_histogram must be an instance of HaloHistogram") 

239 

240 if ( 

241 not np.array_equal(self.mass_bins, halo_histogram.mass_bins) 

242 or not np.array_equal(self.z_bins, halo_histogram.z_bins) 

243 or not np.array_equal(self.time_bins, halo_histogram.time_bins) 

244 ): 

245 raise ValueError("Mass, redshift, or time bins do not match.") 

246 

247 self.histogram_all += halo_histogram.histogram_all 

248 self.histogram_central += halo_histogram.histogram_central 

249 self.histogram_subhalo += halo_histogram.histogram_subhalo 

250 

251 def get_histogram(self, galaxy_type="blue", model="Berner+24", **kwargs): 

252 """ 

253 Returns the histogram for red or blue galaxies for the specified SHAM model. 

254 Currently, the following models are supported: 

255 

256 - "Berner+24": free parameters m_limit and quenching time, see arXiv:2310.15223 

257 - "Peng+10": quenching fractions based on mass, see arXiv:1003.4747 

258 

259 :param galaxy_type: Type of galaxy ("blue" or "red") 

260 :param model: Model name (default: "Berner+24") 

261 :param m_limit: Mass limit for central halos in Berner+24 (default: 9e12) 

262 :param quenching_time: Quenching time for subhalos in Berner+24 (default: 2) 

263 :param mlim_sigma: Scatter in mass limit for extended Berner+24 (default: 0.3) 

264 :param initial_quenched_fraction: Initial quenched fraction for subhalos in 

265 extended Berner+24 (default: 0.1) 

266 :param quenching_time_sigma: Scatter in quenching time for extended Berner+24 

267 (default: 0.5) 

268 :param M_mu: Characteristic mass for Peng+10 (default: 11.935) 

269 :param sigma: Mass scatter for Peng+10 (default: 0.51) 

270 :param b: Satellite quenching efficiency for Peng+10 (default: 0.41) 

271 :return: Histogram for the specified galaxy type 

272 """ 

273 if model == "Berner+24": 

274 kwargs.setdefault("m_limit", 8e12) 

275 kwargs.setdefault("quenching_time", 2.0) 

276 return self._get_histogram_berner( 

277 galaxy_type, kwargs["m_limit"], kwargs["quenching_time"] 

278 ) 

279 elif model == "Berner+24_ext": 

280 kwargs.setdefault("m_limit", 8e12) 

281 kwargs.setdefault("quenching_time", 2.0) 

282 kwargs.setdefault("mlim_sigma", 0.3) 

283 kwargs.setdefault("initial_quenched_fraction", 0.1) 

284 kwargs.setdefault("quenching_time_sigma", 0.5) 

285 return self._get_histogram_berner_ext( 

286 galaxy_type, 

287 kwargs["m_limit"], 

288 kwargs["quenching_time"], 

289 mlim_sigma=kwargs["mlim_sigma"], 

290 initial_quenched_fraction=kwargs["initial_quenched_fraction"], 

291 quenching_time_sigma=kwargs["quenching_time_sigma"], 

292 ) 

293 elif model == "Peng+10": 

294 kwargs.setdefault("M_mu", 11.935) 

295 kwargs.setdefault("sigma", 0.51) 

296 kwargs.setdefault("b", 0.41) 

297 return self._get_histogram_peng( 

298 galaxy_type, kwargs["M_mu"], kwargs["sigma"], kwargs["b"] 

299 ) 

300 else: 

301 raise ValueError( 

302 f"Model '{model}' is not supported. Currently only the following " 

303 "models are supported: 'Berner+24', 'Berner+24_ext, 'Peng+10'." 

304 ) 

305 

306 def _get_histogram_berner(self, galaxy_type, m_limit, quenching_time): 

307 """ 

308 Get histogram using Berner+24 model with hard cuts. 

309 """ 

310 histogram = np.zeros_like(self.histogram_all) 

311 

312 # Create fractional masks for mass and time bins 

313 mass_bin_centers = self.get_mass_bins()[1] 

314 time_bin_centers = self.get_time_bins()[1] 

315 

316 # For mass bins: fraction of bin that is <= m_limit 

317 mass_blue_mask = np.zeros(len(mass_bin_centers)) 

318 for i, (bin_start, bin_end) in enumerate( 

319 zip(self.mass_bins[:-1], self.mass_bins[1:], strict=True) 

320 ): 

321 if bin_end <= m_limit: 

322 mass_blue_mask[i] = 1.0 # Entire bin is blue 

323 elif bin_start > m_limit: 

324 mass_blue_mask[i] = 0.0 # Entire bin is red 

325 else: 

326 # Limit is within bin, calculate fraction 

327 mass_blue_mask[i] = (m_limit - bin_start) / (bin_end - bin_start) 

328 

329 # For time bins: fraction of bin that is <= quenching_time 

330 time_blue_mask = np.zeros(len(time_bin_centers)) 

331 for i, (bin_start, bin_end) in enumerate( 

332 zip(self.time_bins[:-1], self.time_bins[1:], strict=True) 

333 ): 

334 if bin_end <= quenching_time: 

335 time_blue_mask[i] = 1.0 # Entire bin is blue 

336 elif bin_start > quenching_time: 

337 time_blue_mask[i] = 0.0 # Entire bin is red 

338 else: 

339 # Limit is within bin, calculate fraction 

340 time_blue_mask[i] = (quenching_time - bin_start) / (bin_end - bin_start) 

341 

342 if galaxy_type == "blue": 

343 # Blue galaxies: 

344 # central halos with mass <= m_limit 

345 # subhalos with delta_t <= quenching_time 

346 histogram += np.round( 

347 self.histogram_central * mass_blue_mask[None, :] 

348 ).astype(int) 

349 histogram += ( 

350 np.round(self.histogram_subhalo * time_blue_mask[None, None, :]) 

351 .sum(axis=2) 

352 .astype(int) 

353 ) 

354 elif galaxy_type == "red": 

355 # Red galaxies: 

356 # central halos with mass > m_limit 

357 # subhalos with delta_t > quenching_time 

358 histogram += np.round( 

359 self.histogram_central * (1 - mass_blue_mask)[None, :] 

360 ).astype(int) 

361 histogram += ( 

362 np.round(self.histogram_subhalo * (1 - time_blue_mask)[None, None, :]) 

363 .sum(axis=2) 

364 .astype(int) 

365 ) 

366 else: 

367 raise ValueError( 

368 f"Galaxy type '{galaxy_type}' not supported. Use 'blue' or 'red'." 

369 ) 

370 

371 return histogram 

372 

373 def _get_histogram_berner_ext( 

374 self, 

375 galaxy_type, 

376 m_limit, 

377 quenching_time, 

378 mlim_sigma, 

379 initial_quenched_fraction, 

380 quenching_time_sigma, 

381 ): 

382 """ 

383 Get histogram using extended Berner+24 model with mass-dependent quenching 

384 fractions. 

385 """ 

386 

387 histogram = np.zeros_like(self.histogram_all, dtype=float) 

388 

389 mass_centers = self.get_mass_bins()[1] 

390 time_centers = self.get_time_bins()[1] 

391 

392 red_centrals = utils.adapted_err_function( 

393 np.log10(mass_centers), np.log10(m_limit), mlim_sigma, min_val=0, max_val=1 

394 ) 

395 blue_centrals = 1 - red_centrals 

396 

397 red_subhalos = utils.adapted_err_function( 

398 time_centers, 

399 quenching_time, 

400 quenching_time_sigma, 

401 min_val=initial_quenched_fraction, 

402 max_val=1, 

403 ) 

404 blue_subhalos = 1 - red_subhalos 

405 

406 histogram_central = self.histogram_central.astype(float) 

407 histogram_subhalo = self.histogram_subhalo.astype(float) 

408 

409 if galaxy_type == "blue": 

410 # Blue galaxies = (1 - quenched fraction) * histogram 

411 histogram += histogram_central * blue_centrals[None, :] 

412 # subhalo histogram is 3D, the selection is over the 3rd dimension 

413 histogram += (histogram_subhalo * blue_subhalos[None, None, :]).sum(axis=2) 

414 elif galaxy_type == "red": 

415 # Red galaxies = quenched fraction * histogram 

416 histogram += histogram_central * red_centrals[None, :] 

417 # subhalo histogram is 3D, the selection is over the 3rd dimension 

418 histogram += (histogram_subhalo * red_subhalos[None, None, :]).sum(axis=2) 

419 else: 

420 raise ValueError( 

421 f"Galaxy type '{galaxy_type}' not supported. Use 'blue' or 'red'." 

422 ) 

423 

424 return histogram 

425 

426 def _get_histogram_peng(self, galaxy_type, M_mu, sigma, b): 

427 """ 

428 Get histogram using Peng+10 model with mass-dependent quenching fractions. 

429 Attention: M_mu is in log10 mass units. 

430 """ 

431 

432 histogram = np.zeros_like(self.histogram_all, dtype=float) 

433 

434 mass_centers = self.get_mass_bins()[1] 

435 

436 def _r0(M_mu, sigma, masses): 

437 return np.log10(masses / (10**M_mu)) / (np.sqrt(2) * sigma) 

438 

439 def _quenched_fraction_central(M_mu, sigma, masses): 

440 return (1 / 2) * (1 + erf(_r0(M_mu, sigma, masses))) 

441 

442 def _quenched_fraction_subhalo(M_mu, sigma, b, masses): 

443 return ((1 - b) / 2) * (1 + erf(_r0(M_mu, sigma, masses))) + b 

444 

445 # Calculate quenching fractions for each mass bin 

446 quenched_frac_central = _quenched_fraction_central(M_mu, sigma, mass_centers) 

447 quenched_frac_subhalo = 1 - _quenched_fraction_subhalo( 

448 M_mu, sigma, b, mass_centers 

449 ) 

450 

451 histogram_central = self.histogram_central.astype(float) 

452 histogram_subhalo = self.histogram_subhalo.astype(float).sum(axis=2) 

453 

454 if galaxy_type == "blue": 

455 # Blue galaxies = (1 - quenched fraction) * histogram 

456 histogram += histogram_central * (1 - quenched_frac_central)[None, :] 

457 histogram += histogram_subhalo * (1 - quenched_frac_subhalo)[None, :] 

458 elif galaxy_type == "red": 

459 # Red galaxies = quenched fraction * histogram 

460 histogram += histogram_central * quenched_frac_central[None, :] 

461 histogram += histogram_subhalo * quenched_frac_subhalo[None, :] 

462 else: 

463 raise ValueError( 

464 f"Galaxy type '{galaxy_type}' not supported. Use 'blue' or 'red'." 

465 ) 

466 

467 return histogram 

468 

469 def save(self, filename): 

470 """ 

471 Saves the class instance to a file using pickle. 

472 

473 :param filename: Name of the file to save the instance to. 

474 """ 

475 

476 with open(filename, "wb") as f: 

477 pickle.dump(self, f) 

478 LOGGER.info(f"Saved HaloHistogram to {filename}") 

479 

480 def get_total_counts(self): 

481 """ 

482 Returns the total counts of all histograms. 

483 

484 :return: Tuple of total counts for all, central, and subhalo histograms. 

485 """ 

486 total_all = np.sum(self.histogram_all) 

487 total_central = np.sum(self.histogram_central) 

488 total_subhalo = np.sum(self.histogram_subhalo) 

489 

490 return total_all, total_central, total_subhalo 

491 

492 def get_z_bins(self): 

493 """ 

494 Returns the redshift bin edges and centers. 

495 :return: Array of redshift bin edges and centers. 

496 """ 

497 return self.z_bins, (self.z_bins[:-1] + self.z_bins[1:]) / 2 

498 

499 def get_mass_bins(self): 

500 """ 

501 Returns the mass bin edges and log centers. 

502 :return: Array of mass bin edges and log centers. 

503 """ 

504 centers_log = (self.log_mass_bins[:-1] + self.log_mass_bins[1:]) / 2 

505 return self.mass_bins, np.power(10, centers_log) 

506 

507 def get_time_bins(self): 

508 """ 

509 Returns the time since last merger bin edges and centers. 

510 :return: Array of time since last merger bin edges and centers. 

511 """ 

512 return self.time_bins, (self.time_bins[:-1] + self.time_bins[1:]) / 2 

513 

514 

515def load_halo_histogram(filename): 

516 """ 

517 Loads a HaloHistogram instance from a file. 

518 

519 :param filename: Name of the file to load the instance from. 

520 :return: Loaded HaloHistogram instance. 

521 """ 

522 with open(filename, "rb") as f: 

523 halo_hist = pickle.load(f) 

524 LOGGER.info(f"Loaded HaloHistogram from {filename}") 

525 return halo_hist 

526 

527 

528def read_pinocchio_config_details(pinocchio_output_filename): 

529 """ 

530 Reads the Pinocchio output file to extract details about the simulation, 

531 such as particle mass, minimum number of particles, and redshift range. 

532 :param pinocchio_output_filename: Path to the Pinocchio output file. 

533 :return: Tuple containing particle mass, minimum number of particles, 

534 minimum redshift, and maximum redshift. 

535 """ 

536 with open(pinocchio_output_filename) as f: 

537 text = f.read() 

538 

539 m_part = float( 

540 re.search("Particle Mass \\(Msun/h\\)\\s+(.+)\n", text).groups()[0], 

541 ) 

542 min_num_part = int( 

543 re.search("MinHaloMass \\(particles\\)\\s+(\\d+)\n", text).groups()[0], 

544 ) 

545 z_max, z_min = ( 

546 float(i) 

547 for i in re.search( 

548 "The Past Light Cone will be reconstruct from z=(.+) to z=(.+)\n", text 

549 ).groups() 

550 ) 

551 

552 return ( 

553 m_part, 

554 min_num_part, 

555 z_min, 

556 z_max, 

557 ) 

558 

559 

560def rebin_halo_histogram( 

561 halo_hist, new_n_bins_mass=None, new_n_bins_z=None, new_n_bins_time=None 

562): 

563 """ 

564 Rebins a HaloHistogram to a new number of bins by combining neighboring bins. 

565 The new number of bins must be a divisor of the current number of bins. 

566 

567 :param halo_hist: HaloHistogram instance to rebin 

568 :param new_n_bins_mass: New number of mass bins (must divide current n_bins_mass) 

569 :param new_n_bins_z: New number of redshift bins (must divide current n_bins_z) 

570 :param new_n_bins_time: New number of time bins (must divide current n_bins_time) 

571 :return: New HaloHistogram instance with rebinned histograms 

572 """ 

573 if halo_hist.no_catalogs_added: 

574 raise ValueError("Cannot rebin histogram before any catalogs have been added") 

575 

576 # Use current values if new values are not specified 

577 if new_n_bins_mass is None: 

578 new_n_bins_mass = halo_hist.n_bins_mass 

579 if new_n_bins_z is None: 

580 new_n_bins_z = halo_hist.n_bins_z 

581 if new_n_bins_time is None: 

582 new_n_bins_time = halo_hist.n_bins_time 

583 

584 # Check if new bin numbers are valid divisors 

585 if halo_hist.n_bins_mass % new_n_bins_mass != 0: 

586 raise ValueError( 

587 f"new_n_bins_mass ({new_n_bins_mass}) must be a divisor of " 

588 f"current n_bins_mass ({halo_hist.n_bins_mass})" 

589 ) 

590 if halo_hist.n_bins_z % new_n_bins_z != 0: 

591 raise ValueError( 

592 f"new_n_bins_z ({new_n_bins_z}) must be a divisor of " 

593 f"current n_bins_z ({halo_hist.n_bins_z})" 

594 ) 

595 if halo_hist.n_bins_time % new_n_bins_time != 0: 

596 raise ValueError( 

597 f"new_n_bins_time ({new_n_bins_time}) must be a divisor of " 

598 f"current n_bins_time ({halo_hist.n_bins_time})" 

599 ) 

600 

601 # Calculate rebin factors 

602 mass_factor = halo_hist.n_bins_mass // new_n_bins_mass 

603 z_factor = halo_hist.n_bins_z // new_n_bins_z 

604 time_factor = halo_hist.n_bins_time // new_n_bins_time 

605 

606 # Create new histogram instance 

607 new_hist = HaloHistogram( 

608 z_min=halo_hist.z_min, 

609 z_max=halo_hist.z_max, 

610 min_mass=halo_hist.min_mass, 

611 max_mass=halo_hist.max_mass, 

612 min_time_since_merger=halo_hist.min_time_since_merger, 

613 max_time_since_merger=halo_hist.max_time_since_merger, 

614 n_bins_mass=new_n_bins_mass, 

615 n_bins_z=new_n_bins_z, 

616 n_bins_time=new_n_bins_time, 

617 ) 

618 

619 # Manually initialize the bins (same as what would happen in add_halo_catalog) 

620 log_mass_bins = np.linspace( 

621 new_hist.min_log_mass, new_hist.max_log_mass, new_n_bins_mass + 1 

622 ) 

623 new_hist.mass_bins = np.power(10, log_mass_bins) 

624 new_hist.z_bins = np.linspace(new_hist.z_min, new_hist.z_max, new_n_bins_z + 1) 

625 new_hist.time_bins = np.linspace( 

626 new_hist.min_time_since_merger, 

627 new_hist.max_time_since_merger, 

628 new_n_bins_time + 1, 

629 ) 

630 new_hist.no_catalogs_added = False 

631 

632 # Rebin the histograms 

633 new_hist.histogram_all = _rebin_2d(halo_hist.histogram_all, z_factor, mass_factor) 

634 new_hist.histogram_central = _rebin_2d( 

635 halo_hist.histogram_central, z_factor, mass_factor 

636 ) 

637 new_hist.histogram_subhalo = _rebin_3d( 

638 halo_hist.histogram_subhalo, z_factor, mass_factor, time_factor 

639 ) 

640 

641 return new_hist 

642 

643 

644def _rebin_2d(histogram, factor_axis0, factor_axis1): 

645 """ 

646 Helper function to rebin a 2D histogram by combining neighboring bins. 

647 """ 

648 old_shape = histogram.shape 

649 new_shape = (old_shape[0] // factor_axis0, old_shape[1] // factor_axis1) 

650 

651 # Reshape and sum over the dimensions to be combined 

652 reshaped = histogram.reshape(new_shape[0], factor_axis0, new_shape[1], factor_axis1) 

653 return reshaped.sum(axis=(1, 3)) 

654 

655 

656def _rebin_3d(histogram, factor_axis0, factor_axis1, factor_axis2): 

657 """ 

658 Helper function to rebin a 3D histogram by combining neighboring bins. 

659 """ 

660 old_shape = histogram.shape 

661 new_shape = ( 

662 old_shape[0] // factor_axis0, 

663 old_shape[1] // factor_axis1, 

664 old_shape[2] // factor_axis2, 

665 ) 

666 

667 # Reshape and sum over the dimensions to be combined 

668 reshaped = histogram.reshape( 

669 new_shape[0], 

670 factor_axis0, 

671 new_shape[1], 

672 factor_axis1, 

673 new_shape[2], 

674 factor_axis2, 

675 ) 

676 return reshaped.sum(axis=(1, 3, 5))