cosmic-popsynth 3.4.15__cp310-cp310-macosx_11_0_arm64.whl

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,1193 @@
1
+ # -*- coding: utf-8 -*-
2
+ # Copyright (C) Katelyn Breivik (2017 - 2021)
3
+ #
4
+ # This file is part of cosmic.
5
+ #
6
+ # cosmic is free software: you can redistribute it and/or modify
7
+ # it under the terms of the GNU General Public License as published by
8
+ # the Free Software Foundation, either version 3 of the License, or
9
+ # (at your option) any later version.
10
+ #
11
+ # cosmic is distributed in the hope that it will be useful,
12
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
13
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14
+ # GNU General Public License for more details.
15
+ #
16
+ # You should have received a copy of the GNU General Public License
17
+ # along with cosmic. If not, see <http://www.gnu.org/licenses/>.
18
+
19
+ """`independent`
20
+ """
21
+
22
+ import numpy as np
23
+ import warnings
24
+ import pandas as pd
25
+
26
+ from cosmic import utils
27
+
28
+ from .sampler import register_sampler
29
+ from .. import InitialBinaryTable
30
+
31
+
32
+ __author__ = "Katelyn Breivik <katie.breivik@gmail.com>"
33
+ __credits__ = ("Scott Coughlin <scott.coughlin@ligo.org>, Michael Zevin <michael.j.zevin@gmail.com>, "
34
+ "Tom Wagg <tomjwagg@gmail.com>")
35
+ __all__ = ["get_independent_sampler", "Sample"]
36
+
37
+
38
+ def get_independent_sampler(
39
+ final_kstar1,
40
+ final_kstar2,
41
+ primary_model,
42
+ ecc_model,
43
+ porb_model,
44
+ SF_start,
45
+ SF_duration,
46
+ binfrac_model,
47
+ met,
48
+ size=None,
49
+ total_mass=np.inf,
50
+ sampling_target="size",
51
+ trim_extra_samples=False,
52
+ q_power_law=0,
53
+ **kwargs
54
+ ):
55
+ """Generates an initial binary sample according to user specified models
56
+
57
+ Parameters
58
+ ----------
59
+ final_kstar1 : `int or list`
60
+ Int or list of final kstar1
61
+
62
+ final_kstar2 : `int or list`
63
+ Int or list of final kstar2
64
+
65
+ primary_model : `str`
66
+ Model to sample primary mass; choices include: kroupa93, kroupa01, salpeter55, custom
67
+ if 'custom' is selected, must also pass arguemts:
68
+ alphas : `array`
69
+ list of power law indicies
70
+ mcuts : `array`
71
+ breaks in the power laws.
72
+ e.g. alphas=[-1.3,-2.3,-2.3],mcuts=[0.08,0.5,1.0,150.] reproduces standard Kroupa2001 IMF
73
+
74
+ ecc_model : `str`
75
+ Model to sample eccentricity; choices include: thermal, uniform, sana12
76
+
77
+ porb_model : `str` or `dict`
78
+ Model to sample orbital period; choices include: log_uniform, sana12, raghavan10, moe19
79
+ or a custom power law distribution defined with a dictionary with keys "min", "max", and "slope"
80
+ (e.g. {"min": 0.15, "max": 0.55, "slope": -0.55}) would reproduce the Sana+2012 distribution
81
+
82
+ qmin : `float`
83
+ kwarg which sets the minimum mass ratio for sampling the secondary
84
+ where the mass ratio distribution is flat in q
85
+ if q > 0, qmin sets the minimum mass ratio
86
+ q = -1, this limits the minimum mass ratio to be set such that
87
+ the pre-MS lifetime of the secondary is not longer than the full
88
+ lifetime of the primary if it were to evolve as a single star
89
+
90
+ m_max : `float`
91
+ kwarg which sets the maximum primary and secondary mass for sampling
92
+ NOTE: this value changes the range of the IMF and should *not* be used
93
+ as a means of selecting certain kstar types!
94
+
95
+ m1_min : `float`
96
+ kwarg which sets the minimum primary mass for sampling
97
+ NOTE: this value changes the range of the IMF and should *not* be used
98
+ as a means of selecting certain kstar types!
99
+
100
+ m2_min : `float`
101
+ kwarg which sets the minimum secondary mass for sampling
102
+ the secondary as uniform in mass_2 between m2_min and mass_1
103
+
104
+ msort : `float`
105
+ Stars with M>msort can have different pairing and sampling of companions
106
+
107
+ qmin_msort : `float`
108
+ Same as qmin for M>msort
109
+
110
+ m2_min_msort : `float`
111
+ Same as m2_min for M>msort
112
+
113
+ SF_start : `float`
114
+ Time in the past when star formation initiates in Myr
115
+
116
+ SF_duration : `float`
117
+ Duration of constant star formation beginning from SF_Start in Myr
118
+
119
+ binfrac_model : `str or float`
120
+ Model for binary fraction; choices include: vanHaaften, offner22, or a fraction where 1.0 is 100% binaries
121
+
122
+ binfrac_model_msort : `str or float`
123
+ Same as binfrac_model for M>msort
124
+
125
+ met : `float`
126
+ Sets the metallicity of the binary population where solar metallicity is zsun
127
+
128
+ size : `int`
129
+ Size of the population to sample
130
+
131
+ total_mass : `float`
132
+ Total mass to use as a target for sampling
133
+
134
+ sampling_target : `str`
135
+ Which type of target to use for sampling (either "size" or "total_mass"), by default "size".
136
+ Note that `total_mass` must not be None when `sampling_target=="total_mass"`.
137
+
138
+ trim_extra_samples : `str`
139
+ Whether to trim the sampled population so that the total mass sampled is as close as possible to
140
+ `total_mass`. Ignored when `sampling_target==size`.
141
+ Note that given the discrete mass of stars, this could mean your sample is off by 300
142
+ solar masses in the worst case scenario (of a 150+150 binary being sampled). In reality the majority
143
+ of cases track the target total mass to within a solar mass.
144
+
145
+ zsun : `float`
146
+ optional kwarg for setting effective radii, default is 0.02
147
+
148
+ q_power_law : `float`
149
+ Exponent for the mass ratio distribution power law, default is 0 (flat in q). Note that
150
+ q_power_law cannot be exactly -1, as this would result in a divergent distribution.
151
+
152
+
153
+ Returns
154
+ -------
155
+ InitialBinaryTable : `pandas.DataFrame`
156
+ DataFrame in the format of the InitialBinaryTable
157
+
158
+ mass_singles : `float`
159
+ Total mass in single stars needed to generate population
160
+
161
+ mass_binaries : `float`
162
+ Total mass in binaries needed to generate population
163
+
164
+ n_singles : `int`
165
+ Number of single stars needed to generate a population
166
+
167
+ n_binaries : `int`
168
+ Number of binaries needed to generate a population
169
+ """
170
+ if sampling_target == "total_mass" and (total_mass is None or total_mass == np.inf):
171
+ raise ValueError("If `sampling_target == 'total mass'` then `total_mass` must be supplied")
172
+ if size is None and (total_mass is None or total_mass == np.inf):
173
+ raise ValueError("Either a sample `size` or `total_mass` must be supplied")
174
+ elif size is None:
175
+ size = int(total_mass)
176
+
177
+ if binfrac_model == 0.0 and sampling_target == "size":
178
+ raise ValueError(("If `binfrac_model == 0.0` then `sampling_target` must be 'total_mass'. Otherwise "
179
+ "you are targetting a population of `size` binaries but will never select any."))
180
+
181
+ final_kstar1 = [final_kstar1] if isinstance(final_kstar1, (int, float)) else final_kstar1
182
+ final_kstar2 = [final_kstar2] if isinstance(final_kstar2, (int, float)) else final_kstar2
183
+ primary_min, primary_max, secondary_min, secondary_max = utils.mass_min_max_select(
184
+ final_kstar1, final_kstar2, **kwargs)
185
+ initconditions = Sample()
186
+
187
+ # set up multiplier if the mass sampling is inefficient
188
+ multiplier = 1
189
+
190
+ # track samples to actually return (after masks)
191
+ mass1_singles = []
192
+ mass1_binary = []
193
+ mass2_binary = []
194
+ binfrac = []
195
+
196
+ # track the total mass of singles and binaries sampled
197
+ m_sampled_singles = 0.0
198
+ m_sampled_binaries = 0.0
199
+
200
+ # track the total number of stars sampled
201
+ n_singles = 0
202
+ n_binaries = 0
203
+
204
+ # if porb_model = `moe19`, the binary fraction is fixed based on the metallicity
205
+ if porb_model == "moe19":
206
+ binfrac_model = utils.get_met_dep_binfrac(met)
207
+ warnings.warn('your supplied binfrac_model has been overwritten to {} match Moe+2019'.format(binfrac_model))
208
+
209
+ # define a function that evaluates whether you've reached your sampling target
210
+ target = lambda mass1_binary, size, m_sampled_singles, m_sampled_binaries, total_mass:\
211
+ len(mass1_binary) < size if sampling_target == "size" else m_sampled_singles + m_sampled_binaries < total_mass
212
+
213
+ # sample until you've reached your target
214
+ while target(mass1_binary, size, m_sampled_singles, m_sampled_binaries, total_mass):
215
+ # sample primary masses
216
+ mass1, _ = initconditions.sample_primary(primary_model, size=int(size * multiplier), **kwargs)
217
+
218
+ # split them into binaries or single stars
219
+ (mass1_binaries, mass_single, binfrac_binaries, binary_index,
220
+ ) = initconditions.binary_select(mass1, binfrac_model=binfrac_model, **kwargs)
221
+
222
+ # sample secondary masses for the single stars
223
+ mass2_binaries = initconditions.sample_secondary(mass1_binaries, q_power_law=q_power_law, **kwargs)
224
+
225
+ # check if this batch of samples will take us over our sampling target
226
+ if not target(mass1_binary, size,
227
+ m_sampled_singles + np.sum(mass_single),
228
+ m_sampled_binaries + np.sum(mass1_binaries) + np.sum(mass2_binaries),
229
+ total_mass) and trim_extra_samples and sampling_target == "total_mass":
230
+ # get the cumulative total mass of the samples
231
+ total_mass_list = np.copy(mass1)
232
+ total_mass_list[binary_index] += mass2_binaries
233
+ sampled_so_far = m_sampled_singles + m_sampled_binaries
234
+ cumulative_total_mass = sampled_so_far + np.cumsum(total_mass_list)
235
+
236
+ # find the boundary for reaching the right total mass
237
+ threshold_index = np.where(cumulative_total_mass > total_mass)[0][0]
238
+
239
+ keep_offset = abs(cumulative_total_mass[threshold_index] - total_mass)
240
+ drop_offset = abs(cumulative_total_mass[threshold_index - 1] - total_mass)
241
+ lim = threshold_index - 1 if (keep_offset > drop_offset) else threshold_index
242
+
243
+ # work out how many singles vs. binaries to delete
244
+ one_if_binary = np.zeros(len(mass1))
245
+ one_if_binary[binary_index] = 1
246
+ sb_delete = one_if_binary[lim + 1:]
247
+ n_single_delete = (sb_delete == 0).sum()
248
+ n_binary_delete = (sb_delete == 1).sum()
249
+
250
+ # delete em!
251
+ if n_single_delete > 0:
252
+ mass_single = mass_single[:-n_single_delete]
253
+ if n_binary_delete > 0:
254
+ mass1_binaries = mass1_binaries[:-n_binary_delete]
255
+ mass2_binaries = mass2_binaries[:-n_binary_delete]
256
+ binfrac_binaries = binfrac_binaries[:-n_binary_delete]
257
+
258
+ # ensure we don't loop again after this
259
+ target = lambda mass1_binary, size, m_sampled_singles, m_sampled_binaries, total_mass: False
260
+
261
+ # track the mass sampled
262
+ m_sampled_singles += sum(mass_single)
263
+ m_sampled_binaries += sum(mass1_binaries)
264
+ m_sampled_binaries += sum(mass2_binaries)
265
+
266
+ # track the total number sampled
267
+ n_singles += len(mass_single)
268
+ n_binaries += len(mass1_binaries)
269
+
270
+ # select out the primaries and secondaries that will produce the final kstars
271
+ ind_select = ( (mass1_binaries > primary_min)
272
+ & (mass1_binaries < primary_max)
273
+ & (mass2_binaries > secondary_min)
274
+ & (mass2_binaries < secondary_max))
275
+ mass1_binary.extend(mass1_binaries[ind_select])
276
+ mass2_binary.extend(mass2_binaries[ind_select])
277
+ binfrac.extend(binfrac_binaries[ind_select])
278
+
279
+ # select out the single stars that will produce the final kstar
280
+ mass1_singles.extend(mass_single[(mass_single > primary_min) & (mass_single < primary_max)])
281
+
282
+ # check to see if we should increase the multiplier factor to sample the population more quickly
283
+ if target(mass1_binary, size / 100, m_sampled_singles, m_sampled_binaries, total_mass / 100):
284
+ # well this sampling rate is clearly not working time to increase
285
+ # the multiplier by an order of magnitude
286
+ multiplier *= 10
287
+
288
+ mass1_binary = np.array(mass1_binary)
289
+ mass2_binary = np.array(mass2_binary)
290
+ binfrac = np.asarray(binfrac)
291
+ mass1_singles = np.asarray(mass1_singles)
292
+
293
+ zsun = kwargs.pop("zsun", 0.02)
294
+
295
+ rad1 = initconditions.set_reff(mass1_binary, metallicity=met, zsun=zsun)
296
+ rad2 = initconditions.set_reff(mass2_binary, metallicity=met, zsun=zsun)
297
+
298
+ # sample periods and eccentricities
299
+ # if the porb_model is moe19, the metallicity needs to be supplied
300
+ if porb_model == "moe19":
301
+ porb,aRL_over_a = initconditions.sample_porb(
302
+ mass1_binary, mass2_binary, rad1, rad2, porb_model, met=met, size=mass1_binary.size
303
+ )
304
+ else:
305
+ porb,aRL_over_a = initconditions.sample_porb(
306
+ mass1_binary, mass2_binary, rad1, rad2, porb_model, size=mass1_binary.size
307
+ )
308
+ ecc = initconditions.sample_ecc(aRL_over_a, ecc_model, size=mass1_binary.size)
309
+
310
+ tphysf, metallicity = initconditions.sample_SFH(
311
+ SF_start=SF_start, SF_duration=SF_duration, met=met, size=mass1_binary.size
312
+ )
313
+ metallicity[metallicity < 1e-4] = 1e-4
314
+ metallicity[metallicity > 0.03] = 0.03
315
+ kstar1 = initconditions.set_kstar(mass1_binary)
316
+ kstar2 = initconditions.set_kstar(mass2_binary)
317
+
318
+ if kwargs.pop("keep_singles", False):
319
+ binary_table = InitialBinaryTable.InitialBinaries(
320
+ mass1_binary,
321
+ mass2_binary,
322
+ porb,
323
+ ecc,
324
+ tphysf,
325
+ kstar1,
326
+ kstar2,
327
+ metallicity,
328
+ binfrac=binfrac,
329
+ )
330
+ tphysf_singles, metallicity_singles = initconditions.sample_SFH(
331
+ SF_start=SF_start, SF_duration=SF_duration, met=met, size=mass1_singles.size
332
+ )
333
+ metallicity_singles[metallicity_singles < 1e-4] = 1e-4
334
+ metallicity_singles[metallicity_singles > 0.03] = 0.03
335
+ kstar1_singles = initconditions.set_kstar(mass1_singles)
336
+ singles_table = InitialBinaryTable.InitialBinaries(
337
+ mass1_singles, # mass1
338
+ np.ones_like(mass1_singles) * 0, # mass2 (all massless remnants)
339
+ np.ones_like(mass1_singles) * -1, # porb (single not binary)
340
+ np.ones_like(mass1_singles) * -1, # ecc (single not binary)
341
+ tphysf_singles, # tphysf
342
+ kstar1_singles, # kstar1
343
+ np.ones_like(mass1_singles) * 15, # kstar2 (all massless remnants)
344
+ metallicity_singles, # metallicity
345
+ )
346
+ binary_table = pd.concat([binary_table, singles_table])
347
+ else:
348
+ binary_table = InitialBinaryTable.InitialBinaries(
349
+ mass1_binary,
350
+ mass2_binary,
351
+ porb,
352
+ ecc,
353
+ tphysf,
354
+ kstar1,
355
+ kstar2,
356
+ metallicity,
357
+ binfrac=binfrac,
358
+ )
359
+
360
+ return (
361
+ binary_table,
362
+ m_sampled_singles,
363
+ m_sampled_binaries,
364
+ n_singles,
365
+ n_binaries,
366
+ )
367
+
368
+
369
+ register_sampler(
370
+ "independent",
371
+ InitialBinaryTable,
372
+ get_independent_sampler,
373
+ usage="final_kstar1, final_kstar2, binfrac_model, primary_model, ecc_model, SFH_model, component_age, metallicity, size",
374
+ )
375
+
376
+
377
+ class Sample(object):
378
+ # sample primary masses
379
+ def sample_primary(self, primary_model='kroupa01', size=None, **kwargs):
380
+ """Sample the primary mass (always the most massive star) from a user-selected model
381
+
382
+ kroupa93 follows Kroupa (1993), normalization comes from
383
+ `Hurley 2002 <https://arxiv.org/abs/astro-ph/0201220>`_
384
+ between 0.08 and 150 Msun
385
+ salpter55 follows
386
+ `Salpeter (1955) <http://adsabs.harvard.edu/abs/1955ApJ...121..161S>`_
387
+ between 0.08 and 150 Msun
388
+ kroupa01 follows Kroupa (2001) <https://arxiv.org/abs/astro-ph/0009005>
389
+ between 0.08 and 100 Msun
390
+
391
+
392
+ Parameters
393
+ ----------
394
+ primary_model : str, optional
395
+ model for mass distribution; choose from:
396
+
397
+ kroupa93 follows Kroupa (1993), normalization comes from
398
+ `Hurley 2002 <https://arxiv.org/abs/astro-ph/0201220>`_
399
+ valid for masses between 0.1 and 100 Msun
400
+
401
+ salpter55 follows
402
+ `Salpeter (1955) <http://adsabs.harvard.edu/abs/1955ApJ...121..161S>`_
403
+ valid for masses between 0.1 and 100 Msun
404
+
405
+ kroupa01 follows Kroupa (2001), normalization comes from
406
+ `Hurley 2002 <https://arxiv.org/abs/astro-ph/0009005>`_
407
+ valid for masses between 0.1 and 100 Msun
408
+
409
+ custom is a generic piecewise power law that takes in the power
410
+ law slopes and break points given in the optional input lists (alphas, mcuts)
411
+ default alphas and mcuts yield an IMF identical to kroupa01
412
+
413
+ Default kroupa01
414
+
415
+ size : int, optional
416
+ number of initial primary masses to sample
417
+ NOTE: this is set in cosmic-pop call as Nstep
418
+
419
+ alphas : array, optional
420
+ absolute values of the power law slopes for primary_model = 'custom'
421
+ Default [-1.3,-2.3,-2.3] (identical to slopes for primary_model = 'kroupa01')
422
+
423
+ mcuts : array, optional, units of Msun
424
+ break points separating the power law 'pieces' for primary_model = 'custom'
425
+ Default [0.08,0.5,1.0,150.] (identical to breaks for primary_model = 'kroupa01')
426
+
427
+ Optional kwargs are defined in `get_independent_sampler`
428
+
429
+ Returns
430
+ -------
431
+ a_0 : array
432
+ Sampled primary masses
433
+ np.sum(a_0) : float
434
+ Total amount of mass sampled
435
+ """
436
+
437
+ # Read in m1_min and m_max kwargs, if provided
438
+ m1_min = kwargs["m1_min"] if "m1_min" in kwargs.keys() else 0.08
439
+ m_max = kwargs["m_max"] if "m_max" in kwargs.keys() else 150.0
440
+
441
+ # Make sure m1_min value is below 0.5, since otherwise it will not work for Kroupa IMF
442
+ if m1_min > 0.5:
443
+ raise ValueError("m1_min must be greater than 0.5 Msun")
444
+
445
+ if primary_model == 'kroupa93':
446
+ alphas, mcuts = [-1.3,-2.2,-2.7], [m1_min,0.5,1.0,m_max]
447
+ # Since COSMIC/BSE can't handle < 0.08Msun, we by default truncate at 0.08 Msun instead of 0.01
448
+ elif primary_model == 'kroupa01':
449
+ alphas, mcuts = [-1.3,-2.3], [m1_min,0.5,m_max]
450
+ elif primary_model == 'salpeter55':
451
+ alphas, mcuts = [-2.35], [m1_min,m_max]
452
+ elif primary_model == 'custom':
453
+ if 'alphas' in kwargs and 'mcuts' in kwargs:
454
+ alphas = kwargs.pop("alphas", [-1.3,-2.3,-2.3])
455
+ mcuts = kwargs.pop("mcuts", [m1_min,0.5,1.0,m_max])
456
+ else:
457
+ raise ValueError("You must supply both alphas and mcuts to use"
458
+ " a custom IMF generator")
459
+
460
+ Ncumulative, Ntotal, coeff = [], 0., 1.
461
+ for i in range(len(alphas)):
462
+ g = 1. + alphas[i]
463
+ # Compute this piece of the IMF's contribution to Ntotal
464
+ if alphas[i] == -1: Ntotal += coeff * np.log(mcuts[i+1]/mcuts[i])
465
+ else: Ntotal += coeff/g * (mcuts[i+1]**g - mcuts[i]**g)
466
+ Ncumulative.append(Ntotal)
467
+ if i < len(alphas)-1: coeff *= mcuts[i+1]**(-alphas[i+1]+alphas[i])
468
+
469
+ cutoffs = np.array(Ncumulative)/Ntotal
470
+ u = np.random.uniform(0.,1.,size)
471
+ idxs = [() for i in range(len(alphas))]
472
+
473
+ for i in range(len(alphas)):
474
+ if i == 0: idxs[i], = np.where(u <= cutoffs[0])
475
+ elif i < len(alphas)-1: idxs[i], = np.where((u > cutoffs[i-1]) & (u <= cutoffs[i]))
476
+ else: idxs[i], = np.where(u > cutoffs[i-1])
477
+ for i in range(len(alphas)):
478
+ if alphas[i] == -1.0:
479
+ u[idxs[i]] = 10**np.random.uniform(np.log10(mcuts[i]),
480
+ np.log10(mcuts[i+1]),
481
+ len(idxs[i]))
482
+ else:
483
+ u[idxs[i]] = utils.rndm(a=mcuts[i], b=mcuts[i+1], g=alphas[i], size=len(idxs[i]))
484
+
485
+ return u, np.sum(u)
486
+
487
+ # sample secondary mass
488
+ def sample_secondary(self, primary_mass, q_power_law=0, **kwargs):
489
+ """Sample a secondary mass using draws from a uniform mass ratio distribution motivated by
490
+ `Mazeh et al. (1992) <http://adsabs.harvard.edu/abs/1992ApJ...401..265M>`_
491
+ and `Goldberg & Mazeh (1994) <http://adsabs.harvard.edu/abs/1994ApJ...429..362G>`_
492
+
493
+ NOTE: the lower lim is set by either qmin or m2_min which are passed as kwargs
494
+
495
+ Parameters
496
+ ----------
497
+ primary_mass : `array`
498
+ sets the maximum secondary mass (for a maximum mass ratio of 1)
499
+
500
+ Optional kwargs are defined in `get_independent_sampler`
501
+
502
+ Returns
503
+ -------
504
+ secondary_mass : array
505
+ sampled secondary masses with array size matching size of
506
+ primary_mass
507
+ """
508
+
509
+ qmin = kwargs["qmin"] if "qmin" in kwargs.keys() else 0.0
510
+ m1_min = kwargs["m1_min"] if "m1_min" in kwargs.keys() else 0.08
511
+ m2_min = kwargs["m2_min"] if "m2_min" in kwargs.keys() else None
512
+ if (m2_min is None) & (qmin is None):
513
+ warnings.warn("It is highly recommended that you specify either qmin or m2_min!")
514
+ if (m2_min is not None) and (m2_min > m1_min):
515
+ raise ValueError("The m2_min you specified is above the minimum"
516
+ " primary mass of the IMF, either lower m2_min or"
517
+ " raise the lower value of your sampled primaries")
518
+
519
+ # --- `msort` kwarg can be set to have different qmin above `msort`
520
+ msort = kwargs["msort"] if "msort" in kwargs.keys() else None
521
+ qmin_msort = kwargs["qmin_msort"] if "qmin_msort" in kwargs.keys() else None
522
+ m2_min_msort = kwargs["m2_min_msort"] if "m2_min_msort" in kwargs.keys() else None
523
+ if (msort is None) and (qmin_msort is not None):
524
+ raise ValueError("If qmin_msort is specified, you must also supply a value for msort")
525
+ if (msort is None) and (m2_min_msort is not None):
526
+ raise ValueError("If m2_min_msort is specified, you must also supply a value for msort")
527
+ if (m2_min_msort is not None) and (m2_min_msort > msort):
528
+ raise ValueError("The m2_min_msort you specified is above the minimum"
529
+ " primary mass of the high-mass binaries msort")
530
+
531
+ if (msort is not None) and (qmin_msort is not None):
532
+ (highmassIdx,) = np.where(primary_mass >= msort)
533
+ (lowmassIdx,) = np.where(primary_mass < msort)
534
+ else:
535
+ (highmassIdx,) = np.where(primary_mass < 0)
536
+ (lowmassIdx,) = np.where(primary_mass >= 0) # all idxs
537
+
538
+
539
+ qmin_vals = -1 * np.ones_like(primary_mass)
540
+ # --- qmin for low-mass systems (all systems if msort is not specified)
541
+ if (qmin > 0.0):
542
+ qmin_vals[lowmassIdx] = qmin * np.ones_like(primary_mass[lowmassIdx])
543
+ elif (qmin < 0.0):
544
+ # mass-dependent qmin, assume qmin=0.1 for m_primary<5
545
+ dat = np.array([[5.0, 0.1363522012578616],
546
+ [6.999999999999993, 0.1363522012578616],
547
+ [12.599999999999994, 0.11874213836477984],
548
+ [20.999999999999993, 0.09962264150943395],
549
+ [29.39999999999999, 0.0820125786163522],
550
+ [41, 0.06490566037735851],
551
+ [55, 0.052327044025157254],
552
+ [70.19999999999999, 0.04301886792452836],
553
+ [87.4, 0.03622641509433966],
554
+ [107.40000000000002, 0.030188679245283068],
555
+ [133.40000000000003, 0.02515723270440262],
556
+ [156.60000000000002, 0.02163522012578628],
557
+ [175.40000000000003, 0.01962264150943399],
558
+ [200.20000000000005, 0.017358490566037776]])
559
+ from scipy.interpolate import interp1d
560
+ qmin_interp = interp1d(dat[:, 0], dat[:, 1])
561
+ qmin_vals[lowmassIdx] = np.ones_like(primary_mass[lowmassIdx]) * 0.1
562
+ ind_5, = np.where(primary_mass[lowmassIdx] > 5.0)
563
+ qmin_vals[lowmassIdx][ind_5] = qmin_interp(primary_mass[lowmassIdx][ind_5])
564
+ else:
565
+ qmin_vals[lowmassIdx] = np.zeros_like(primary_mass[lowmassIdx])
566
+ # --- qmin for high-mass systems, if msort and qmin_msort are specified
567
+ if (msort is not None) and (qmin_msort is not None):
568
+ if (qmin_msort > 0.0):
569
+ qmin_vals[highmassIdx] = qmin_msort * np.ones_like(primary_mass[highmassIdx])
570
+ elif (qmin_msort < 0.0):
571
+ # mass-dependent qmin, assume qmin=0.1 for m_primary<5
572
+ dat = np.array([[5.0, 0.1363522012578616],
573
+ [6.999999999999993, 0.1363522012578616],
574
+ [12.599999999999994, 0.11874213836477984],
575
+ [20.999999999999993, 0.09962264150943395],
576
+ [29.39999999999999, 0.0820125786163522],
577
+ [41, 0.06490566037735851],
578
+ [55, 0.052327044025157254],
579
+ [70.19999999999999, 0.04301886792452836],
580
+ [87.4, 0.03622641509433966],
581
+ [107.40000000000002, 0.030188679245283068],
582
+ [133.40000000000003, 0.02515723270440262],
583
+ [156.60000000000002, 0.02163522012578628],
584
+ [175.40000000000003, 0.01962264150943399],
585
+ [200.20000000000005, 0.017358490566037776]])
586
+ from scipy.interpolate import interp1d
587
+ qmin_interp = interp1d(dat[:, 0], dat[:, 1])
588
+ qmin_vals[highmassIdx] = np.ones_like(primary_mass[highmassIdx]) * 0.1
589
+ ind_5, = np.where(primary_mass[highmassIdx] > 5.0)
590
+ qmin_vals[highmassIdx][ind_5] = qmin_interp(primary_mass[highmassIdx][ind_5])
591
+ else:
592
+ qmin_vals[highmassIdx] = np.zeros_like(primary_mass[highmassIdx])
593
+
594
+ # --- apply m2_min and m2_min_msort, if specified
595
+ if m2_min is not None:
596
+ qmin_vals[lowmassIdx] = np.maximum(qmin_vals[lowmassIdx], m2_min/primary_mass[lowmassIdx])
597
+ if m2_min_msort is not None:
598
+ qmin_vals[highmassIdx] = np.maximum(qmin_vals[highmassIdx], m2_min_msort/primary_mass[highmassIdx])
599
+
600
+ # --- now, randomly sample mass ratios and get secondary masses
601
+ secondary_mass = utils.rndm(qmin_vals, 1, q_power_law, size=len(primary_mass)) * primary_mass
602
+ return secondary_mass
603
+
604
+ def binary_select(self, primary_mass, binfrac_model=0.5, **kwargs):
605
+ """Select which primary masses will have a companion using
606
+ either a binary fraction specified by a float or a
607
+ primary-mass dependent binary fraction following
608
+ `van Haaften et al.(2009) <http://adsabs.harvard.edu/abs/2013A%26A...552A..69V>`_ in appdx
609
+ or `Offner et al.(2022) <https://arxiv.org/abs/2203.10066>`_ in fig 1
610
+
611
+ Parameters
612
+ ----------
613
+ primary_mass : array
614
+ Mass that determines the binary fraction
615
+
616
+ binfrac_model : str or float
617
+ vanHaaften - primary mass dependent and ONLY VALID up to 100 Msun
618
+
619
+ offner22 - primary mass dependent
620
+
621
+ float - fraction of binaries; 0.5 means 2 in 3 stars are a binary pair while 1
622
+ means every star is in a binary pair
623
+
624
+ Optional kwargs are defined in `get_independent_sampler`
625
+
626
+ Returns
627
+ -------
628
+ stars_in_binary : array
629
+ primary masses that will have a binary companion
630
+
631
+ stars_in_single : array
632
+ primary masses that will be single stars
633
+
634
+ binary_fraction : array
635
+ system-specific probability of being in a binary
636
+
637
+ binaryIdx : array
638
+ Idx of stars in binary
639
+ """
640
+
641
+ # --- `msort` kwarg can be set to have different binary fraction above `msort`
642
+ msort = kwargs["msort"] if "msort" in kwargs.keys() else None
643
+ binfrac_model_msort = kwargs["binfrac_model_msort"] if "binfrac_model_msort" in kwargs.keys() else None
644
+ if (msort is None) and (binfrac_model_msort is not None):
645
+ raise ValueError("If binfrac_model_msort is specified, you must also supply a value for msort")
646
+ if (msort is not None) and (binfrac_model_msort is not None):
647
+ (highmassIdx,) = np.where(primary_mass >= msort)
648
+ (lowmassIdx,) = np.where(primary_mass < msort)
649
+ else:
650
+ (highmassIdx,) = np.where(primary_mass < 0)
651
+ (lowmassIdx,) = np.where(primary_mass >= 0) # all idxs
652
+
653
+
654
+ # --- read in binfrac models
655
+ if type(binfrac_model) == str:
656
+ if binfrac_model == "vanHaaften":
657
+ binary_fraction_low = 1 / 2.0 + 1 / \
658
+ 4.0 * np.log10(primary_mass[lowmassIdx])
659
+ binary_choose_low = np.random.uniform(
660
+ 0, 1.0, primary_mass[lowmassIdx].size)
661
+
662
+ (singleIdx_low,) = np.where(
663
+ binary_fraction_low < binary_choose_low)
664
+ (binaryIdx_low,) = np.where(
665
+ binary_fraction_low >= binary_choose_low)
666
+ elif binfrac_model == "offner22":
667
+ from scipy.interpolate import BSpline
668
+ t = [0.0331963853, 0.0331963853, 0.0331963853, 0.0331963853, 0.106066017,
669
+ 0.212132034, 0.424264069, 0.866025404, 1.03077641, 1.11803399,
670
+ 1.95959179, 3.87298335, 6.32455532, 11.6619038, 29.1547595,
671
+ 40, 50, 60, 70, 80, 90, 100, 110, 120, 130, 150, 150, 150, 150]
672
+ c = [0.08, 0.15812003, 0.20314101, 0.23842953, 0.33154153, 0.39131739,
673
+ 0.46020725, 0.59009569, 0.75306454, 0.81652502, 0.93518422, 0.92030594,
674
+ 0.96, 0.96, 0.96, 0.96, 0.96, 0.96, 0.96, 0.96, 0.96, 0.96, 0.96, 0.96, 0.96]
675
+ k = 3
676
+ def offner_curve(x):
677
+ a = -0.16465041
678
+ b = -0.11616329
679
+ return np.piecewise(x, [x < 6.4, x >= 6.4], [BSpline(t,c,k), lambda x : a * np.exp(b * x) + 0.97])
680
+ binary_fraction_low = offner_curve(primary_mass[lowmassIdx])
681
+ binary_choose_low = np.random.uniform(
682
+ 0, 1.0, primary_mass[lowmassIdx].size)
683
+
684
+ (singleIdx_low,) = np.where(
685
+ binary_fraction_low < binary_choose_low)
686
+ (binaryIdx_low,) = np.where(
687
+ binary_fraction_low >= binary_choose_low)
688
+ else:
689
+ raise ValueError(
690
+ "You have supplied a non-supported binary fraction model. Please choose vanHaaften, offner22, or a float"
691
+ )
692
+ elif type(binfrac_model) == float:
693
+ if (binfrac_model <= 1.0) & (binfrac_model >= 0.0):
694
+ binary_fraction_low = binfrac_model * \
695
+ np.ones(primary_mass[lowmassIdx].size)
696
+ binary_choose_low = np.random.uniform(
697
+ 0, 1.0, primary_mass[lowmassIdx].size)
698
+
699
+ (singleIdx_low,) = np.where(
700
+ binary_choose_low > binary_fraction_low)
701
+ (binaryIdx_low,) = np.where(
702
+ binary_choose_low <= binary_fraction_low)
703
+ else:
704
+ raise ValueError(
705
+ "You have supplied a fraction outside of 0-1. Please choose a fraction between 0 and 1."
706
+ )
707
+ else:
708
+ raise ValueError(
709
+ "You have not supplied a model or a fraction. Please choose either vanHaaften, offner22, or a float"
710
+ )
711
+
712
+ # --- if using a different binary fraction for high-mass systems
713
+ if (binfrac_model_msort is not None) and (type(binfrac_model_msort) == str):
714
+ if binfrac_model_msort == "vanHaaften":
715
+ binary_fraction_high = 1 / 2.0 + 1 / \
716
+ 4.0 * np.log10(primary_mass[highmassIdx])
717
+ binary_choose_high = np.random.uniform(
718
+ 0, 1.0, primary_mass[highmassIdx].size)
719
+
720
+ (singleIdx_high,) = np.where(
721
+ binary_fraction_high < binary_choose_high)
722
+ (binaryIdx_high,) = np.where(
723
+ binary_fraction_high >= binary_choose_high)
724
+ elif binfrac_model_msort == "offner22":
725
+ from scipy.interpolate import BSpline
726
+ t = [0.0331963853, 0.0331963853, 0.0331963853, 0.0331963853, 0.106066017,
727
+ 0.212132034, 0.424264069, 0.866025404, 1.03077641, 1.11803399,
728
+ 1.95959179, 3.87298335, 6.32455532, 11.6619038, 29.1547595,
729
+ 40, 50, 60, 70, 80, 90, 100, 110, 120, 130, 150, 150, 150, 150]
730
+ c = [0.08, 0.15812003, 0.20314101, 0.23842953, 0.33154153, 0.39131739,
731
+ 0.46020725, 0.59009569, 0.75306454, 0.81652502, 0.93518422, 0.92030594,
732
+ 0.96, 0.96, 0.96, 0.96, 0.96, 0.96, 0.96, 0.96, 0.96, 0.96, 0.96, 0.96, 0.96]
733
+ k = 3
734
+ def offner_curve(x):
735
+ a = -0.16465041
736
+ b = -0.11616329
737
+ return np.piecewise(x, [x < 6.4, x >= 6.4], [BSpline(t,c,k), lambda x : a * np.exp(b * x) + 0.97])
738
+ binary_fraction_high = offner_curve(primary_mass[highmassIdx])
739
+ binary_choose_high = np.random.uniform(
740
+ 0, 1.0, primary_mass[highmassIdx].size)
741
+
742
+ (singleIdx_high,) = np.where(
743
+ binary_fraction_high < binary_choose_high)
744
+ (binaryIdx_high,) = np.where(
745
+ binary_fraction_high >= binary_choose_high)
746
+ else:
747
+ raise ValueError(
748
+ "You have supplied a non-supported binary fraction model. Please choose vanHaaften, offner22, or a float"
749
+ )
750
+ elif (binfrac_model_msort is not None) and (type(binfrac_model_msort) == float):
751
+ if (binfrac_model_msort <= 1.0) & (binfrac_model_msort >= 0.0):
752
+ binary_fraction_high = binfrac_model_msort * \
753
+ np.ones(primary_mass[highmassIdx].size)
754
+ binary_choose_high = np.random.uniform(
755
+ 0, 1.0, primary_mass[highmassIdx].size)
756
+
757
+ (singleIdx_high,) = np.where(
758
+ binary_choose_high > binary_fraction_high)
759
+ (binaryIdx_high,) = np.where(
760
+ binary_choose_high <= binary_fraction_high)
761
+ else:
762
+ raise ValueError(
763
+ "You have supplied a fraction outside of 0-1. Please choose a fraction between 0 and 1."
764
+ )
765
+ elif (binfrac_model_msort is not None):
766
+ raise ValueError(
767
+ "You have not supplied a model or a fraction. Please choose either vanHaaften, offner22, or a float"
768
+ )
769
+
770
+
771
+ # --- get pertinent info
772
+ if (binfrac_model_msort is not None):
773
+ stars_in_binary = np.append(
774
+ primary_mass[highmassIdx][binaryIdx_high], primary_mass[lowmassIdx][binaryIdx_low])
775
+ stars_in_single = np.append(
776
+ primary_mass[highmassIdx][singleIdx_high], primary_mass[lowmassIdx][singleIdx_low])
777
+ binary_fraction = np.append(
778
+ binary_fraction_high[binaryIdx_high], binary_fraction_low[binaryIdx_low])
779
+ binaryIdx = np.append(
780
+ highmassIdx[binaryIdx_high], lowmassIdx[binaryIdx_low])
781
+ else:
782
+ stars_in_binary = primary_mass[lowmassIdx][binaryIdx_low]
783
+ stars_in_single = primary_mass[lowmassIdx][singleIdx_low]
784
+ binary_fraction = binary_fraction_low[binaryIdx_low]
785
+ binaryIdx = lowmassIdx[binaryIdx_low]
786
+
787
+ return (
788
+ stars_in_binary,
789
+ stars_in_single,
790
+ binary_fraction,
791
+ binaryIdx,
792
+ )
793
+
794
+ def sample_porb(self, mass1, mass2, rad1, rad2, porb_model, porb_max=None, size=None, **kwargs):
795
+ """Sample the orbital period according to the user-specified model
796
+
797
+ Parameters
798
+ ----------
799
+ mass1 : array
800
+ primary masses
801
+ mass2 : array
802
+ secondary masses
803
+ rad1 : array
804
+ radii of the primaries.
805
+ rad2 : array
806
+ radii of the secondaries
807
+ porb_model : `str` or `dict`
808
+ selects which model to sample orbital periods, choices include:
809
+ log_uniform : semi-major axis flat in log space from RRLO < 0.5 up to 1e5 Rsun according to
810
+ `Abt (1983) <http://adsabs.harvard.edu/abs/1983ARA%26A..21..343A>`_
811
+ and consistent with Dominik+2012,2013
812
+ and then converted to orbital period in days using Kepler III
813
+ sana12 : power law orbital period between 0.15 < log(P/day) < 5.5 following
814
+ `Sana+2012 <https://ui.adsabs.harvard.edu/abs/2012Sci...337..444S/abstract>_`
815
+ renzo19 : power law orbital period for m1 > 15Msun binaries from
816
+ `Sana+2012 <https://ui.adsabs.harvard.edu/abs/2012Sci...337..444S/abstract>_`
817
+ following the implementation of
818
+ `Renzo+2019 <https://ui.adsabs.harvard.edu/abs/2019A%26A...624A..66R/abstract>_`
819
+ and flat in log otherwise
820
+ raghavan10 : log normal orbital periods in days with mean_logP = 4.9
821
+ and sigma_logP = 2.3 between 0 < log10(P/day) < 9 following
822
+ `Raghavan+2010 <https://ui.adsabs.harvard.edu/abs/2010ApJS..190....1R/abstract>_`
823
+ moe19 : log normal orbital periods in days with mean_logP = 4.9
824
+ and sigma_logP = 2.3 between 0 < log10(P/day) < 9 following
825
+ `Raghavan+2010 <https://ui.adsabs.harvard.edu/abs/2010ApJS..190....1R/abstract>_`
826
+ but with different close binary fractions following
827
+ `Moe+2019 <https://ui.adsabs.harvard.edu/abs/2019ApJ...875...61M/abstract>_`
828
+ Custom power law distribution defined with a dictionary with keys "min", "max", and "slope"
829
+ (e.g. porb_model={"min": 0.15, "max": 0.55, "slope": -0.55}) would reproduce the
830
+ Sana+2012 distribution.
831
+ met : float
832
+ metallicity of the population
833
+
834
+ Returns
835
+ -------
836
+ porb : array
837
+ orbital period with array size equalling array size
838
+ of mass1 and mass2 in units of days
839
+ aRL_over_a: array
840
+ ratio of radius where RL overflow starts to the sampled seperation
841
+ used to truncate the eccentricitiy distribution
842
+ """
843
+
844
+ # First we need to compute where RL overflow starts. We truncate the lower-bound
845
+ # of the period distribution there
846
+ q = mass2 / mass1
847
+ RL_fac = (0.49 * q ** (2.0 / 3.0)) / (
848
+ 0.6 * q ** (2.0 / 3.0) + np.log(1 + q ** 1.0 / 3.0)
849
+ )
850
+
851
+ q2 = mass1 / mass2
852
+ RL_fac2 = (0.49 * q2 ** (2.0 / 3.0)) / (
853
+ 0.6 * q2 ** (2.0 / 3.0) + np.log(1 + q2 ** 1.0 / 3.0)
854
+ )
855
+
856
+ # include the factor for the eccentricity
857
+ RL_max = 2 * rad1 / RL_fac
858
+ (ind_switch,) = np.where(RL_max < 2 * rad2 / RL_fac2)
859
+ if len(ind_switch) >= 1:
860
+ RL_max[ind_switch] = 2 * rad2[ind_switch] / RL_fac2[ind_switch]
861
+
862
+ # Can either sample the porb first and truncate the eccentricities at RL overflow
863
+ # or sample the eccentricities first and truncate a(1-e) at RL overflow
864
+ #
865
+ # If we haven't sampled the eccentricities, then the minimum semi-major axis is at
866
+ # RL overflow
867
+ #
868
+ # If we have, then the minimum pericenter is set to RL overflow
869
+ a_min = RL_max
870
+
871
+ if porb_model == "log_uniform":
872
+ if porb_max is None:
873
+ a_0 = np.random.uniform(np.log(a_min), np.log(1e5), size)
874
+ else:
875
+ # If in CMC, only sample binaries as wide as the local hard/soft boundary
876
+ a_max = utils.a_from_p(porb_max,mass1,mass2)
877
+ a_max[a_max < a_min] = a_min[a_max < a_min]
878
+ a_0 = np.random.uniform(np.log(a_min), np.log(a_max), size)
879
+
880
+ # convert out of log space
881
+ a_0 = np.exp(a_0)
882
+ aRL_over_a = a_min/a_0
883
+
884
+ # convert to au
885
+ rsun_au = 0.00465047
886
+ a_0 = a_0 * rsun_au
887
+
888
+ # convert to orbital period in years
889
+ yr_day = 365.24
890
+ porb_yr = ((a_0 ** 3.0) / (mass1 + mass2)) ** 0.5
891
+ porb = porb_yr * yr_day
892
+ elif porb_model == "sana12":
893
+ # Same here: if using CMC, set the maximum porb to the smaller of either the
894
+ # hard/soft boundary or 5.5 (from Sana paper)
895
+ if porb_max is None:
896
+ log10_porb_max = 5.5
897
+ else:
898
+ log10_porb_max = np.minimum(5.5,np.log10(porb_max))
899
+
900
+ # Use the lower limit from the Sana12 distribution, unless this means the binaries are sampled at RL overflow. If so,
901
+ # change the lower limit to a_min
902
+
903
+ log10_porb_min = np.array([0.15]*len(a_min))
904
+ RL_porb = utils.p_from_a(a_min,mass1,mass2)
905
+ log10_RL_porb = np.log10(RL_porb)
906
+ log10_porb_min[log10_porb_min < log10_RL_porb] = log10_RL_porb[log10_porb_min < log10_RL_porb]
907
+
908
+ porb = 10 ** utils.rndm(a=log10_porb_min, b=log10_porb_max, g=-0.55, size=size)
909
+ aRL_over_a = a_min / utils.a_from_p(porb,mass1,mass2)
910
+
911
+ elif isinstance(porb_model, dict):
912
+ # use a power law distribution for the orbital periods
913
+ params = {
914
+ "min": 0.15,
915
+ "max": 5.5,
916
+ "slope": -0.55,
917
+ }
918
+ # update the default parameters with the user-supplied ones
919
+ params.update(porb_model)
920
+
921
+ # same calculations as sana12 case (sample from a power law distribution but avoid RLOF)
922
+ log10_RL_porb = np.log10(utils.p_from_a(a_min, mass1, mass2))
923
+ params["min"] = np.full(len(a_min), params["min"])
924
+ params["min"][params["min"] < log10_RL_porb] = log10_RL_porb[params["min"] < log10_RL_porb]
925
+ porb = 10**utils.rndm(a=params["min"], b=params["max"], g=params["slope"], size=size)
926
+ aRL_over_a = a_min / utils.a_from_p(porb, mass1, mass2)
927
+
928
+ elif porb_model == "renzo19":
929
+ # Same here: if using CMC, set the maximum porb to the smaller of either the
930
+ # hard/soft boundary or 5.5 (from Sana paper)
931
+ if porb_max is None:
932
+ log10_porb_max = 5.5
933
+
934
+ else:
935
+ log10_porb_max = np.minimum(5.5,np.log10(porb_max))
936
+
937
+ # Use the lower limit from the Sana12 distribution, unless this means the binaries are sampled at RL overflow. If so,
938
+ # change the lower limit to a_min
939
+ log10_porb_min = np.array([0.15]*len(a_min))
940
+ RL_porb = utils.p_from_a(a_min,mass1,mass2)
941
+ log10_RL_porb = np.log10(RL_porb)
942
+ log10_porb_min[log10_porb_min < log10_RL_porb] = log10_RL_porb[log10_porb_min < log10_RL_porb]
943
+
944
+ porb = 10 ** (np.random.uniform(log10_porb_min, log10_porb_max, size))
945
+ (ind_massive,) = np.where(mass1 > 15)
946
+
947
+ if type(log10_porb_max) != float:
948
+ log10_porb_max = log10_porb_max[ind_massive]
949
+ log10_porb_min = log10_porb_min[ind_massive]
950
+
951
+
952
+ porb[ind_massive] = 10 ** utils.rndm(
953
+ a=log10_porb_min[ind_massive], b=log10_porb_max, g=-0.55, size=len(ind_massive))
954
+ aRL_over_a = a_min / utils.a_from_p(porb,mass1,mass2)
955
+
956
+ elif porb_model == "raghavan10":
957
+ import scipy
958
+ # Same here: if using CMC, set the maximum porb to the smaller of either the
959
+ # hard/soft boundary or 5.5 (from Sana paper)
960
+ if porb_max is None:
961
+ log10_porb_max = 9.0
962
+ else:
963
+ log10_porb_max = np.minimum(5.5, np.log10(porb_max))
964
+
965
+ lower = 0
966
+ upper = log10_porb_max
967
+ mu = 4.9
968
+ sigma = 2.3
969
+
970
+ porb = 10 ** (scipy.stats.truncnorm.rvs(
971
+ (lower-mu)/sigma,(upper-mu)/sigma, loc=mu, scale=sigma, size=size
972
+ ))
973
+
974
+ aRL_over_a = a_min / utils.a_from_p(porb,mass1,mass2)
975
+
976
+ elif porb_model == "moe19":
977
+ from scipy.interpolate import interp1d
978
+ from scipy.stats import norm
979
+ from scipy.integrate import trapezoid
980
+
981
+ try:
982
+ met = kwargs.pop('met')
983
+ except:
984
+ raise ValueError(
985
+ "You have chosen moe19 for the orbital period distribution which is a metallicity-dependent distribution. "
986
+ "Please specify a metallicity for the population."
987
+ )
988
+ def get_logP_dist(nsamp, norm_wide, norm_close, mu=4.4, sigma=2.1):
989
+ logP_lo_lim=0
990
+ logP_hi_lim=9
991
+ close_logP=4.0
992
+ wide_logP=6.0
993
+ neval = 500
994
+ prob_wide = norm.pdf(np.linspace(wide_logP, logP_hi_lim, neval), loc=mu, scale=sigma)*norm_wide
995
+ prob_close = norm.pdf(np.linspace(logP_lo_lim, close_logP, neval), loc=mu, scale=sigma)*norm_close
996
+ slope = -(prob_close[-1] - prob_wide[0]) / (wide_logP - close_logP)
997
+ prob_intermediate = slope * (np.linspace(close_logP, wide_logP, neval) - close_logP) + prob_close[-1]
998
+ prob_interp_int = interp1d(np.linspace(close_logP, wide_logP, neval), prob_intermediate)
999
+
1000
+ log_p_success = []
1001
+ n_success = 0
1002
+ while n_success < nsamp:
1003
+ logP_samp = np.random.uniform(logP_lo_lim, logP_hi_lim, nsamp*5)
1004
+ logP_prob = np.random.uniform(0, 1, nsamp*5)
1005
+
1006
+ logP_samp_lo = logP_samp[logP_samp<close_logP]
1007
+ logP_prob_lo = logP_prob[logP_samp<close_logP]
1008
+ log_p_success.extend(logP_samp_lo[np.where(logP_prob_lo < norm.pdf(logP_samp_lo, loc=mu, scale=sigma)*norm_close)])
1009
+
1010
+ logP_samp_int = logP_samp[(logP_samp>=close_logP) & (logP_samp<wide_logP)]
1011
+ logP_prob_int = logP_prob[(logP_samp>=close_logP) & (logP_samp<wide_logP)]
1012
+ log_p_success.extend(logP_samp_int[np.where(logP_prob_int < prob_interp_int(logP_samp_int))])
1013
+
1014
+ logP_samp_hi = logP_samp[(logP_samp>=wide_logP)]
1015
+ logP_prob_hi = logP_prob[(logP_samp>=wide_logP)]
1016
+
1017
+ log_p_success.extend(logP_samp_hi[np.where(logP_prob_hi < norm.pdf(logP_samp_hi, loc=mu, scale=sigma)*norm_wide)])
1018
+
1019
+ n_success = len(log_p_success)
1020
+ log_p_success = np.array(log_p_success)[np.random.randint(0,n_success,nsamp)]
1021
+ return log_p_success
1022
+ norm_wide, norm_close = utils.get_porb_norm(met)
1023
+ logP_dist = get_logP_dist(size, norm_wide, norm_close)
1024
+ logP_dist = logP_dist[np.random.randint(0, len(logP_dist), size)]
1025
+ porb = 10**logP_dist
1026
+ aRL_over_a = a_min / utils.a_from_p(porb,mass1,mass2)
1027
+
1028
+
1029
+ else:
1030
+ raise ValueError(
1031
+ "You have supplied a non-supported model; Please choose either log_flat, sana12, renzo19, raghavan10, or moe19"
1032
+ )
1033
+ return porb, aRL_over_a
1034
+
1035
+ def sample_ecc(self, aRL_over_a, ecc_model="sana12", size=None):
1036
+ """Sample the eccentricity according to a user specified model
1037
+
1038
+ Parameters
1039
+ ----------
1040
+ ecc_model : string
1041
+ 'thermal' samples from a thermal eccentricity distribution following
1042
+ `Heggie (1975) <http://adsabs.harvard.edu/abs/1975MNRAS.173..729H>`_
1043
+ 'uniform' samples from a uniform eccentricity distribution
1044
+ 'sana12' samples from the eccentricity distribution from
1045
+ `Sana+2012 <https://ui.adsabs.harvard.edu/abs/2012Sci...337..444S/abstract>_`
1046
+ 'circular' assumes zero eccentricity for all systems
1047
+ DEFAULT = 'sana12'
1048
+
1049
+ aRL_over_a : ratio of the minimum seperation (where RL overflow starts)
1050
+ to the sampled semi-major axis. Use this to truncate the eccentricitiy
1051
+
1052
+ size : int, optional
1053
+ number of eccentricities to sample
1054
+ this is set in cosmic-pop call as Nstep
1055
+
1056
+ Returns
1057
+ -------
1058
+ ecc : array
1059
+ array of sampled eccentricities with size=size
1060
+ """
1061
+
1062
+ # if we sampled the periods first, we need to truncate the eccentricities
1063
+ # to avoid RL overflow/collision at pericenter
1064
+ e_max = 1.0 - aRL_over_a
1065
+
1066
+ if ecc_model == "thermal":
1067
+ a_0 = np.random.uniform(0.0, e_max**2, size)
1068
+ ecc = a_0 ** 0.5
1069
+ return ecc
1070
+
1071
+ elif ecc_model == "uniform":
1072
+ ecc = np.random.uniform(0.0, e_max, size)
1073
+ return ecc
1074
+
1075
+ elif ecc_model == "sana12":
1076
+ sana_max = np.array([0.9]*len(e_max))
1077
+ max_e = np.minimum(e_max, sana_max)
1078
+ ecc = utils.rndm(a=0.001, b=max_e, g=-0.45, size=size)
1079
+
1080
+ return ecc
1081
+
1082
+ elif ecc_model == "circular":
1083
+ ecc = np.zeros(size)
1084
+ return ecc
1085
+
1086
+ else:
1087
+ raise ValueError("You have specified an unsupported model. Please choose from thermal, "
1088
+ "uniform, sana12, or circular")
1089
+
1090
+ def sample_SFH(self, SF_start=13700.0, SF_duration=0.0, met=0.02, size=None):
1091
+ """Sample an evolution time for each binary based on a user-specified
1092
+ time at the start of star formation and the duration of star formation.
1093
+ The default is a burst of star formation 13,700 Myr in the past.
1094
+
1095
+ Parameters
1096
+ ----------
1097
+ SF_start : float
1098
+ Time in the past when star formation initiates in Myr
1099
+ SF_duration : float
1100
+ Duration of constant star formation beginning from SF_Start in Myr
1101
+ met : float
1102
+ metallicity of the population [Z_sun = 0.02]
1103
+ Default: 0.02
1104
+ size : int, optional
1105
+ number of evolution times to sample
1106
+ NOTE: this is set in cosmic-pop call as Nstep
1107
+
1108
+ Returns
1109
+ -------
1110
+ tphys : array
1111
+ array of evolution times of size=size
1112
+ metallicity : array
1113
+ array of metallicities
1114
+ """
1115
+
1116
+ if (SF_start > 0.0) & (SF_duration >= 0.0):
1117
+ tphys = np.random.uniform(SF_start - SF_duration, SF_start, size)
1118
+ metallicity = np.ones(size) * met
1119
+ return tphys, metallicity
1120
+ else:
1121
+ raise ValueError(
1122
+ 'SF_start and SF_duration must be positive and SF_start must be greater than 0.0')
1123
+
1124
+ def set_kstar(self, mass):
1125
+ """Initialize stellar types according to BSE classification
1126
+ kstar=1 if M>=0.7 Msun; kstar=0 if M<0.7 Msun
1127
+
1128
+ Parameters
1129
+ ----------
1130
+ mass : array
1131
+ array of masses
1132
+
1133
+ Returns
1134
+ -------
1135
+ kstar : array
1136
+ array of initial stellar types
1137
+ """
1138
+
1139
+ kstar = np.zeros(mass.size)
1140
+ low_cutoff = 0.7
1141
+ lowIdx = np.where(mass < low_cutoff)[0]
1142
+ hiIdx = np.where(mass >= low_cutoff)[0]
1143
+
1144
+ kstar[lowIdx] = 0
1145
+ kstar[hiIdx] = 1
1146
+
1147
+ return kstar
1148
+
1149
+ def set_reff(self, mass, metallicity, zsun=0.02):
1150
+ """
1151
+ Better way to set the radii from BSE, by calling it directly
1152
+
1153
+ takes masses and metallicities, and returns the radii
1154
+
1155
+ Note that the BSE function is hard-coded to go through arrays
1156
+ of length 10^5. If your masses are more than that, you'll
1157
+ need to divide it into chunks
1158
+ """
1159
+
1160
+ from cosmic import _evolvebin
1161
+
1162
+
1163
+ max_array_size = 100000
1164
+ total_length = len(mass)
1165
+ radii = np.zeros(total_length)
1166
+
1167
+ _evolvebin.metvars.zsun = zsun
1168
+
1169
+ idx = 0
1170
+ while total_length > max_array_size:
1171
+ ## cycle through the masses max_array_size number at a time
1172
+ temp_mass = mass[idx*max_array_size:(idx+1)*max_array_size]
1173
+
1174
+ temp_radii = _evolvebin.compute_r(temp_mass,metallicity,max_array_size)
1175
+
1176
+ ## put these in the radii array
1177
+ radii[idx*max_array_size:(idx+1)*max_array_size] = temp_radii
1178
+
1179
+ total_length -= max_array_size
1180
+ idx += 1
1181
+
1182
+ length_remaining = total_length
1183
+
1184
+ ## if smaller than 10^5, need to pad out the array
1185
+ temp_mass = np.zeros(max_array_size)
1186
+ temp_mass[:length_remaining] = mass[-length_remaining:]
1187
+
1188
+ temp_radii = _evolvebin.compute_r(temp_mass,metallicity,length_remaining)
1189
+
1190
+ #finish up the array
1191
+ radii[-length_remaining:] = temp_radii[:length_remaining]
1192
+
1193
+ return radii