ocelot 0.3.1a0__py3-none-any.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.
- ocelot/__init__.py +1 -0
- ocelot/calculate/__init__.py +1 -0
- ocelot/calculate/calculate.py +207 -0
- ocelot/calculate/distance.py +1 -0
- ocelot/calculate/generic.py +97 -0
- ocelot/calculate/motion.py +11 -0
- ocelot/calculate/position.py +64 -0
- ocelot/calculate/profile.py +225 -0
- ocelot/cluster/__init__.py +11 -0
- ocelot/cluster/epsilon.py +726 -0
- ocelot/cluster/nearest_neighbor.py +66 -0
- ocelot/cluster/preprocess.py +370 -0
- ocelot/cluster/resample.py +192 -0
- ocelot/crossmatch/__init__.py +7 -0
- ocelot/crossmatch/_catalogue.py +467 -0
- ocelot/isochrone/__init__.py +4 -0
- ocelot/isochrone/interpolate.py +588 -0
- ocelot/isochrone/io.py +148 -0
- ocelot/plot/__init__.py +13 -0
- ocelot/plot/axis/__init__.py +1 -0
- ocelot/plot/axis/cluster.py +468 -0
- ocelot/plot/axis/nn_statistics.py +184 -0
- ocelot/plot/gaia_explorer.py +436 -0
- ocelot/plot/plot_figure.py +391 -0
- ocelot/plot/process.py +73 -0
- ocelot/plot/utilities.py +168 -0
- ocelot/util/__init__.py +0 -0
- ocelot/util/check.py +15 -0
- ocelot/util/random.py +37 -0
- ocelot/verify/__init__.py +2 -0
- ocelot/verify/find.py +269 -0
- ocelot/verify/significance.py +396 -0
- ocelot/verify/stats.py +261 -0
- ocelot-0.3.1a0.dist-info/LICENSE +21 -0
- ocelot-0.3.1a0.dist-info/METADATA +95 -0
- ocelot-0.3.1a0.dist-info/RECORD +38 -0
- ocelot-0.3.1a0.dist-info/WHEEL +5 -0
- ocelot-0.3.1a0.dist-info/top_level.txt +1 -0
ocelot/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = '0.4.0'
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Todo decide what to make top-level here
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""A set of functions for calculating typical cluster parameters.
|
|
2
|
+
|
|
3
|
+
Todo: error treatment here could be made more bayesian
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import Optional
|
|
7
|
+
from astropy.coordinates import SkyCoord
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
import pandas as pd
|
|
11
|
+
|
|
12
|
+
from .constants import mas_per_yr_to_rad_per_s, pc_to_m, deg_to_rad
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _handle_ra_discontinuity(ra_data, middle_ras_raise_error=True):
|
|
16
|
+
"""Tries to detect when the ras in a field cross the (0, 360) ra discontinuity and returns corrected results. Will
|
|
17
|
+
raise an error if ras are all over the place (which will happen e.g. at very high declinations) in which you
|
|
18
|
+
ought to instead switch to a method free of spherical distortions.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
ra_data (pd.Series or np.ndarray): data on ras.
|
|
22
|
+
middle_ras_raise_error (bool): whether or not a cluster having right ascensions in all ranges [0, 90), [90, 270]
|
|
23
|
+
and (270, 360] raises an error. The error here indicates that this cluster has extreme spherical
|
|
24
|
+
discontinuities (e.g. it's near a coordinate pole) and that the mean ra and mean dec will be inaccurate.
|
|
25
|
+
Default: True
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
ra_data but corrected for distortions. If values are both <90 and >270, the new ra data will be in the range
|
|
29
|
+
(-90, 90).
|
|
30
|
+
|
|
31
|
+
"""
|
|
32
|
+
# Firstly, check that the ras are valid ras
|
|
33
|
+
if np.any(ra_data >= 360) or np.any(ra_data < 0):
|
|
34
|
+
raise ValueError(
|
|
35
|
+
"at least one input ra value was invalid! Ras must be in the range [0, 360)."
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
# Next, grab all the locations of dodgy friends and check that all three aren't ever in use at the same time
|
|
39
|
+
low_ra = ra_data < 90
|
|
40
|
+
high_ra = ra_data > 270
|
|
41
|
+
middle_ra = np.logical_not(np.logical_or(low_ra, high_ra))
|
|
42
|
+
|
|
43
|
+
# Proceed if we have both low and high ras
|
|
44
|
+
if np.any(low_ra) and np.any(high_ra):
|
|
45
|
+
# Stop if we have middle too (would imply stars everywhere or an extreme dec value)
|
|
46
|
+
if np.any(middle_ra) and middle_ras_raise_error:
|
|
47
|
+
raise ValueError(
|
|
48
|
+
"ra values are in all three ranges: [0, 90), [90, 270] and (270, 360). This cluster can't "
|
|
49
|
+
"be processed by this function! Spherical distortions must be removed first."
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# Otherwise, apply the discontinuity removal
|
|
53
|
+
else:
|
|
54
|
+
# Make a copy so nothing weird happens
|
|
55
|
+
ra_data = ra_data.copy()
|
|
56
|
+
|
|
57
|
+
# And remove the distortion for all high numbers
|
|
58
|
+
ra_data[high_ra] = ra_data[high_ra] - 360
|
|
59
|
+
|
|
60
|
+
return ra_data
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def mean_radius(
|
|
64
|
+
data_gaia: pd.DataFrame,
|
|
65
|
+
membership_probabilities: Optional[np.ndarray] = None,
|
|
66
|
+
already_inferred_parameters: Optional[dict] = None,
|
|
67
|
+
key_ra: str = "ra",
|
|
68
|
+
key_ra_error: str = "ra_error",
|
|
69
|
+
key_dec: str = "dec",
|
|
70
|
+
key_dec_error: str = "dec_error",
|
|
71
|
+
distance_to_use: str = "inverse_parallax",
|
|
72
|
+
middle_ras_raise_error: bool = True,
|
|
73
|
+
**kwargs,
|
|
74
|
+
):
|
|
75
|
+
"""Produces various radius statistics on a given cluster, finding its sky location and three radii: the core, tidal
|
|
76
|
+
and 50% radius.
|
|
77
|
+
|
|
78
|
+
Done in a very basic, frequentist way, whereby means are weighted based on the membership probabilities (if
|
|
79
|
+
specified).
|
|
80
|
+
|
|
81
|
+
N.B. unlike the above functions, errors do *not change the mean* as this would potentially bias the
|
|
82
|
+
estimates towards being dominated by large, centrally-located stars within clusters (that have generally lower
|
|
83
|
+
velocities.) Hence, estimates here will be less precise but hopefully more accurate.
|
|
84
|
+
|
|
85
|
+
Todo: add error estimation to this function (hard)
|
|
86
|
+
|
|
87
|
+
Todo: add galactic l, b to the output of this function
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
data_gaia (pd.DataFrame): Gaia data for the cluster in the standard format (e.g. as in DR2.)
|
|
91
|
+
membership_probabilities (optional, np.ndarray): membership probabilities for the cluster. When specified,
|
|
92
|
+
they can increase or decrease the effect of certain stars on the mean.
|
|
93
|
+
already_inferred_parameters (optional, dict): a parameter dictionary of the mean distance and proper motion.
|
|
94
|
+
Otherwise, this function calculates a version.
|
|
95
|
+
key_ra (str): Gaia parameter name.
|
|
96
|
+
key_ra_error (str): Gaia parameter name.
|
|
97
|
+
key_dec (str): Gaia parameter name.
|
|
98
|
+
key_dec_error (str): Gaia parameter name.
|
|
99
|
+
distance_to_use (str): which already inferred distance to use to convert angular radii to parsecs.
|
|
100
|
+
Default: "inverse_parallax"
|
|
101
|
+
middle_ras_raise_error (bool): whether or not a cluster having right ascensions in all ranges [0, 90), [90, 270]
|
|
102
|
+
and (270, 360] raises an error. The error here indicates that this cluster has extreme spherical
|
|
103
|
+
discontinuities (e.g. it's near a coordinate pole) and that the mean ra and mean dec will be inaccurate.
|
|
104
|
+
Default: True
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
a dict, formatted with:
|
|
108
|
+
{
|
|
109
|
+
# Position
|
|
110
|
+
"ra": ra of the cluster
|
|
111
|
+
"ra_error": error on the above
|
|
112
|
+
"dec": dec of the cluster
|
|
113
|
+
"dec_error": error on the above
|
|
114
|
+
|
|
115
|
+
# Angular radii
|
|
116
|
+
"ang_radius_50": median ang. distance from the center, i.e.angular radius of the cluster with 50% of members
|
|
117
|
+
"ang_radius_50_error": error on the above
|
|
118
|
+
"ang_radius_c": angular King's core radius of the cluster
|
|
119
|
+
"ang_radius_c_error": error on the above
|
|
120
|
+
"ang_radius_t": maximum angular distance from the center, i.e. angular King's tidal radius of the cluster
|
|
121
|
+
"ang_radius_t_error": error on the above
|
|
122
|
+
|
|
123
|
+
# Parsec radii
|
|
124
|
+
"radius_50": median distance from the center, i.e.radius of the cluster with 50% of members
|
|
125
|
+
"radius_50_error": error on the above
|
|
126
|
+
"radius_c": King's core radius of the cluster
|
|
127
|
+
"radius_c_error": error on the above
|
|
128
|
+
"radius_t": maximum distance from the center, i.e. King's tidal radius of the cluster
|
|
129
|
+
"radius_t_error": error on the above
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
"""
|
|
133
|
+
inferred_parameters = {}
|
|
134
|
+
sqrt_n_stars = np.sqrt(data_gaia.shape[0])
|
|
135
|
+
|
|
136
|
+
# Grab the distances if they aren't specified - we'll need them in a moment!
|
|
137
|
+
if already_inferred_parameters is None:
|
|
138
|
+
already_inferred_parameters = mean_distance(data_gaia, membership_probabilities)
|
|
139
|
+
|
|
140
|
+
# Estimate the ra, dec of the cluster as the weighted mean
|
|
141
|
+
ra_data = _handle_ra_discontinuity(
|
|
142
|
+
data_gaia[key_ra], middle_ras_raise_error=middle_ras_raise_error
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
inferred_parameters["ra"] = np.average(ra_data, weights=membership_probabilities)
|
|
146
|
+
inferred_parameters["ra_std"] = _weighted_standard_deviation(
|
|
147
|
+
ra_data, membership_probabilities
|
|
148
|
+
)
|
|
149
|
+
inferred_parameters["ra_error"] = inferred_parameters["ra_std"] / sqrt_n_stars
|
|
150
|
+
|
|
151
|
+
if inferred_parameters["ra"] < 0:
|
|
152
|
+
inferred_parameters["ra"] += 360
|
|
153
|
+
|
|
154
|
+
inferred_parameters["dec"] = np.average(
|
|
155
|
+
data_gaia[key_dec], weights=membership_probabilities
|
|
156
|
+
)
|
|
157
|
+
inferred_parameters["dec_std"] = _weighted_standard_deviation(
|
|
158
|
+
data_gaia[key_dec], membership_probabilities
|
|
159
|
+
)
|
|
160
|
+
inferred_parameters["dec_error"] = inferred_parameters["dec_std"] / sqrt_n_stars
|
|
161
|
+
|
|
162
|
+
inferred_parameters["ang_dispersion"] = np.sqrt(
|
|
163
|
+
inferred_parameters["ra_std"] ** 2 + inferred_parameters["dec_std"] ** 2
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
# Calculate how far every star in the cluster is from the central point
|
|
167
|
+
center_skycoord = SkyCoord(
|
|
168
|
+
ra=inferred_parameters["ra"], dec=inferred_parameters["dec"], unit="deg"
|
|
169
|
+
)
|
|
170
|
+
star_skycoords = SkyCoord(
|
|
171
|
+
ra=data_gaia[key_ra].to_numpy(), dec=data_gaia[key_dec].to_numpy(), unit="deg"
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
distances_from_center = center_skycoord.separation(star_skycoords).degree
|
|
175
|
+
|
|
176
|
+
# And say something about the radii in this case
|
|
177
|
+
inferred_parameters["ang_radius_50"] = np.median(distances_from_center)
|
|
178
|
+
inferred_parameters["ang_radius_50_error"] = np.nan
|
|
179
|
+
|
|
180
|
+
inferred_parameters["ang_radius_c"] = np.nan
|
|
181
|
+
inferred_parameters["ang_radius_c_error"] = np.nan
|
|
182
|
+
|
|
183
|
+
inferred_parameters["ang_radius_t"] = np.max(distances_from_center)
|
|
184
|
+
inferred_parameters["ang_radius_t_error"] = np.nan
|
|
185
|
+
|
|
186
|
+
# Convert the angular distances into parsecs
|
|
187
|
+
inferred_parameters["radius_50"] = (
|
|
188
|
+
np.tan(inferred_parameters["ang_radius_50"] * deg_to_rad)
|
|
189
|
+
* already_inferred_parameters[distance_to_use]
|
|
190
|
+
)
|
|
191
|
+
inferred_parameters["radius_50_error"] = np.nan
|
|
192
|
+
inferred_parameters["radius_c"] = np.nan
|
|
193
|
+
inferred_parameters["radius_c_error"] = np.nan
|
|
194
|
+
inferred_parameters["radius_t"] = (
|
|
195
|
+
np.tan(inferred_parameters["ang_radius_t"] * deg_to_rad)
|
|
196
|
+
* already_inferred_parameters[distance_to_use]
|
|
197
|
+
)
|
|
198
|
+
inferred_parameters["radius_t_error"] = np.nan
|
|
199
|
+
|
|
200
|
+
return inferred_parameters
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def all_statistics():
|
|
204
|
+
"""
|
|
205
|
+
"""
|
|
206
|
+
# Todo refactor this (and other high-level calculation methods)
|
|
207
|
+
pass
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Functions for working with cluster distances."""
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Generic calculation utilities used across the module."""
|
|
2
|
+
import numpy as np
|
|
3
|
+
from numpy.typing import ArrayLike, NDArray
|
|
4
|
+
from typing import Optional, Union
|
|
5
|
+
from ocelot.util.check import _check_matching_lengths_of_non_nones
|
|
6
|
+
from scipy.stats import directional_stats
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _weighted_standard_deviation(x: ArrayLike, weights: Optional[ArrayLike] = None):
|
|
10
|
+
"""Computes weighted standard deviation. Uses method from
|
|
11
|
+
https://stackoverflow.com/a/52655244/12709989.
|
|
12
|
+
"""
|
|
13
|
+
# Todo: not sure that this deals with small numbers of points correctly!
|
|
14
|
+
# See: unit test fails when v. few points used
|
|
15
|
+
return np.sqrt(np.cov(x, aweights=weights))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def standard_error(
|
|
19
|
+
standard_deviation: Union[ArrayLike[Union[float, int]], float, int],
|
|
20
|
+
number_of_measurements: Union[ArrayLike[int], int],
|
|
21
|
+
) -> float:
|
|
22
|
+
"""Calculates the standard error on the mean of some parameter given the standard
|
|
23
|
+
deviation.
|
|
24
|
+
|
|
25
|
+
Parameters
|
|
26
|
+
----------
|
|
27
|
+
standard_deviation : array-like, float, or int
|
|
28
|
+
Standard deviation(s)
|
|
29
|
+
number_of_measurements : array-like of ints, int
|
|
30
|
+
Number of measurements used to find standard deviation.
|
|
31
|
+
|
|
32
|
+
Returns
|
|
33
|
+
-------
|
|
34
|
+
standard_error : float
|
|
35
|
+
"""
|
|
36
|
+
return standard_deviation / np.sqrt(number_of_measurements)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def mean_and_deviation(
|
|
40
|
+
values: ArrayLike,
|
|
41
|
+
weights: Optional[ArrayLike] = None,
|
|
42
|
+
) -> tuple[float]:
|
|
43
|
+
"""Calculates the mean and standard deviation of some set of values.
|
|
44
|
+
|
|
45
|
+
Parameters
|
|
46
|
+
----------
|
|
47
|
+
values : array-like
|
|
48
|
+
Values to calculate mean and standard deviation of.
|
|
49
|
+
weights : array-like, optional
|
|
50
|
+
Array of weights to use to compute a weighted mean and average.
|
|
51
|
+
|
|
52
|
+
Returns
|
|
53
|
+
-------
|
|
54
|
+
mean : float
|
|
55
|
+
Mean of values.
|
|
56
|
+
std : float
|
|
57
|
+
Standard deviation of values.
|
|
58
|
+
"""
|
|
59
|
+
_check_matching_lengths_of_non_nones(values, weights)
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
np.average(values, weights=weights),
|
|
63
|
+
_weighted_standard_deviation(values, weights),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def lonlat_to_unitvec(longitudes: NDArray, latitudes: NDArray):
|
|
68
|
+
"""Converts longitudes and latitudes to unit vectors on a unit sphere. Uses method
|
|
69
|
+
at https://en.wikipedia.org/wiki/Spherical_coordinate_system#Cartesian_coordinates.
|
|
70
|
+
Assumes that latitudes is in the range [-pi / 2, pi / 2] as is common in
|
|
71
|
+
astronomical unit systems.
|
|
72
|
+
"""
|
|
73
|
+
x = np.cos(latitudes) * np.cos(longitudes)
|
|
74
|
+
y = np.cos(latitudes) * np.sin(longitudes)
|
|
75
|
+
z = np.sin(latitudes)
|
|
76
|
+
return np.column_stack((x, y, z))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def unitvec_to_lonlat(unit_vectors: NDArray):
|
|
80
|
+
"""Converts unit vectors on a unit sphere to longitudes and latitudes. See
|
|
81
|
+
`lonlat_to_unitvec` for more details.
|
|
82
|
+
"""
|
|
83
|
+
x, y, z = [column.ravel() for column in np.hsplit(unit_vectors, 3)]
|
|
84
|
+
longitudes = np.arctan2(y, x)
|
|
85
|
+
latitudes = np.arcsin(z / np.sqrt(x**2 + y**2 + z**2))
|
|
86
|
+
return longitudes, latitudes
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def spherical_mean(longitudes: ArrayLike, latitudes: ArrayLike):
|
|
90
|
+
"""Calculates the spherical mean of angular positions."""
|
|
91
|
+
longitudes = np.asarray_chkfinite(longitudes)
|
|
92
|
+
latitudes = np.asarray_chkfinite(latitudes)
|
|
93
|
+
|
|
94
|
+
unit_vectors = lonlat_to_unitvec(longitudes, latitudes)
|
|
95
|
+
mean_unit_vector = directional_stats(unit_vectors).mean_direction
|
|
96
|
+
mean_lon, mean_lat = unitvec_to_lonlat(mean_unit_vector)
|
|
97
|
+
return mean_lon[0], mean_lat[0]
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Different methods for calculating the center of a cluster in spherical coordinates.
|
|
2
|
+
(This is actually oddly difficult, thanks to how spheres work. Damn spheres.)
|
|
3
|
+
"""
|
|
4
|
+
import numpy as np
|
|
5
|
+
from numpy.typing import ArrayLike
|
|
6
|
+
|
|
7
|
+
from ocelot.calculate.generic import spherical_mean
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def mean_position(
|
|
11
|
+
longitudes: ArrayLike, latitudes: ArrayLike, degrees=True
|
|
12
|
+
) -> tuple[float]:
|
|
13
|
+
"""Calculates the spherical mean of angular positions, specified as longitudes and
|
|
14
|
+
latitudes. This uses directional statistics to do so in a way that is aware of
|
|
15
|
+
discontinuities, such as the fact that 0° = 360°.
|
|
16
|
+
|
|
17
|
+
Parameters
|
|
18
|
+
----------
|
|
19
|
+
longitudes : array-like
|
|
20
|
+
Array of longitudinal positions of stars in your cluster (e.g. right ascensions
|
|
21
|
+
or galactic longitudes.) Assumed to be in the range [0°, 360°].
|
|
22
|
+
latitudes : array-like
|
|
23
|
+
Array of latitudinal positions of stars in your cluster (e.g. declinations or
|
|
24
|
+
galactic latitudes.) Assumed to be in the range [-90°, 90°].
|
|
25
|
+
degrees : bool
|
|
26
|
+
Whether longitudes and latitudes are in degrees, and whether to return an answer
|
|
27
|
+
in degrees. Defaults to True. If False, longitudes and latitudes are assumed to
|
|
28
|
+
be in radians, with ranges [0, 2π] and [-π/2, π/2] respectively.
|
|
29
|
+
|
|
30
|
+
Returns
|
|
31
|
+
-------
|
|
32
|
+
mean_longitude : float
|
|
33
|
+
mean_latitude : float
|
|
34
|
+
|
|
35
|
+
Notes
|
|
36
|
+
-----
|
|
37
|
+
This function explicitly assumes that your star cluster *has* a well-defined mean
|
|
38
|
+
position. Some configurations (such as points uniformly distributed in at least one
|
|
39
|
+
axis of a sphere) will not have a meaningful mean position.
|
|
40
|
+
|
|
41
|
+
Internally, this function uses `scipy.stats.directional_stats`, with a definition
|
|
42
|
+
taken from [1]. See [2] for more background.
|
|
43
|
+
|
|
44
|
+
References
|
|
45
|
+
----------
|
|
46
|
+
[1] Mardia, Jupp. (2000). Directional Statistics (p. 163). Wiley.
|
|
47
|
+
[2] https://en.wikipedia.org/wiki/Directional_statistics
|
|
48
|
+
"""
|
|
49
|
+
if degrees:
|
|
50
|
+
longitudes, latitudes = np.radians(longitudes), np.radians(latitudes)
|
|
51
|
+
mean_lon, mean_lat = spherical_mean(longitudes, latitudes)
|
|
52
|
+
if degrees:
|
|
53
|
+
return np.degrees(mean_lon), np.degrees(mean_lat)
|
|
54
|
+
return mean_lon, mean_lat
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def mode_position():
|
|
58
|
+
"""Attempts to find the mode of a star cluster's 2D on-sky distribution. This is a
|
|
59
|
+
better estimator than the mean position for clusters that are assymmetric, which is
|
|
60
|
+
often the case for clusters with assymetric tidal tails (e.g. due to one side being
|
|
61
|
+
more easily detected than the other.)
|
|
62
|
+
"""
|
|
63
|
+
# Todo
|
|
64
|
+
pass
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
# Todo: refactor to new structure (e.g. class-based, mirroring scipy.stats maybe?)
|
|
2
|
+
|
|
3
|
+
from typing import Union, Optional
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def king_surface_density(
|
|
9
|
+
r_values: Union[float, np.ndarray],
|
|
10
|
+
r_core: float,
|
|
11
|
+
r_tidal: float,
|
|
12
|
+
normalise: bool = False,
|
|
13
|
+
):
|
|
14
|
+
"""Computes the King surface density (King 1962, equation 14) given the three parameters. Can take vectorised input.
|
|
15
|
+
Will return the surface density per square unit expected at a distance r_values from the core of the cluster.
|
|
16
|
+
|
|
17
|
+
Valid only for:
|
|
18
|
+
0 <= r < r_tidal (0 elsewhere - this is checked internally)
|
|
19
|
+
0 < r_core < r_tidal (raises an error if not the case)
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
r_values (float or np.ndarray): r values to compute the surface density at.
|
|
23
|
+
r_core (float): the core radius of the cluster.
|
|
24
|
+
r_tidal (float): the tidal radius of the cluster.
|
|
25
|
+
normalise (bool): whether or not to return the normalised King surface density profile, calculated
|
|
26
|
+
numerically.
|
|
27
|
+
Default: False
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
a float or array of floats of the surface density for the cluster.
|
|
31
|
+
|
|
32
|
+
"""
|
|
33
|
+
_check_core_and_tidal_radii(r_core, r_tidal)
|
|
34
|
+
r_values = _check_r_values(r_values)
|
|
35
|
+
|
|
36
|
+
# Constants
|
|
37
|
+
rt_rc = r_tidal / r_core
|
|
38
|
+
a = (1 + rt_rc**2) ** -0.5
|
|
39
|
+
|
|
40
|
+
# Compute normalisation constant if desired
|
|
41
|
+
if normalise:
|
|
42
|
+
term_1 = r_core * np.arctan(rt_rc)
|
|
43
|
+
term_2 = -2 * a * r_core * np.log(rt_rc + 1 / a)
|
|
44
|
+
term_3 = r_tidal * a**2
|
|
45
|
+
normalisation_constant = 1 / (term_1 + term_2 + term_3)
|
|
46
|
+
else:
|
|
47
|
+
normalisation_constant = 1.0
|
|
48
|
+
|
|
49
|
+
# Work out where 0 <= r < r_tidal
|
|
50
|
+
r_valid = np.logical_and(r_values >= 0, r_values < r_tidal)
|
|
51
|
+
r_invalid = np.invert(r_valid)
|
|
52
|
+
|
|
53
|
+
# Compute result
|
|
54
|
+
reduced_r_values = (1 + (r_values[r_valid] / r_core) ** 2) ** (-0.5)
|
|
55
|
+
result = np.empty(r_values.shape)
|
|
56
|
+
result[r_valid] = normalisation_constant * (reduced_r_values - a) ** 2
|
|
57
|
+
result[r_invalid] = 0
|
|
58
|
+
|
|
59
|
+
return result
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _check_r_values(r_values):
|
|
63
|
+
# Convert to a np array that's at least 1d, and check that it isn't bad
|
|
64
|
+
r_values = np.atleast_1d(r_values)
|
|
65
|
+
if not np.isfinite(r_values).all():
|
|
66
|
+
raise ValueError("invalid r_values (e.g. nan or inf) are not allowed!")
|
|
67
|
+
if np.any(r_values < 0):
|
|
68
|
+
raise ValueError("all r_values must be positive or zero.")
|
|
69
|
+
return r_values
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _check_core_and_tidal_radii(r_core, r_tidal):
|
|
73
|
+
if not np.isfinite(r_core).all() or not np.isfinite(r_tidal).all():
|
|
74
|
+
raise ValueError("input parameters must be finite!")
|
|
75
|
+
if r_tidal < r_core or r_core < 0 or r_tidal < 0:
|
|
76
|
+
raise ValueError("parameters must satisfy 0 < r_core < r_tidal")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def king_surface_density_fast(r_values: np.ndarray, r_core: float, r_tidal: float):
|
|
80
|
+
"""Computes the King surface density (King 1962, equation 14) given the three parameters. Can take vectorised input.
|
|
81
|
+
Fast version intended for use with random sampling - it has no checks and cannot be normalised! Be careful!
|
|
82
|
+
|
|
83
|
+
Valid only for:
|
|
84
|
+
0 <= r < r_tidal (NOT CHECKED in this function!)
|
|
85
|
+
0 < r_core < r_tidal (NOT CHECKED in this function!)
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
r_values (np.ndarray): r values to compute the surface density at. Must be a numpy array!
|
|
89
|
+
r_core (float): the core radius of the cluster.
|
|
90
|
+
r_tidal (float): the tidal radius of the cluster.
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
a float or array of floats of the surface density for the cluster.
|
|
94
|
+
|
|
95
|
+
"""
|
|
96
|
+
# Constants
|
|
97
|
+
rt_rc = r_tidal / r_core
|
|
98
|
+
a = (1 + rt_rc**2) ** -0.5
|
|
99
|
+
|
|
100
|
+
# Compute result
|
|
101
|
+
return ((1 + (r_values / r_core) ** 2) ** (-0.5) - a) ** 2
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def sample_2d_king_profile(
|
|
105
|
+
r_core: float,
|
|
106
|
+
r_tidal: float,
|
|
107
|
+
n_samples: int,
|
|
108
|
+
seed=None,
|
|
109
|
+
oversampling_factor: float = 10,
|
|
110
|
+
return_generator: bool = False,
|
|
111
|
+
):
|
|
112
|
+
"""Samples a 2D King profile to return n_samples sample radii.
|
|
113
|
+
|
|
114
|
+
Valid only for:
|
|
115
|
+
0 < r_core < r_tidal
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
r_core (float): the core radius of the cluster.
|
|
119
|
+
r_tidal (float): the tidal radius of the cluster.
|
|
120
|
+
n_samples (int): the number of samples to generate.
|
|
121
|
+
seed (int, optional): the seed of the random number generator. Default: None.
|
|
122
|
+
oversampling_factor (float): how many times n_samples to generate each step, which helps to make sure that
|
|
123
|
+
enough samples are quickly generated in just one or two loops. Default: 10.
|
|
124
|
+
return_generator (bool): whether or not to return the numpy random number generator created. Default: False
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
an array of sample radii of size n_samples, plus the random generator if return_generator==True.
|
|
128
|
+
"""
|
|
129
|
+
_check_core_and_tidal_radii(r_core, r_tidal)
|
|
130
|
+
|
|
131
|
+
r_samples = np.empty(n_samples, dtype=float)
|
|
132
|
+
generator = np.random.default_rng(seed=seed)
|
|
133
|
+
completed_samples = 0
|
|
134
|
+
|
|
135
|
+
max_value = king_surface_density_fast(np.zeros(1), r_core, r_tidal)[0]
|
|
136
|
+
|
|
137
|
+
while completed_samples < n_samples:
|
|
138
|
+
remaining_samples = n_samples - completed_samples
|
|
139
|
+
samples_to_generate = int(
|
|
140
|
+
np.clip(remaining_samples * oversampling_factor, 10, np.inf)
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
# Generate some initial radius samples
|
|
144
|
+
test_r_values = generator.uniform(high=r_tidal, size=samples_to_generate)
|
|
145
|
+
test_king_values = king_surface_density_fast(test_r_values, r_core, r_tidal)
|
|
146
|
+
test_mcmc_values = generator.uniform(size=samples_to_generate, high=max_value)
|
|
147
|
+
|
|
148
|
+
# See which & how many are valid and save them!
|
|
149
|
+
valid_test_samples = test_king_values < test_mcmc_values
|
|
150
|
+
n_valid_test_samples = np.count_nonzero(valid_test_samples)
|
|
151
|
+
|
|
152
|
+
if n_valid_test_samples > remaining_samples:
|
|
153
|
+
r_samples[completed_samples:] = test_r_values[valid_test_samples][
|
|
154
|
+
:remaining_samples
|
|
155
|
+
]
|
|
156
|
+
break
|
|
157
|
+
|
|
158
|
+
r_samples[
|
|
159
|
+
completed_samples : completed_samples + n_valid_test_samples
|
|
160
|
+
] = test_r_values[valid_test_samples]
|
|
161
|
+
completed_samples += n_valid_test_samples
|
|
162
|
+
|
|
163
|
+
if return_generator:
|
|
164
|
+
return r_samples, generator
|
|
165
|
+
else:
|
|
166
|
+
return r_samples
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def sample_1d_king_profile(
|
|
170
|
+
r_core: float,
|
|
171
|
+
r_tidal: float,
|
|
172
|
+
n_samples: int,
|
|
173
|
+
seed: Optional[int] = None,
|
|
174
|
+
oversampling_factor: float = 10,
|
|
175
|
+
):
|
|
176
|
+
"""Samples a 1D King profile (e.g. useful to get line of sight distances from the center of a cluster.) Uses a
|
|
177
|
+
little trick - assumes that we're looking at the cluster side-on and removes the not-line-of-sight axis as if we
|
|
178
|
+
were looking at it from the front. (This is because I cba to work out a 1D profile and more to the point, I couldn't
|
|
179
|
+
fucking find one)
|
|
180
|
+
|
|
181
|
+
Todo: change this to using the strip density g(x), which I *think* could do this better...
|
|
182
|
+
|
|
183
|
+
Valid only for:
|
|
184
|
+
0 < r_core < r_tidal
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
r_core (float): the core radius of the cluster.
|
|
188
|
+
r_tidal (float): the tidal radius of the cluster.
|
|
189
|
+
n_samples (int): the number of samples to generate.
|
|
190
|
+
seed (int, optional): the seed of the random number generator. Default: None.
|
|
191
|
+
oversampling_factor (float): how many times n_samples to generate each step, which helps to make sure that
|
|
192
|
+
enough samples are quickly generated in just one or two loops. Default: 10.
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
an array of sample radii of size n_samples
|
|
196
|
+
"""
|
|
197
|
+
r_samples, generator = sample_2d_king_profile(
|
|
198
|
+
r_core,
|
|
199
|
+
r_tidal,
|
|
200
|
+
n_samples,
|
|
201
|
+
seed=seed,
|
|
202
|
+
oversampling_factor=oversampling_factor,
|
|
203
|
+
return_generator=True,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
# Now, deproject this to 1D by giving every value an angle and then finding the 1D radius with the cosine
|
|
207
|
+
random_angles = generator.uniform(high=2 * np.pi, size=n_samples)
|
|
208
|
+
|
|
209
|
+
return r_samples * np.cos(random_angles)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def king_number_density(r, r_core, r_tidal, k=1):
|
|
213
|
+
"""Calculates the King1962 number density (eqn 18 in the paper.)
|
|
214
|
+
|
|
215
|
+
Unnormalised by default (i.e. k=1.)
|
|
216
|
+
"""
|
|
217
|
+
x = (r / r_core)**2
|
|
218
|
+
x_t = (r_tidal / r_core)**2
|
|
219
|
+
|
|
220
|
+
term_1 = np.log(1 + x)
|
|
221
|
+
term_2 = - 4 * ((1 + x)**(0.5) - 1) / (1 + x_t)**(0.5)
|
|
222
|
+
term_3 = x / (1 + x_t)
|
|
223
|
+
|
|
224
|
+
return np.pi * r_core**2 * k * (term_1 + term_2 + term_3)
|
|
225
|
+
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from . import epsilon
|
|
2
|
+
from .nearest_neighbor import precalculate_nn_distances
|
|
3
|
+
from .preprocess import cut_dataset, rescale_dataset, recenter_dataset
|
|
4
|
+
from .resample import generate_gaia_covariance_matrix, resample_gaia_astrometry
|
|
5
|
+
import warnings
|
|
6
|
+
|
|
7
|
+
warnings.warn(
|
|
8
|
+
"ocelot.cluster API will change soon. Most objects will move, change, or be "
|
|
9
|
+
"deprecated.",
|
|
10
|
+
DeprecationWarning,
|
|
11
|
+
)
|