Coverage for src/galsbi/ucat/galaxy_population_models/galaxy_luminosity_function.py: 100%

180 statements  

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

1# Copyright (C) 2018 ETH Zurich, Institute for Particle Physics and Astrophysics 

2 

3""" 

4Created on Sept 2021 

5author: Tomasz Kacprzak 

6using code from: Joerg Herbel 

7""" 

8 

9from collections import OrderedDict 

10 

11import numpy as np 

12import scipy.integrate 

13import scipy.optimize 

14import scipy.special 

15from cosmic_toolbox import logger 

16 

17LOGGER = logger.get_logger(__file__) 

18 

19 

20def find_closest_ind(grid, vals): 

21 ind = np.searchsorted(grid, vals) 

22 ind[ind == grid.size] -= 1 

23 ind[np.fabs(grid[ind] - vals) > np.fabs(grid[ind - 1] - vals)] -= 1 

24 return ind 

25 

26 

27def initialize_luminosity_functions(par, pixarea, cosmo, z_m_intp=None): 

28 kw_lumfun = dict( 

29 pixarea=pixarea, 

30 z_res=par.lum_fct_z_res, 

31 m_res=par.lum_fct_m_res, 

32 z_max=par.lum_fct_z_max, 

33 m_max=par.lum_fct_m_max, 

34 z_m_intp=z_m_intp, 

35 ngal_multiplier=par.ngal_multiplier, 

36 cosmo=cosmo, 

37 ) 

38 if par.ngal_multiplier != 1: 

39 LOGGER.warning(f"ngal_multiplier is set to {par.ngal_multiplier}") 

40 

41 lum_funcs = OrderedDict() 

42 for i, g in enumerate(par.galaxy_types): 

43 lum_funcs[g] = LuminosityFunction( 

44 lum_fct_parametrization=par.lum_fct_parametrization, 

45 m_star_slope=getattr(par, f"lum_fct_m_star_{g}_slope"), 

46 m_star_intcpt=getattr(par, f"lum_fct_m_star_{g}_intcpt"), 

47 phi_star_amp=getattr(par, f"lum_fct_phi_star_{g}_amp"), 

48 phi_star_exp=getattr(par, f"lum_fct_phi_star_{g}_exp"), 

49 z_const=getattr(par, f"lum_fct_z_const_{g}"), 

50 alpha=getattr(par, f"lum_fct_alpha_{g}"), 

51 name=g, 

52 galaxy_type=i, 

53 seed_ngal=par.seed_ngal, 

54 **kw_lumfun, 

55 ) 

56 return lum_funcs 

57 

58 

59def maximum_redshift( 

60 z_m_intp, m_max, z_max, parametrization, z_const, alpha, m_star_par, seed_ngal 

61): 

62 """ 

63 Computes the maximum redshift up to which we sample objects from the luminosity 

64 function. The cutoff is based on the criterion that the CDF for absolute magnitudes 

65 is larger than 1e-5, i.e. that there is a reasonable probability of actually 

66 obtaining objects at this redshift and absolute magnitude which still pass the cut 

67 on par.gals_mag_max. 

68 """ 

69 if z_m_intp is None: 

70 return z_max 

71 

72 def cond_mag_cdf_lim(z): 

73 m_s = m_star_lum_fct(z, parametrization, z_const, *m_star_par) 

74 cdf_lim = ( 

75 upper_inc_gamma(alpha + 1, 10 ** (0.4 * (m_s - z_m_intp(z)))) 

76 / upper_inc_gamma(alpha + 1, 10 ** (0.4 * (m_s - m_max))) 

77 - 1e-5 

78 ) 

79 return cdf_lim 

80 

81 try: 

82 z_max_cutoff = scipy.optimize.brentq(cond_mag_cdf_lim, 0, z_m_intp.x[-1]) 

83 except ValueError: 

84 z_max_cutoff = z_m_intp.x[-1] 

85 

86 z_max = min(z_max, z_max_cutoff) 

87 np.random.seed(seed_ngal) 

88 z_max += np.random.uniform(0, 0.0001) 

89 

90 return z_max 

91 

92 

93def m_star_lum_fct(z, parametrization, z_const, slope, intercept): 

94 if parametrization == "linexp": 

95 # Eq. 3.3. from http://arxiv.org/abs/1705.05386 

96 m_s = np.polyval((slope, intercept), z) 

97 elif parametrization == "logpower": 

98 # Eq. 21 + 22 from https://arxiv.org/abs/1106.2039 

99 m_s = intercept + slope * np.log10(1 + z) 

100 elif parametrization == "truncated_logexp": 

101 m_s = intercept + slope * np.log10(1 + np.where(z < z_const, z, z_const)) 

102 return m_s 

103 

104 

105def phi_star_lum_fct(z, parametrization, amplitude, exp): 

106 if (parametrization == "linexp") | (parametrization == "truncated_logexp"): 

107 # Eq. 3.4. from http://arxiv.org/abs/1705.05386 

108 p_star = amplitude * np.exp(exp * z) 

109 elif parametrization == "logpower": 

110 # Eq. 25 from https://arxiv.org/abs/1106.2039 

111 p_star = amplitude * (1 + z) ** exp 

112 return p_star 

113 

114 

115def upper_inc_gamma(a, x): 

116 if a > 0: 

117 uig = scipy.special.gamma(a) * scipy.special.gammaincc(a, x) 

118 

119 elif a == 0: 

120 uig = -scipy.special.expi(-x) 

121 

122 else: 

123 uig = 1 / a * (upper_inc_gamma(a + 1, x) - x**a * np.exp(-x)) 

124 

125 return uig 

126 

127 

128class NumGalCalculator: 

129 """ 

130 Computes galaxy number counts by integrating the galaxy luminosity function. 

131 The integral over absolute magnitudes can be done analytically, while the integral 

132 over redshifts is computed numerically. See also 

133 docs/jupyter_notebooks/sample_redshift_magnitude.ipynb. 

134 """ 

135 

136 def __init__( 

137 self, 

138 z_min, 

139 z_max, 

140 m_min, 

141 m_max, 

142 parametrization, 

143 z_const, 

144 alpha, 

145 m_star_par, 

146 phi_star_par, 

147 cosmo, 

148 pixarea, 

149 ngal_multiplier=1, 

150 ): 

151 """ 

152 Initializes the galaxy number count calculator. 

153 

154 :param z_min: Minimum redshift 

155 :param z_max: Maximum redshift 

156 :param m_min: Minimum absolute magnitude (brightest) 

157 :param m_max: Maximum absolute magnitude (faintest) 

158 :param parametrization: Luminosity function parametrization 

159 :param z_const: Redshift constant for truncated logexp parametrization 

160 :param alpha: Luminosity function slope 

161 :param m_star_par: Parameters for the M* - z relation (slope, intercept) 

162 :param phi_star_par: Parameters for the phi* - z relation (amplitude, exponent) 

163 :param cosmo: PyCosmo cosmology object 

164 :param pixarea: Sky area in square radians 

165 :param ngal_multiplier: multiplier for the number of galaxies 

166 """ 

167 

168 z_density_int = scipy.integrate.quad( 

169 func=self._redshift_density, 

170 a=z_min, 

171 b=z_max, 

172 args=( 

173 m_min, 

174 m_max, 

175 parametrization, 

176 z_const, 

177 alpha, 

178 m_star_par, 

179 phi_star_par, 

180 cosmo, 

181 ), 

182 )[0] 

183 self.n_gal_mean = z_density_int * pixarea * ngal_multiplier 

184 

185 def __call__(self): 

186 """ 

187 Randomly samples the number of galaxies from the Poisson distribution with the 

188 mean number of galaxies. 

189 """ 

190 if self.n_gal_mean > 0: 

191 if self.n_gal_mean < 9e18: 

192 n_gal = np.random.poisson(self.n_gal_mean) 

193 else: 

194 # This is a workaround for the fact that np.random.poisson does not work 

195 # for large numbers. We use the fact that the Poisson distribution 

196 # converges to a Gaussian for large means and sample from a Gaussian 

197 # instead. 

198 # It will probably be a crazy galaxy population model anyway... 

199 LOGGER.warning("Using Gaussian instead of a Poisson due to large mean.") 

200 n = float(self.n_gal_mean) 

201 n_gal = int(np.random.normal(n, np.sqrt(n))) 

202 else: 

203 n_gal = self.n_gal_mean 

204 return n_gal 

205 

206 def _redshift_density( 

207 self, 

208 z, 

209 m_min, 

210 m_max, 

211 parametrization, 

212 z_const, 

213 alpha, 

214 par_m_star, 

215 par_phi_star, 

216 cosmo, 

217 ): 

218 m_star = m_star_lum_fct(z, parametrization, z_const, *par_m_star) 

219 phi_star = phi_star_lum_fct(z, parametrization, *par_phi_star) 

220 e = np.sqrt(cosmo.params.omega_m * (1 + z) ** 3 + cosmo.params.omega_l) 

221 d_h = cosmo.params.c / cosmo.params.H0 

222 d_m = cosmo.background.dist_trans_a(a=1 / (1 + z)) 

223 if m_min is not None: 

224 m_min_subtract = upper_inc_gamma(alpha + 1, 10 ** (0.4 * (m_star - m_min))) 

225 else: 

226 m_min_subtract = 0 

227 density = ( 

228 phi_star 

229 * d_h 

230 * d_m**2 

231 / e 

232 * ( 

233 upper_inc_gamma(alpha + 1, 10 ** (0.4 * (m_star - m_max))) 

234 - m_min_subtract 

235 ) 

236 ) 

237 return density 

238 

239 

240class RedshiftAbsMagSampler: 

241 """ 

242 Samples redshifts and absolute magnitudes from the galaxy luminosity function. 

243 The sampling is done by first drawing redshifts from the redshift-pdf obtained by 

244 integrating out absolute magnitudes. Then, we sample absolute magnitudes from the 

245 conditional pdfs obtained by conditioning the luminosity function on the sampled 

246 redshifts (the conditional pdf is different for each redshift). See also 

247 docs/jupyter_notebooks/sample_redshift_magnitude.ipynb and 

248 docs/jupyter_notebooks/test_self_consistency.ipynb. 

249 """ 

250 

251 def __init__( 

252 self, 

253 z_res, 

254 z_min, 

255 z_max, 

256 m_res, 

257 m_min, 

258 m_max, 

259 parametrization, 

260 z_const, 

261 alpha, 

262 m_star_par, 

263 phi_star_par, 

264 cosmo, 

265 ): 

266 """ 

267 Initializes the redshift and absolute magnitude grid for sampling. 

268 

269 :param z_res: Redshift resolution 

270 :param z_min: Minimum redshift 

271 :param z_max: Maximum redshift 

272 :param m_res: Absolute magnitude resolution 

273 :param m_min: Minimum absolute magnitude (brightest) 

274 :param m_max: Maximum absolute magnitude (faintest) 

275 :param parametrization: Luminosity function parametrization 

276 :param z_const: Redshift constant for truncated logexp parametrization 

277 :param alpha: Luminosity function slope 

278 :param m_star_par: Parameters for the M* - z relation (slope, intercept) 

279 :param phi_star_par: Parameters for the phi* - z relation (amplitude, exponent) 

280 :param cosmo: PyCosmo cosmology object 

281 """ 

282 self.z_res = z_res 

283 self.z_min = z_min 

284 self.z_max = z_max 

285 self.m_res = m_res 

286 self.m_min = m_min 

287 self.m_max = m_max 

288 self.parametrization = parametrization 

289 self.z_const = z_const 

290 self.alpha = alpha 

291 self.m_star_par = m_star_par 

292 self.phi_star_par = phi_star_par 

293 self.cosmo = cosmo 

294 

295 # TODO: Do we need to pass all of these parameters? Should be in the class... 

296 self._setup_redshift_grid() 

297 self._setup_mag_grid() 

298 

299 def __call__(self, n_samples): 

300 """ 

301 Returns n_samples redshifts and absolute magnitudes sampled from the galaxy 

302 luminosity function. 

303 

304 :param n_samples: Number of samples 

305 :return: Redshifts and absolute magnitudes 

306 """ 

307 z = np.random.choice(self.z_grid, size=n_samples, replace=True, p=self.nz_grid) 

308 

309 m_s = m_star_lum_fct(z, self.parametrization, self.z_const, *self.m_star_par) 

310 if self.m_min is None: 

311 low = 0 

312 else: 

313 low = upper_inc_gamma(self.alpha + 1, 10 ** (0.4 * (m_s - self.m_min))) 

314 m_rvs = np.random.uniform( 

315 low=low, 

316 high=upper_inc_gamma(self.alpha + 1, 10 ** (0.4 * (m_s - self.m_max))), 

317 size=n_samples, 

318 ) # here, we sample M* - M, where M* is redshift-dependent 

319 uig_ind = find_closest_ind(self.uig_grid, m_rvs) 

320 m = m_s - self.m_s__m__grid[uig_ind] # now we transform from M* - M to M 

321 return z, m 

322 

323 def _setup_redshift_grid(self): 

324 self.z_grid = np.linspace( 

325 self.z_min, 

326 self.z_max, 

327 num=int(round((self.z_max - self.z_min) / self.z_res)) + 1, 

328 ) 

329 

330 e = np.sqrt( 

331 self.cosmo.params.omega_m * (1 + self.z_grid) ** 3 

332 + self.cosmo.params.omega_l 

333 ) 

334 d_h = self.cosmo.params.c / self.cosmo.params.H0 

335 d_m = self.cosmo.background.dist_trans_a(a=1 / (1 + self.z_grid)) 

336 f = d_h * d_m**2 / e 

337 m_star = m_star_lum_fct( 

338 self.z_grid, self.parametrization, self.z_const, *self.m_star_par 

339 ) 

340 phi_star = phi_star_lum_fct( 

341 self.z_grid, self.parametrization, *self.phi_star_par 

342 ) 

343 

344 if self.m_min is not None: 

345 m_min_subtract = upper_inc_gamma( 

346 self.alpha + 1, 10 ** (0.4 * (m_star - self.m_min)) 

347 ) 

348 else: 

349 m_min_subtract = 0 

350 self.nz_grid = ( 

351 f 

352 * phi_star 

353 * ( 

354 upper_inc_gamma(self.alpha + 1, 10 ** (0.4 * (m_star - self.m_max))) 

355 - m_min_subtract 

356 ) 

357 ) 

358 self.nz_grid /= np.sum(self.nz_grid) 

359 

360 def _setup_mag_grid(self): 

361 m_s_min = scipy.optimize.minimize_scalar( 

362 lambda z: m_star_lum_fct( 

363 z, self.parametrization, self.z_const, *self.m_star_par 

364 ), 

365 bounds=(self.z_min, self.z_max), 

366 method="bounded", 

367 ).fun 

368 m_s_max = -scipy.optimize.minimize_scalar( 

369 lambda z: -m_star_lum_fct( 

370 z, self.parametrization, self.z_const, *self.m_star_par 

371 ), 

372 bounds=(self.z_min, self.z_max), 

373 method="bounded", 

374 ).fun 

375 

376 m_min = self.m_max 

377 while upper_inc_gamma(self.alpha + 1, 10 ** (0.4 * (m_s_min - m_min))) > 0: 

378 m_min -= 0.1 

379 self.m_s__m__grid = np.linspace( 

380 m_s_max - m_min, 

381 m_s_min - self.m_max, 

382 num=int(round((m_s_max - m_min - m_s_min + self.m_max) / self.m_res)) + 1, 

383 ) 

384 

385 self.uig_grid = upper_inc_gamma(self.alpha + 1, 10 ** (0.4 * self.m_s__m__grid)) 

386 

387 

388class LuminosityFunction: 

389 """ 

390 Luminosity function 

391 """ 

392 

393 def __init__( 

394 self, 

395 name, 

396 lum_fct_parametrization, 

397 m_star_slope, 

398 m_star_intcpt, 

399 phi_star_amp, 

400 phi_star_exp, 

401 z_const, 

402 alpha, 

403 cosmo, 

404 pixarea, 

405 galaxy_type, 

406 seed_ngal, 

407 z_res=0.001, 

408 m_res=0.001, 

409 z_min=0, 

410 z_max=6, 

411 m_min=None, 

412 m_max=2, 

413 z_m_intp=None, 

414 ngal_multiplier=1, 

415 ): 

416 """ 

417 Initializes the luminosity function. 

418 

419 :param lum_fct_parametrization: Luminosity function parametrization 

420 :param m_star_slope: redshift evolution parameter for M* - z relation 

421 :param m_star_intcpt: amplitude of M* at z=0 

422 :param phi_star_amp: amplitude of phi* at z=0 

423 :param phi_star_exp: redshift evolution parameter for phi* - z relation 

424 :param z_const: redshift constant for truncated logexp parametrization 

425 :param alpha: luminosity function slope 

426 :param cosmo: PyCosmo cosmology object 

427 :param pixarea: Sky area in square radians 

428 :param galaxy_type: Galaxy type 

429 :param seed_ngal: Seed for random number generator 

430 :param z_res: Redshift resolution 

431 :param m_res: Absolute magnitude resolution 

432 :param z_min: Minimum redshift 

433 :param z_max: Maximum redshift 

434 :param m_min: Minimum absolute magnitude (brightest) 

435 :param m_max: Maximum absolute magnitude (faintest) 

436 :param z_m_intp: Redshift - absolute magnitude interpolation function 

437 :param ngal_multiplier: Multiplier for the number of galaxies 

438 """ 

439 self.parametrization = lum_fct_parametrization 

440 self.m_star_slope = m_star_slope 

441 self.m_star_intcpt = m_star_intcpt 

442 self.phi_star_amp = phi_star_amp 

443 self.phi_star_exp = phi_star_exp 

444 self.m_star_par = m_star_slope, m_star_intcpt 

445 self.phi_star_par = phi_star_amp, phi_star_exp 

446 self.z_const = z_const 

447 self.alpha = alpha 

448 self.z_m_intp = z_m_intp 

449 self.m_min = m_min 

450 self.m_max = m_max 

451 self.cosmo = cosmo 

452 self.pixarea = pixarea 

453 self.z_res = z_res 

454 self.m_res = m_res 

455 self.z_min = z_min 

456 self.name = name 

457 self.galaxy_type = galaxy_type 

458 self.ngal_multiplier = ngal_multiplier 

459 

460 self.z_max = maximum_redshift( 

461 z_m_intp=z_m_intp, 

462 m_max=m_max, 

463 z_max=z_max, 

464 parametrization=lum_fct_parametrization, 

465 z_const=z_const, 

466 alpha=alpha, 

467 m_star_par=self.m_star_par, 

468 seed_ngal=seed_ngal, 

469 ) 

470 

471 self.n_gal_calc = NumGalCalculator( 

472 z_min=self.z_min, 

473 z_max=self.z_max, 

474 m_min=m_min, 

475 m_max=m_max, 

476 parametrization=lum_fct_parametrization, 

477 z_const=z_const, 

478 alpha=alpha, 

479 m_star_par=self.m_star_par, 

480 phi_star_par=self.phi_star_par, 

481 cosmo=cosmo, 

482 pixarea=pixarea, 

483 ngal_multiplier=ngal_multiplier, 

484 ) 

485 

486 self.z_mabs_sampler = RedshiftAbsMagSampler( 

487 z_res=z_res, 

488 z_min=self.z_min, 

489 z_max=self.z_max, 

490 parametrization=lum_fct_parametrization, 

491 z_const=z_const, 

492 m_res=m_res, 

493 m_min=m_min, 

494 m_max=m_max, 

495 alpha=alpha, 

496 m_star_par=self.m_star_par, 

497 phi_star_par=self.phi_star_par, 

498 cosmo=cosmo, 

499 ) 

500 

501 def sample_z_mabs_and_apply_cut( 

502 self, 

503 seed_ngal, 

504 seed_lumfun, 

505 n_gal_max=np.inf, 

506 size_chunk=10000, 

507 ): 

508 """ 

509 This function gets the abs mag and z using chunking, which uses less memory than 

510 the original method. It does not give exactly the same result as before due to 

511 different order of random draws in z_mabs_sampler, but it's the same sample. 

512 """ 

513 np.random.seed(seed_ngal) 

514 n_gal = self.n_gal_calc() 

515 if n_gal == 0: 

516 return np.array([]), np.array([]) 

517 n_chunks = int(np.ceil(float(n_gal) / float(size_chunk))) 

518 list_abs_mag = [] 

519 list_z = [] 

520 n_gal_final = 0 

521 for ic in range(n_chunks): 

522 n_gal_sample = n_gal % size_chunk if ic + 1 == n_chunks else size_chunk 

523 z_chunk, abs_mag_chunk = self.z_mabs_sampler(n_gal_sample) 

524 # Apply cut in z - M - plane 

525 if self.z_m_intp is not None: 

526 select = abs_mag_chunk < self.z_m_intp(z_chunk) 

527 z_chunk = z_chunk[select] 

528 abs_mag_chunk = abs_mag_chunk[select] 

529 

530 n_gal_final += len(z_chunk) 

531 list_z.append(z_chunk) 

532 list_abs_mag.append(abs_mag_chunk) 

533 

534 if n_gal_final > n_gal_max: 

535 break 

536 

537 z = np.hstack(list_z) 

538 abs_mag = np.hstack(list_abs_mag) 

539 return abs_mag, z 

540 

541 def analytical_number_density(self, m, z): 

542 """ 

543 Returns the number density of galaxies at redshift z and absolute magnitude m. 

544 Classic Schechter function with units of Mpc^-3 mag^-1. 

545 

546 :param m: Absolute magnitude 

547 :param z: Redshift 

548 :return: Number density of galaxies at redshift z and absolute magnitude m 

549 """ 

550 m_star = m_star_lum_fct(z, self.parametrization, self.z_const, *self.m_star_par) 

551 phi_star = phi_star_lum_fct(z, self.parametrization, *self.phi_star_par) 

552 return ( 

553 0.4 

554 * np.log(10) 

555 * ( 

556 phi_star 

557 * (10 ** (0.4 * (m_star - m))) ** (self.alpha + 1) 

558 * np.exp(-(10 ** (0.4 * (m_star - m)))) 

559 ) 

560 )