cosmic-popsynth 3.4.17__cp310-cp310-macosx_14_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.
- cosmic/Match.py +191 -0
- cosmic/__init__.py +32 -0
- cosmic/_commit_hash.py +1 -0
- cosmic/_evolvebin.cpython-310-darwin.so +0 -0
- cosmic/_version.py +1 -0
- cosmic/bse_utils/__init__.py +18 -0
- cosmic/bse_utils/zcnsts.py +570 -0
- cosmic/bse_utils/zdata.py +596 -0
- cosmic/checkstate.py +128 -0
- cosmic/evolve.py +524 -0
- cosmic/filter.py +214 -0
- cosmic/get_commit_hash.py +15 -0
- cosmic/plotting.py +683 -0
- cosmic/sample/__init__.py +26 -0
- cosmic/sample/cmc/__init__.py +18 -0
- cosmic/sample/cmc/elson.py +411 -0
- cosmic/sample/cmc/king.py +260 -0
- cosmic/sample/initialbinarytable.py +254 -0
- cosmic/sample/initialcmctable.py +448 -0
- cosmic/sample/sampler/__init__.py +25 -0
- cosmic/sample/sampler/cmc.py +418 -0
- cosmic/sample/sampler/independent.py +1193 -0
- cosmic/sample/sampler/multidim.py +873 -0
- cosmic/sample/sampler/sampler.py +130 -0
- cosmic/test_evolve.py +100 -0
- cosmic/test_match.py +30 -0
- cosmic/test_sample.py +559 -0
- cosmic/test_utils.py +198 -0
- cosmic/utils.py +1857 -0
- cosmic_popsynth-3.4.17.data/scripts/cosmic-pop +492 -0
- cosmic_popsynth-3.4.17.dist-info/METADATA +56 -0
- cosmic_popsynth-3.4.17.dist-info/RECORD +33 -0
- cosmic_popsynth-3.4.17.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
# Copyright (C) Carl Rodriguez (2017 - 2020)
|
|
4
|
+
#
|
|
5
|
+
# This file is part of COSMIC
|
|
6
|
+
#
|
|
7
|
+
# COSMIC is free software: you can redistribute it and/or modify
|
|
8
|
+
# it under the terms of the GNU General Public License as published by
|
|
9
|
+
# the Free Software Foundation, either version 3 of the License, or
|
|
10
|
+
# (at your option) any later version.
|
|
11
|
+
#
|
|
12
|
+
# COSMIC is distributed in the hope that it will be useful,
|
|
13
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
14
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
15
|
+
# GNU General Public License for more details.
|
|
16
|
+
#
|
|
17
|
+
# You should have received a copy of the GNU General Public License
|
|
18
|
+
# along with COSMIC. If not, see <http://www.gnu.org/licenses/>
|
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
# Copyright (C) Carl Rodriguez (2020 - 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
|
+
"""`Elson`
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from scipy.interpolate import interp1d, CubicSpline
|
|
23
|
+
from scipy.integrate import quad
|
|
24
|
+
from scipy.special import hyp2f1
|
|
25
|
+
from scipy.optimize import brentq
|
|
26
|
+
import numpy as np
|
|
27
|
+
from numpy.random import uniform, normal
|
|
28
|
+
from scipy.stats import maxwell
|
|
29
|
+
|
|
30
|
+
__author__ = "Carl Rodriguez <carllouisrodriguez@gmail.com>"
|
|
31
|
+
__credits__ = "Carl Rodriguez <carllouisrodriguez@gmail.com>"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def M_enclosed(r, gamma, rho_0):
|
|
35
|
+
"""
|
|
36
|
+
Compute the mass enclosed in an Elson profile at radius r with slope
|
|
37
|
+
gamma, central concentration rho_0, and assumed scale factor a = 1
|
|
38
|
+
|
|
39
|
+
In practice, this is only used to sample the positions of stars, so rho_0 is
|
|
40
|
+
just picked to normalize the distribution (i.e. rho_0 s.t. M_enclosed(rmax) = 1)
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
prefactor = 4 * np.pi * rho_0 * r * r * r / 3.0
|
|
44
|
+
|
|
45
|
+
hypergeometric = hyp2f1(1.5, (gamma + 1.0) / 2.0, 2.5, -(r ** 2))
|
|
46
|
+
|
|
47
|
+
return prefactor * hypergeometric
|
|
48
|
+
|
|
49
|
+
def phi_r(r, gamma, rho_0):
|
|
50
|
+
"""
|
|
51
|
+
Compute the gravitational potential of an Elson profile at radius r with slope
|
|
52
|
+
gamma, central concentration rho_0, and assumed scale factor a = 1
|
|
53
|
+
|
|
54
|
+
Needed to compute the escape speed (for resampling stars)
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
prefactor = - 4 * np.pi * rho_0 / (gamma - 1)
|
|
58
|
+
|
|
59
|
+
hypergeometric = hyp2f1(0.5, (gamma - 1.0) / 2.0, 1.5, -(r ** 2))
|
|
60
|
+
|
|
61
|
+
return prefactor * hypergeometric
|
|
62
|
+
|
|
63
|
+
def rho_r(r, gamma, rho_0):
|
|
64
|
+
"""
|
|
65
|
+
Compute the density of the Elson profile at radius r
|
|
66
|
+
Best to use the same normalized rho_0 from M_enclosed
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
return rho_0 * pow(1 + r * r, -(gamma + 1.0) / 2.0)
|
|
71
|
+
|
|
72
|
+
def virial_radius_analytic(gamma, r_max):
|
|
73
|
+
"""
|
|
74
|
+
Virial radius is best calculated directly, since rmax may be pretty far from
|
|
75
|
+
infinity. Directly integrate 4*pi*r*rho*m_enclosed from 0 to rMax to get the
|
|
76
|
+
binding energy, then just divide 0.5 by that.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
rho_0 = 1.0 / M_enclosed(r_max, gamma, 1)
|
|
80
|
+
|
|
81
|
+
integral, error = quad(lambda r: rho_r(r, gamma, rho_0) * r * 4 * np.pi * M_enclosed(r, gamma, rho_0), 0, r_max)
|
|
82
|
+
|
|
83
|
+
return 1 / 2.0 / integral
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def find_rmax_vir(r_max, gamma):
|
|
87
|
+
"""
|
|
88
|
+
This is a little tricky: because the virial radius of the Elson profile
|
|
89
|
+
depends on the maximum radius, if the profile is very flat (e.g. gamma~2)
|
|
90
|
+
then you need a large maximum radius to get a large number of virial radius
|
|
91
|
+
in there. This basically finds r such that r / rVir(r) = r_max. In other
|
|
92
|
+
word, how far out do we need to go to have r_max number of virial radii in the
|
|
93
|
+
sample.
|
|
94
|
+
"""
|
|
95
|
+
rOrvirMin = 1.0
|
|
96
|
+
rOrvirMax = 20000.0
|
|
97
|
+
|
|
98
|
+
# rvir ~ 1 for gamma > 4, and the M_enclosed integral doesn't converge.
|
|
99
|
+
# Here's a cheap fix for that...
|
|
100
|
+
if gamma > 4:
|
|
101
|
+
rOrvirMax /= 10
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def y_zero(r):
|
|
105
|
+
return r / virial_radius_analytic(gamma, r) - r_max
|
|
106
|
+
|
|
107
|
+
yMin = y_zero(rOrvirMin)
|
|
108
|
+
yMax = y_zero(rOrvirMax)
|
|
109
|
+
|
|
110
|
+
rmax_vir = brentq(y_zero, rOrvirMin,rOrvirMax)
|
|
111
|
+
|
|
112
|
+
return rmax_vir
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def find_sigma_sqr(r, r_max_cluster, gamma):
|
|
116
|
+
"""
|
|
117
|
+
Find the 1D velocity dispersion at a given radius r using one of the
|
|
118
|
+
spherial Jeans equations (and assuming velocity is isotropic)
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
rho_0 = 1.0 / M_enclosed(r_max_cluster, gamma, 1)
|
|
122
|
+
|
|
123
|
+
jeans_integrand = (
|
|
124
|
+
lambda rr: rho_r(rr, gamma, rho_0) * M_enclosed(rr, gamma, rho_0) / rr / rr
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
integral, error = quad(jeans_integrand, r, r_max_cluster)
|
|
128
|
+
|
|
129
|
+
return integral / rho_r(r, gamma, rho_0)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def get_positions(N, r_max_cluster, gamma):
|
|
133
|
+
"""
|
|
134
|
+
This one's easy: the mass enclosed function is just the CDF of the mass
|
|
135
|
+
density, so just invert that, and you've got positions.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
# First normalize the central density s.t. M_enc(r_max) = 1
|
|
139
|
+
rho_0 = 1.0 / M_enclosed(r_max_cluster, gamma, 1)
|
|
140
|
+
|
|
141
|
+
radii_grid = np.logspace(-3, np.log10(r_max_cluster), 1000)
|
|
142
|
+
|
|
143
|
+
# Add r=0 to the array
|
|
144
|
+
radii_grid[0] = 0
|
|
145
|
+
|
|
146
|
+
mass_enclosed_grid = M_enclosed(radii_grid, gamma, rho_0)
|
|
147
|
+
|
|
148
|
+
# if it's a steep profile, you'll get a lot of similar numbers (to machine precision)
|
|
149
|
+
# remove them here
|
|
150
|
+
mass_enclosed_grid, unique_idx = np.unique(mass_enclosed_grid,return_index=True)
|
|
151
|
+
radii_grid = radii_grid[unique_idx]
|
|
152
|
+
|
|
153
|
+
# Use an interpolator, but flip x and y for the CDF
|
|
154
|
+
interpolator = interp1d(mass_enclosed_grid, radii_grid, kind="cubic")
|
|
155
|
+
|
|
156
|
+
X = uniform(size=N)
|
|
157
|
+
|
|
158
|
+
positions = interpolator(X)
|
|
159
|
+
|
|
160
|
+
return positions
|
|
161
|
+
|
|
162
|
+
def get_velocities_old(r, r_max_cluster, gamma):
|
|
163
|
+
"""
|
|
164
|
+
Uses the spherical Jeans functions to sample the velocity dispersion for the
|
|
165
|
+
cluster at different radii, then draws a random, isotropic velocity for each
|
|
166
|
+
star
|
|
167
|
+
|
|
168
|
+
NOTE: this gives you correct velocity dispersion and produces a cluster in virial
|
|
169
|
+
equilibrium, but it's not strictly speaking correct (the distribution should
|
|
170
|
+
have some kurtosis, in addition to getting the variance of the Gaussian correct).
|
|
171
|
+
You can see the disagreement in the tail of the distributions when sampling a
|
|
172
|
+
Plummer sphere. This is what mcluster does...
|
|
173
|
+
|
|
174
|
+
Use the new get_velocities function, which generates a distribution function
|
|
175
|
+
directly from rho and samples velocities from it
|
|
176
|
+
|
|
177
|
+
This is kept around because it's super useful to sample from when the rejection sampling
|
|
178
|
+
gets too slow at the last X samples (e.g. gamma ~ 10)
|
|
179
|
+
|
|
180
|
+
returns (vr,vt) with same length as r
|
|
181
|
+
"""
|
|
182
|
+
|
|
183
|
+
N = len(r)
|
|
184
|
+
|
|
185
|
+
rho_0 = 1.0 / M_enclosed(r_max_cluster, gamma, 1)
|
|
186
|
+
|
|
187
|
+
# Rather than calculate the integral for every stellar position, just
|
|
188
|
+
# create an interpolator of 1000 or so points, then sample from the
|
|
189
|
+
# interpolated curve
|
|
190
|
+
radii_grid = np.logspace(np.log10(min(r) * 0.999), np.log10(max(r) * 1.001), 1000)
|
|
191
|
+
sigma_sqr_grid = np.array(
|
|
192
|
+
[find_sigma_sqr(rr, r_max_cluster, gamma) for rr in radii_grid]
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
interpolator = interp1d(radii_grid, sigma_sqr_grid, kind="cubic")
|
|
196
|
+
|
|
197
|
+
# Draw the 1D velocity dispersions
|
|
198
|
+
sigma = np.sqrt(interpolator(r))
|
|
199
|
+
|
|
200
|
+
# Then sample an isotropic gaussian in vx,vy,vz using those dispersions
|
|
201
|
+
vx = normal(scale=sigma, size=N)
|
|
202
|
+
vy = normal(scale=sigma, size=N)
|
|
203
|
+
vz = normal(scale=sigma, size=N)
|
|
204
|
+
|
|
205
|
+
# Finally the rejection sampling: because of the Gaussian, some of the velocities
|
|
206
|
+
# we draw will naturally be above the local escape speed of the cluster. To avoid
|
|
207
|
+
# this, we flag them, and resample them from the velocity distribution
|
|
208
|
+
while False:
|
|
209
|
+
ve = np.sqrt(-2*phi_r(r, gamma, rho_0))
|
|
210
|
+
escapers = (np.sqrt(vx**2 + vy**2 + vz**2) > ve)
|
|
211
|
+
|
|
212
|
+
number_of_escapers = np.sum(escapers)
|
|
213
|
+
if number_of_escapers == 0:
|
|
214
|
+
break
|
|
215
|
+
else:
|
|
216
|
+
vx_temp = normal(scale=sigma[escapers], size=number_of_escapers)
|
|
217
|
+
vy_temp = normal(scale=sigma[escapers], size=number_of_escapers)
|
|
218
|
+
vz_temp = normal(scale=sigma[escapers], size=number_of_escapers)
|
|
219
|
+
|
|
220
|
+
vx[escapers] = vx_temp
|
|
221
|
+
vy[escapers] = vy_temp
|
|
222
|
+
vz[escapers] = vz_temp
|
|
223
|
+
|
|
224
|
+
return np.sqrt(vx**2 + vy**2 + vz**2)
|
|
225
|
+
|
|
226
|
+
def get_velocities(r, r_max_cluster, gamma):
|
|
227
|
+
"""
|
|
228
|
+
The correct way to generate velocities: generate the distribution function from rho,
|
|
229
|
+
then use rejection sampling.
|
|
230
|
+
|
|
231
|
+
returns (vr,vt) with same length as r
|
|
232
|
+
"""
|
|
233
|
+
|
|
234
|
+
N = len(r)
|
|
235
|
+
|
|
236
|
+
# If we actually want a plummer sphere, we can default to the simpler function
|
|
237
|
+
# using the analytic distribution function
|
|
238
|
+
if gamma == 4:
|
|
239
|
+
return get_velocities_plummer(r,r_max_cluster)
|
|
240
|
+
# Otherwise need to compute f(E) numerically
|
|
241
|
+
|
|
242
|
+
# First get phi and rho over 100 points or so
|
|
243
|
+
rho_0 = 1.0 / M_enclosed(max(r), gamma, 1)
|
|
244
|
+
|
|
245
|
+
r_stensil = np.logspace(np.log10(min(r)),np.log10(max(r)),100)
|
|
246
|
+
phi = phi_r(r_stensil,gamma,rho_0)
|
|
247
|
+
rho = rho_r(r_stensil,gamma,rho_0)
|
|
248
|
+
|
|
249
|
+
# Then construct a cubic spline of rho as a function of phi
|
|
250
|
+
# Technically should be psi = -phi, but FITPACK only accepts increasing values
|
|
251
|
+
rho_phi_spline = CubicSpline(phi,rho,extrapolate=False)
|
|
252
|
+
|
|
253
|
+
f_E = []
|
|
254
|
+
energies = []
|
|
255
|
+
|
|
256
|
+
# Now integrate over energies to get the distribution function
|
|
257
|
+
for en in np.linspace(min(-phi),max(-phi),100):
|
|
258
|
+
|
|
259
|
+
integrand = lambda psi: rho_phi_spline(-psi,2) / np.sqrt(en - psi)
|
|
260
|
+
|
|
261
|
+
# Eqn 4.46b of Binney and Tremain (2nd Edition)
|
|
262
|
+
# (4.140b in 1st edition)
|
|
263
|
+
temp_f_E = ((quad(integrand,min(-phi)*1.01,en,limit=200)[0] +
|
|
264
|
+
rho_phi_spline(-min(-phi),1)/np.sqrt(en)) / 17.493418) # sqrt(8)*pi^2
|
|
265
|
+
|
|
266
|
+
# Extrapolating derivatives from a cubic spline is hella dangerous
|
|
267
|
+
# Better to have it throw NaNs and discard them
|
|
268
|
+
if np.isnan(temp_f_E):
|
|
269
|
+
continue
|
|
270
|
+
else:
|
|
271
|
+
f_E.append(temp_f_E)
|
|
272
|
+
energies.append(en)
|
|
273
|
+
|
|
274
|
+
# Set f(E) to zero at zero energy
|
|
275
|
+
f_E = np.array([0] + f_E)
|
|
276
|
+
energies = np.array([0] + energies)
|
|
277
|
+
|
|
278
|
+
# Make an interpolator for f(E) and compute f and phi at every star
|
|
279
|
+
f_E_interp = interp1d(energies,f_E)
|
|
280
|
+
phi_at_r = phi_r(r,gamma,rho_0)
|
|
281
|
+
f_E_at_r = f_E_interp(-phi_at_r)
|
|
282
|
+
|
|
283
|
+
# Make an initial guess for every velocity
|
|
284
|
+
x_rand = uniform(size=N)
|
|
285
|
+
y_rand = uniform(size=N)
|
|
286
|
+
velocities = x_rand*np.sqrt(-2*phi_at_r)
|
|
287
|
+
|
|
288
|
+
resample = np.ones(N,dtype=bool)
|
|
289
|
+
number_to_resample = N
|
|
290
|
+
|
|
291
|
+
# Now do the rejection sampling
|
|
292
|
+
while number_to_resample > 0:
|
|
293
|
+
resample[resample] = (y_rand[resample]*f_E_at_r[resample] >
|
|
294
|
+
x_rand[resample]**2 * f_E_interp(-phi_at_r[resample]*(1 - x_rand[resample]**2)))
|
|
295
|
+
number_to_resample = np.sum(resample)
|
|
296
|
+
x_rand[resample] = uniform(size=number_to_resample)
|
|
297
|
+
y_rand[resample] = uniform(size=number_to_resample)
|
|
298
|
+
|
|
299
|
+
velocities = x_rand * np.sqrt(-2*phi_at_r)
|
|
300
|
+
|
|
301
|
+
theta = np.arccos(uniform(-1,1,N))
|
|
302
|
+
|
|
303
|
+
vr = velocities*np.cos(theta)
|
|
304
|
+
vt = velocities*np.sin(theta)
|
|
305
|
+
|
|
306
|
+
return vr, vt
|
|
307
|
+
|
|
308
|
+
def get_velocities_plummer(r, r_max_cluster):
|
|
309
|
+
"""
|
|
310
|
+
The correct way to generate velocities
|
|
311
|
+
|
|
312
|
+
this is a special case for gamma=4, which is the plummer sphere (and has an analytic distribution function)
|
|
313
|
+
|
|
314
|
+
returns (vr,vt) with same length as r
|
|
315
|
+
"""
|
|
316
|
+
|
|
317
|
+
N = len(r)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
# Make an initial guess for every velocity
|
|
321
|
+
x_rand = uniform(size=N)
|
|
322
|
+
y_rand = uniform(size=N)
|
|
323
|
+
vesc = np.sqrt(2/np.sqrt(1+r**2))
|
|
324
|
+
velocities = x_rand * vesc
|
|
325
|
+
|
|
326
|
+
resample = np.ones(N,dtype=bool)
|
|
327
|
+
number_to_resample = N
|
|
328
|
+
|
|
329
|
+
# Now do the rejection sampling; this is taken from Aarseth's original algorithm using the dist function
|
|
330
|
+
while number_to_resample > 0:
|
|
331
|
+
resample[resample] = 0.1*y_rand[resample] > x_rand[resample]**2 * (1 - x_rand[resample]**2)**3.5
|
|
332
|
+
number_to_resample = np.sum(resample)
|
|
333
|
+
x_rand[resample] = uniform(size=number_to_resample)
|
|
334
|
+
y_rand[resample] = uniform(size=number_to_resample)
|
|
335
|
+
|
|
336
|
+
# Scale up to the local escape speed
|
|
337
|
+
velocities = x_rand * vesc
|
|
338
|
+
|
|
339
|
+
theta = np.arccos(uniform(-1,1,N))
|
|
340
|
+
|
|
341
|
+
vr = velocities*np.cos(theta)
|
|
342
|
+
vt = velocities*np.sin(theta)
|
|
343
|
+
|
|
344
|
+
return vr, vt
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def scale_pos_and_vel(r,vr,vt):
|
|
348
|
+
"""
|
|
349
|
+
Scale the positions and velocities to be in N-body units
|
|
350
|
+
If we add binaries we'll do this again in initialcmctable.py
|
|
351
|
+
|
|
352
|
+
takes r, vr, and vt as input
|
|
353
|
+
|
|
354
|
+
returns (r,vr,vt) scaled to Henon units
|
|
355
|
+
"""
|
|
356
|
+
|
|
357
|
+
# Normalize masses to 1/Mtotal
|
|
358
|
+
mass = np.ones_like(r)/len(r)
|
|
359
|
+
cumul_mass = np.cumsum(mass)
|
|
360
|
+
|
|
361
|
+
radius = np.array(r)
|
|
362
|
+
radius_p1 = np.append(radius[1:], [1e100])
|
|
363
|
+
|
|
364
|
+
# Then compute the total kinetic and potential energy
|
|
365
|
+
# There's probably a cleaner way to do the PE (this is a one-line version
|
|
366
|
+
# of the for loop we use in CMC; vectorized and pythonic, but sloppy)
|
|
367
|
+
KE = 0.5 * sum(mass * (vr ** 2 + vt ** 2))
|
|
368
|
+
PE = 0.5 * sum(
|
|
369
|
+
mass[::-1]
|
|
370
|
+
* np.cumsum((cumul_mass * (1.0 / radius - 1.0 / radius_p1))[::-1])
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
# Compute the position and velocity scalings
|
|
374
|
+
rfac = 2 * PE
|
|
375
|
+
vfac = 1.0 / np.sqrt(4 * KE)
|
|
376
|
+
|
|
377
|
+
return r*rfac, vr*vfac, vt*vfac
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def draw_r_vr_vt(N=100000, r_max=300, gamma=4):
|
|
381
|
+
"""
|
|
382
|
+
Draw random velocities and positions from the Elson profile.
|
|
383
|
+
|
|
384
|
+
N = number of stars
|
|
385
|
+
r_max = maximum number of virial radii for the farthest star
|
|
386
|
+
gamma = steepness function of the profile
|
|
387
|
+
|
|
388
|
+
Note that gamma=4 is the Plummer profile
|
|
389
|
+
|
|
390
|
+
returns (vr,vt,r) in G=M_cluster=1 units
|
|
391
|
+
|
|
392
|
+
N.B. if you're getting a "Expect x to not have duplicates" error
|
|
393
|
+
with a large gamma, use a smaller r_max.
|
|
394
|
+
"""
|
|
395
|
+
# First convert r_max into max number of virial radii
|
|
396
|
+
r_max = find_rmax_vir(r_max, gamma)
|
|
397
|
+
|
|
398
|
+
# Then draw the positions from the cumulative mass function
|
|
399
|
+
r = get_positions(N, r_max, gamma)
|
|
400
|
+
|
|
401
|
+
# Sort the radii (needed later)
|
|
402
|
+
r = np.sort(r)
|
|
403
|
+
|
|
404
|
+
# Finally, draw the velocities using the radii and one of the Jeans
|
|
405
|
+
# equations
|
|
406
|
+
vr, vt = get_velocities(r, r_max, gamma)
|
|
407
|
+
|
|
408
|
+
# And scale them to Henon units
|
|
409
|
+
r,vr,vt = scale_pos_and_vel(r,vr,vt)
|
|
410
|
+
|
|
411
|
+
return r, vr, vt
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
# Copyright (C) Carl Rodriguez (2020 - 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
|
+
"""`King`
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import numpy as np
|
|
23
|
+
from numpy.random import uniform, normal
|
|
24
|
+
from scipy.integrate import RK45, quad, simpson, cumulative_trapezoid
|
|
25
|
+
from scipy.interpolate import interp1d
|
|
26
|
+
from scipy.special import erf
|
|
27
|
+
|
|
28
|
+
__author__ = "Carl Rodriguez <carllouisrodriguez@gmail.com>"
|
|
29
|
+
__credits__ = "Carl Rodriguez <carllouisrodriguez@gmail.com>"
|
|
30
|
+
__all__ = ["calc_rho", "integrate_king_profile", "virial_radius_numerical", "find_sigma_sqr", "get_positions", "get_velocities", "scale_pos_and_vel", "draw_r_vr_vt"]
|
|
31
|
+
|
|
32
|
+
def calc_rho(w):
|
|
33
|
+
"""
|
|
34
|
+
Returns the density (unnormalized) given w = psi/sigma^2
|
|
35
|
+
Note that w(0) = w_0, the main parameter of a King profile
|
|
36
|
+
"""
|
|
37
|
+
rho = np.exp(w)*erf(np.sqrt(w)) - np.sqrt(4.0*w/np.pi)*(1.0+2.0*w/3.0)
|
|
38
|
+
return rho
|
|
39
|
+
|
|
40
|
+
def integrate_king_profile(w0,tidal_boundary=1e-6):
|
|
41
|
+
"""
|
|
42
|
+
Integrate a King Profile of a given w_0 until the density (or phi/sigma^2)
|
|
43
|
+
drops below tidal_boundary limit (1e-8 times the central density by default)
|
|
44
|
+
|
|
45
|
+
Let's define some things:
|
|
46
|
+
The King potential is often expressed in terms of w = psi / sigma^2 (psi is phi0 - phi, so just positive potential)
|
|
47
|
+
(note that sigma is the central velocity dispersion with an infinitely deep potential, and close otherwise)
|
|
48
|
+
|
|
49
|
+
in the center, w_0 = psi_0 / sigma^2 (the free parameter of the King profile)
|
|
50
|
+
|
|
51
|
+
The core radius is defined (King 1966) as r_c = sqrt(9 * sigma^2 / (4 pi G rho_0))
|
|
52
|
+
|
|
53
|
+
If we define new scaled quantities
|
|
54
|
+
r_tilda = r/r_c
|
|
55
|
+
rho_tilda = rho/rho_o
|
|
56
|
+
We can rewrite Poisson's equation, (1/r^2) d/dr (r^2 dphi/dr) = 4 pi G rho as:
|
|
57
|
+
d^2(r_tilda w)/dr_tilda^2 = 9 r_tilda rho_tilda
|
|
58
|
+
|
|
59
|
+
After that, all we need is initial conditions:
|
|
60
|
+
w(0) = w_0
|
|
61
|
+
w'(0) = 0
|
|
62
|
+
|
|
63
|
+
returns (radii, rho, phi, M_enclosed)
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
rho_0 = calc_rho(w0)
|
|
67
|
+
|
|
68
|
+
def ode_rhs(r_tilda,w_vec):
|
|
69
|
+
## Unpack the w_vector (note I'm cheating by putting rho_0 as an element)
|
|
70
|
+
w,w_dot,rho_0 = w_vec
|
|
71
|
+
|
|
72
|
+
## Compute rho and second derivative of w
|
|
73
|
+
rho = calc_rho(w)
|
|
74
|
+
w_ddot = -9*rho/rho_0 - 2*w_dot/r_tilda
|
|
75
|
+
|
|
76
|
+
## return dw/dr_tilda, d^2w/dr_tilda^2, d(rho_tilda)/dr_tilda = 0
|
|
77
|
+
return (w_dot,w_ddot,0)
|
|
78
|
+
|
|
79
|
+
king_profile = RK45(ode_rhs,1e-4,[w0,0,rho_0],1e5,rtol=1e-10,atol=1e-12)
|
|
80
|
+
|
|
81
|
+
at_the_tidal_boundary = False
|
|
82
|
+
r = []
|
|
83
|
+
rho_r = []
|
|
84
|
+
phi_r = []
|
|
85
|
+
|
|
86
|
+
while not at_the_tidal_boundary:
|
|
87
|
+
w,w_dot,rho_0 = king_profile.y
|
|
88
|
+
rho = calc_rho(w)
|
|
89
|
+
|
|
90
|
+
r.append(king_profile.t)
|
|
91
|
+
rho_r.append(rho)
|
|
92
|
+
phi_r.append(w)
|
|
93
|
+
|
|
94
|
+
if rho/rho_0 < tidal_boundary:
|
|
95
|
+
at_the_tidal_boundary = True
|
|
96
|
+
else:
|
|
97
|
+
king_profile.step()
|
|
98
|
+
|
|
99
|
+
r = np.array(r)
|
|
100
|
+
rho_r = np.array(rho_r)
|
|
101
|
+
|
|
102
|
+
## Finally compute the cumulative mass enclosed
|
|
103
|
+
M_enclosed = cumulative_trapezoid(4*np.pi*r**2*rho_r, r, initial=0)
|
|
104
|
+
|
|
105
|
+
return r, rho_r, phi_r, M_enclosed
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def virial_radius_numerical(r, rho_r, M_enclosed):
|
|
109
|
+
"""
|
|
110
|
+
Virial radius is best calculated directly. Directly integrate
|
|
111
|
+
4*pi*r*rho*m_enclosed over the samples binding energy, then just divide 0.5 by that.
|
|
112
|
+
"""
|
|
113
|
+
integral = simpson(y=rho_r * r * 4 * np.pi * M_enclosed,x=r)
|
|
114
|
+
|
|
115
|
+
return 1 / 2.0 / integral
|
|
116
|
+
|
|
117
|
+
def find_sigma_sqr(r_sample, r, rho_r, M_enclosed):
|
|
118
|
+
"""
|
|
119
|
+
Find the 1D velocity dispersion at a given radius r using one of the
|
|
120
|
+
spherial Jeans equations (and assuming velocity is isotropic)
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
## This needs to be done continuously, so use interpolators
|
|
124
|
+
rho_interp = interp1d(r,rho_r)
|
|
125
|
+
M_enc_interp = interp1d(r,M_enclosed)
|
|
126
|
+
|
|
127
|
+
jeans_integrand = lambda rr: rho_interp(rr) * M_enc_interp(rr) / rr / rr
|
|
128
|
+
jeans_integrand = interp1d(r,rho_r*M_enclosed/r/r)
|
|
129
|
+
|
|
130
|
+
integral, error = quad(jeans_integrand, r_sample, r[-1])
|
|
131
|
+
return integral / rho_interp(r_sample)
|
|
132
|
+
|
|
133
|
+
def get_positions(N, r, M_enclosed):
|
|
134
|
+
"""
|
|
135
|
+
This one's easy: the mass enclosed function is just the CDF of the mass
|
|
136
|
+
density, so just invert that, and you've got positions.
|
|
137
|
+
|
|
138
|
+
Note that here we've already normalized M_enclosed to 1 at r_tidal
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
# Use an interpolator, but flip x and y for the CDF
|
|
142
|
+
interpolator = interp1d(M_enclosed, r, kind="cubic")
|
|
143
|
+
|
|
144
|
+
X = uniform(size=N)
|
|
145
|
+
|
|
146
|
+
positions = interpolator(X)
|
|
147
|
+
|
|
148
|
+
return positions
|
|
149
|
+
|
|
150
|
+
def get_velocities(r, r_profile, psi_profile, M_enclosed_profile):
|
|
151
|
+
"""
|
|
152
|
+
The correct way to generate velocities: start from the distribution function
|
|
153
|
+
and use rejection sampling.
|
|
154
|
+
|
|
155
|
+
returns (vr,vt) with same length as r
|
|
156
|
+
"""
|
|
157
|
+
|
|
158
|
+
N = len(r)
|
|
159
|
+
|
|
160
|
+
# create an interpolator for the psi
|
|
161
|
+
psi_r = interp1d(r_profile,psi_profile)
|
|
162
|
+
psi = psi_r(r)
|
|
163
|
+
|
|
164
|
+
# Make an initial guess for every velocity (some fraction of the escape speed)
|
|
165
|
+
x_rand = uniform(size=N)
|
|
166
|
+
y_rand = uniform(size=N)
|
|
167
|
+
|
|
168
|
+
resample = np.ones(N,dtype=bool)
|
|
169
|
+
number_to_resample = N
|
|
170
|
+
|
|
171
|
+
minF = (np.exp(psi) - 1)*2*psi
|
|
172
|
+
v = x_rand * np.sqrt(2*psi)
|
|
173
|
+
f_0 = y_rand*minF
|
|
174
|
+
f = (np.exp(psi-v**2/2)-1)*v**2
|
|
175
|
+
|
|
176
|
+
# Now do the rejection sampling
|
|
177
|
+
while number_to_resample > 0:
|
|
178
|
+
v[resample] = x_rand[resample] * np.sqrt(2*psi[resample])
|
|
179
|
+
f_0[resample] = y_rand[resample]*minF[resample]
|
|
180
|
+
f[resample] = (np.exp(psi[resample]-v[resample]**2/2)-1)*v[resample]**2
|
|
181
|
+
|
|
182
|
+
resample[resample] = f[resample] < f_0[resample]
|
|
183
|
+
number_to_resample = np.sum(resample)
|
|
184
|
+
x_rand[resample] = uniform(size=number_to_resample)
|
|
185
|
+
y_rand[resample] = uniform(size=number_to_resample)
|
|
186
|
+
|
|
187
|
+
theta = np.arccos(uniform(-1,1,N))
|
|
188
|
+
|
|
189
|
+
vr = v*np.cos(theta)
|
|
190
|
+
vt = v*np.sin(theta)
|
|
191
|
+
|
|
192
|
+
return vr, vt
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def scale_pos_and_vel(r,vr,vt):
|
|
196
|
+
"""
|
|
197
|
+
Scale the positions and velocities to be in N-body units
|
|
198
|
+
If we add binaries we'll do this again in initialcmctable.py
|
|
199
|
+
|
|
200
|
+
takes r, vr, and vt as input
|
|
201
|
+
|
|
202
|
+
returns (r,vr,vt) scaled to Henon units
|
|
203
|
+
"""
|
|
204
|
+
|
|
205
|
+
# Normalize masses to 1/Mtotal
|
|
206
|
+
mass = np.ones_like(r)/len(r)
|
|
207
|
+
cumul_mass = np.cumsum(mass)
|
|
208
|
+
|
|
209
|
+
radius = np.array(r)
|
|
210
|
+
radius_p1 = np.append(radius[1:], [1e100])
|
|
211
|
+
|
|
212
|
+
# Then compute the total kinetic and potential energy
|
|
213
|
+
# There's probably a cleaner way to do the PE (this is a one-line version
|
|
214
|
+
# of the for loop we use in CMC; vectorized and pythonic, but sloppy)
|
|
215
|
+
KE = 0.5 * sum(mass * (vr ** 2 + vt ** 2))
|
|
216
|
+
PE = 0.5 * sum(
|
|
217
|
+
mass[::-1]
|
|
218
|
+
* np.cumsum((cumul_mass * (1.0 / radius - 1.0 / radius_p1))[::-1])
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
# Compute the position and velocity scalings
|
|
222
|
+
rfac = 2 * PE
|
|
223
|
+
vfac = 1.0 / np.sqrt(4 * KE)
|
|
224
|
+
|
|
225
|
+
return r*rfac, vr*vfac, vt*vfac
|
|
226
|
+
|
|
227
|
+
def draw_r_vr_vt(N=100000, w_0=6, tidal_boundary=1e-6):
|
|
228
|
+
"""
|
|
229
|
+
Draw random velocities and positions from the King profile.
|
|
230
|
+
|
|
231
|
+
N = number of stars
|
|
232
|
+
w_0 = King concentration parameter (-psi/sigma^2)
|
|
233
|
+
tidal_boundary = ratio of rho/rho_0 where we truncate the tidal boundary
|
|
234
|
+
|
|
235
|
+
returns (vr,vt,r) in G=M_cluster=1 units
|
|
236
|
+
"""
|
|
237
|
+
# First integrate the differential equation of a King profile
|
|
238
|
+
r_profile, rho_profile, psi_profile, M_enclosed_profile = integrate_king_profile(w_0, tidal_boundary=tidal_boundary)
|
|
239
|
+
|
|
240
|
+
## Normalize the masses to 1 at r_tidal
|
|
241
|
+
rho_profile /= M_enclosed_profile[-1]
|
|
242
|
+
M_enclosed_profile /= M_enclosed_profile[-1]
|
|
243
|
+
|
|
244
|
+
## Normalize r (currently in units of the King core radius) to the virial radius
|
|
245
|
+
r_profile /= virial_radius_numerical(r_profile, rho_profile, M_enclosed_profile)
|
|
246
|
+
|
|
247
|
+
# Then draw the positions from the cumulative mass function
|
|
248
|
+
r = get_positions(N, r_profile, M_enclosed_profile)
|
|
249
|
+
|
|
250
|
+
# Sort the radii (needed later)
|
|
251
|
+
r = np.sort(r)
|
|
252
|
+
|
|
253
|
+
# Finally, draw the velocities using the radii and one of the Jeans
|
|
254
|
+
# equations
|
|
255
|
+
vr, vt = get_velocities(r, r_profile, psi_profile, M_enclosed_profile)
|
|
256
|
+
|
|
257
|
+
# Scale into Henon units
|
|
258
|
+
r, vr, vt = scale_pos_and_vel(r,vr,vt)
|
|
259
|
+
|
|
260
|
+
return r, vr, vt
|