# Source code for xga.samples.extended

#  This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS).

from typing import Union, List

import numpy as np
from astropy.cosmology import Planck15
from astropy.units import Quantity
from tqdm import tqdm

from .base import BaseSample
from ..exceptions import PeakConvergenceFailedError, ModelNotAssociatedError, ParameterNotAssociatedError, \
NoProductAvailableError, NoValidObservationsError
from ..products.profile import GasDensity3D
from ..relations.fit import *
from ..sources.extended import GalaxyCluster

# Names are required for the ClusterSample because they'll be used to access specific cluster objects
[docs]class ClusterSample(BaseSample):
"""
A sample class to be used for declaring and analysing populations of galaxy clusters, with many cluster-science
specific functions, such as the ability to create common scaling relations.

:param np.ndarray ra: The right-ascensions of the clusters, in degrees.
:param np.ndarray dec: The declinations of the clusters, in degrees.
:param np.ndarray redshift: The redshifts of the clusters, required for cluster analysis.
:param np.ndarray name: The names of the clusters.
:param Quantity r200: Values for the R200s of the clusters. At least one overdensity radius must be passed.
:param Quantity r500: Values for the R500s of the clusters. At least one overdensity radius must be passed.
:param Quantity r2500: Values for the R2500s of the clusters. At least one overdensity radius must be passed.
:param np.ndarray richness: Optical richnesses of the clusters, optional.
:param np.ndarray richness_err: Uncertainties on the optical richnesses of the clusters, optional.
:param Quantity wl_mass: Weak lensing masses of the clusters, optional.
:param Quantity wl_mass_err: Uncertainties on the weak lensing masses of the clusters, optional.
:param Quantity custom_region_radius: A custom analysis region radius for this cluster, optional.
:param bool use_peak: Whether peak position should be found and used.
:param Quantity peak_lo_en: The lower energy bound for the RateMap to calculate peak position
from. Default is 0.5keV
:param Quantity peak_hi_en: The upper energy bound for the RateMap to calculate peak position
from. Default is 2.0keV.
:param float back_inn_rad_factor: This factor is multiplied by an analysis region radius, and gives the inner
radius for the background region. Default is 1.05.
:param float back_out_rad_factor: This factor is multiplied by an analysis region radius, and gives the outer
radius for the background region. Default is 1.5.
:param cosmology: An astropy cosmology object for use throughout analysis of the source.
:param str peak_find_method: Which peak finding method should be used (if use_peak is True). Default
is hierarchical, simple may also be passed.
:param bool clean_obs: Should the observations be subjected to a minimum coverage check, i.e. whether a
certain fraction of a certain region is covered by an ObsID. Default is True.
:param str clean_obs_reg: The region to use for the cleaning step, default is R200.
:param float clean_obs_threshold: The minimum coverage fraction for an observation to be kept for analysis.
:param str peak_find_method: Which peak finding method should be used (if use_peak is True). Default
is hierarchical, simple may also be passed.
"""
def __init__(self, ra: np.ndarray, dec: np.ndarray, redshift: np.ndarray, name: np.ndarray, r200: Quantity = None,
r500: Quantity = None, r2500: Quantity = None, richness: np.ndarray = None,
richness_err: np.ndarray = None, wl_mass: Quantity = None, wl_mass_err: Quantity = None,
custom_region_radius: Quantity = None, use_peak: bool = True,
peak_lo_en: Quantity = Quantity(0.5, "keV"), peak_hi_en: Quantity = Quantity(2.0, "keV"),
load_fits: bool = False, clean_obs: bool = True, clean_obs_reg: str = "r200",
clean_obs_threshold: float = 0.3, no_prog_bar: bool = False, psf_corr: bool = False,
peak_find_method: str = "hierarchical"):
"""
The init of the ClusterSample XGA class, for the analysis of a large sample of galaxy clusters.
Takes information on the clusters to enable analyses.
"""

# I don't like having this here, but it does avoid a circular import problem
from xga.sas import evselect_image, eexpmap, emosaic

# Using the super defines BaseSources and stores them in the self._sources dictionary
no_prog_bar=no_prog_bar)

# This part is super useful - it is much quicker to use the base sources to generate all
#  necessary ratemaps, as we can do it in parallel for the entire sample, rather than one at a time as
#  might be necessary for peak finding in the cluster init.
evselect_image(self, peak_lo_en, peak_hi_en)
eexpmap(self, peak_lo_en, peak_hi_en)
emosaic(self, "image", peak_lo_en, peak_hi_en)
emosaic(self, "expmap", peak_lo_en, peak_hi_en)

# Now that we've made those images the BaseSource objects aren't required anymore, we're about
#  to define GalaxyClusters
del self._sources
self._sources = {}

# We have this final names list in case so that we don't need to remove elements of self.names if one of the
#  clusters doesn't pass the observation cleaning stage.
final_names = []
# This records which clusters had a failed peak finding attempt, for a warning at the end of the declaration
failed_peak_find = []
with tqdm(desc="Setting up Galaxy Clusters", total=len(self.names), disable=no_prog_bar) as dec_lb:
for ind, r in enumerate(ra):
# Just splitting out relevant values for this particular cluster so the object declaration isn't
#  super ugly.
d = dec[ind]
z = redshift[ind]
# The replace is there because source declaration removes spaces from any passed names,
n = name[ind].replace(' ', '')
# Declaring the BaseSample higher up weeds out those objects that aren't in any XMM observations
#  So we want to check that the current object name is in the list of objects that have data
if n in self.names:
# I know this code is a bit ugly, but oh well
if r200 is not None:
r2 = r200[ind]
else:
r2 = None
if r500 is not None:
r5 = r500[ind]
else:
r5 = None
if r2500 is not None:
r25 = r2500[ind]
else:
r25 = None
else:
cr = None

# Here we check the options that are allowed to be None
if richness is not None:
lam = richness[ind]
lam_err = richness_err[ind]
else:
lam = None
lam_err = None

if wl_mass is not None:
wlm = wl_mass[ind]
wlm_err = wl_mass_err[ind]
else:
wlm = None
wlm_err = None

# Will definitely load products (the True in this call), because I just made sure I generated a
#  bunch to make GalaxyCluster declaration quicker
try:
# Declare the galaxy cluster, telling it is a part of a sample with in_sample=True
self._sources[n] = GalaxyCluster(r, d, z, n, r2, r5, r25, lam, lam_err, wlm, wlm_err, cr,
clean_obs_reg, clean_obs_threshold, False, peak_find_method,
True)
final_names.append(n)

except PeakConvergenceFailedError:
try:
failed_peak_find.append(n)
# If the peak finding failed, we need to re-declare the galaxy cluster, telling it is
#  a part of a sample with in_sample=True
self._sources[n] = GalaxyCluster(r, d, z, n, r2, r5, r25, lam, lam_err, wlm, wlm_err, cr,
clean_obs_reg, clean_obs_threshold, False,
peak_find_method, True)
final_names.append(n)
except NoValidObservationsError:
# warn("After a failed attempt to find an X-ray peak, and after applying the criteria for "
#      "the minimum amount of cluster required on an observation, {} cannot be declared as "
#      "all potential observations were removed".format(n))
self._failed_sources[n] = "Failed ObsClean"

except NoValidObservationsError:
# warn("After applying the criteria for the minimum amount of cluster required on an "
#      "observation, {} cannot be declared as all potential observations were removed".format(n))
# Note we don't append n to the final_names list here, as it is effectively being
#  removed from the sample
self._failed_sources[n] = "Failed ObsClean"
dec_lb.update(1)

self._names = final_names

# And again I ask XGA to generate the merged images and exposure maps, in case any sources have been
#  cleaned and had data removed
if clean_obs:
emosaic(self, "image", peak_lo_en, peak_hi_en)
emosaic(self, "expmap", peak_lo_en, peak_hi_en)

if clean_obs and use_peak:
for n in self.names:
# If the source in question has had data removed
if self._sources[n].disassociated:
try:
# This re-runs peak finding
self._sources[n]._all_peaks(peak_find_method)
self._sources[n]._default_coord = self._sources[n].peak

except PeakConvergenceFailedError:
pass

# I don't offer the user choices as to the configuration for PSF correction at the moment
if psf_corr:
from ..imagetools.psf import rl_psf
rl_psf(self, lo_en=peak_lo_en, hi_en=peak_hi_en)

# It is possible (especially if someone is using the Sample classes as a way to check whether things have
#  XMM data) that no sources will have been declared by this point, in which case it should fail now
if len(self._sources) == 0:
raise NoValidObservationsError(
"No Galaxy Clusters have been declared, none of the sample passed the cleaning steps.")

# Put all the warnings for there being no XMM data in one - I think it's neater. Wait until after the check
#  to make sure that are some sources because in that case this warning is redundant.
no_data = [name for name in self._failed_sources if self._failed_sources[name] == 'NoMatch' or
self._failed_sources[name] == 'Failed ObsClean']
# If there are names in that list, then we do the warning
if len(no_data) != 0:
warn("The following do not appear to have any XMM data, and will not be included in the "
"sample (can also check .failed_names); {n}".format(n=', '.join(no_data)), stacklevel=2)

# We also do a combined warning for those clusters that had a failed peak finding attempt, if there are any
if len(failed_peak_find) != 0:
warn("Peak finding did not converge for the following; {n}, using user "
"supplied coordinates".format(n=', '.join(failed_peak_find)), stacklevel=2)

# This shows a warning that tells the user how to see any suppressed warnings that occurred during source
#  declarations, but only if there actually were any.
self._check_source_warnings()

@property
def r200_snr(self) -> np.ndarray:
"""
Fetches and returns the R200 signal to noises from the constituent sources.

:return: The signal to noise ration calculated at the R200.
:rtype: np.ndarray
"""
snrs = []
for s in self:
try:
snrs.append(s.get_snr("r200"))
except ValueError:
snrs.append(None)
return np.array(snrs)

@property
def r500_snr(self) -> np.ndarray:
"""
Fetches and returns the R500 signal to noises from the constituent sources.

:return: The signal to noise ration calculated at the R500.
:rtype: np.ndarray
"""
snrs = []
for s in self:
try:
snrs.append(s.get_snr("r500"))
except ValueError:
snrs.append(None)
return np.array(snrs)

@property
def r2500_snr(self) -> np.ndarray:
"""
Fetches and returns the R2500 signal to noises from the constituent sources.

:return: The signal to noise ration calculated at the R2500.
:rtype: np.ndarray
"""
snrs = []
for s in self:
try:
snrs.append(s.get_snr("r2500"))
except ValueError:
snrs.append(None)
return np.array(snrs)

@property
def richness(self) -> Quantity:
"""
Provides the richnesses of the clusters in this sample, if they were passed in on definition.

:return: A unitless Quantity object of the richnesses and their error(s).
:rtype: Quantity
"""
rs = []
for gcs in self._sources.values():
rs.append(gcs.richness.value)

rs = np.array(rs)

# We're going to throw an error if all the richnesses are NaN, because obviously something is wrong
check_rs = rs[~np.isnan(rs)]
if len(check_rs) == 0:
raise ValueError("All richnesses appear to be NaN.")

return Quantity(rs)

@property
def wl_mass(self) -> Quantity:
"""
Provides the weak lensing masses of the clusters in this sample, if they were passed in on definition.

:return: A Quantity object of the WL masses and their error(s), in whatever units they were when
they were passed in originally.
:rtype: Quantity
"""
wlm = []
for gcs in self._sources.values():
wlm.append(gcs.weak_lensing_mass.value)
wlm_unit = gcs.weak_lensing_mass.unit

wlm = np.array(wlm)

# We're going to throw an error if all the weak lensing masses are NaN, because obviously something is wrong
check_wlm = wlm[~np.isnan(wlm)]
if len(check_wlm) == 0:
raise ValueError("All weak lensing masses appear to be NaN.")

return Quantity(wlm, wlm_unit)

@property
def r200(self) -> Quantity:
"""
Returns all the R200 values passed in on declaration, but in units of kpc.

:return: A quantity of R200 values.
:rtype: Quantity
"""
for gcs in self._sources.values():
else:

raise ValueError("All R200 values appear to be NaN.")

@property
def r500(self) -> Quantity:
"""
Returns all the R500 values passed in on declaration, but in units of kpc.

:return: A quantity of R500 values.
:rtype: Quantity
"""
for gcs in self._sources.values():
else:

raise ValueError("All R500 values appear to be NaN.")

@property
def r2500(self) -> Quantity:
"""
Returns all the R2500 values passed in on declaration, but in units of kpc.

:return: A quantity of R2500 values.
:rtype: Quantity
"""
for gcs in self._sources.values():
else:

raise ValueError("All R2500 values appear to be NaN.")

[docs]    def Lx(self, outer_radius: Union[str, Quantity], model: str = 'constant*tbabs*apec',
inner_radius: Union[str, Quantity] = Quantity(0, 'arcsec'), lo_en: Quantity = Quantity(0.5, 'keV'),
hi_en: Quantity = Quantity(2.0, 'keV'), group_spec: bool = True, min_counts: int = 5, min_sn: float = None,
over_sample: float = None, quality_checks: bool = True):
"""
A get method for luminosities measured for the constituent sources of this sample. An error will be
thrown if luminosities haven't been measured for the given region and model, no default model has been
set, unlike the Tx method of ClusterSample. An extra condition that aims to only return 'good' data has
been included, so that any Lx measurement with an uncertainty greater than value will be set to NaN, and
a warning will be issued.

This overrides the BaseSample method, but the only difference is that this has a default model, which
is what single_temp_apec fits (constant*tbabs*apec).

:param str model: The name of the fitted model that you're requesting the luminosities
from (e.g. constant*tbabs*apec).
:param str/Quantity outer_radius: The name or value of the outer radius that was used for the generation of
the spectra which were fitted to produce the desired result (for instance 'r200' would be acceptable
for a GalaxyCluster, or Quantity(1000, 'kpc')). You may also pass a quantity containing radius values,
with one value for each source in this sample.
:param str/Quantity inner_radius: The name or value of the inner radius that was used for the generation of
the spectra which were fitted to produce the desired result (for instance 'r500' would be acceptable
for a GalaxyCluster, or Quantity(300, 'kpc')). By default this is zero arcseconds, resulting in a
circular spectrum. You may also pass a quantity containing radius values, with one value for each
source in this sample.
:param Quantity lo_en: The lower energy limit for the desired luminosity measurement.
:param Quantity hi_en: The upper energy limit for the desired luminosity measurement.
:param bool group_spec: Whether the spectra that were fitted for the desired result were grouped.
:param float min_counts: The minimum counts per channel, if the spectra that were fitted for the
desired result were grouped by minimum counts.
:param float min_sn: The minimum signal to noise per channel, if the spectra that were fitted for the
desired result were grouped by minimum signal to noise.
:param float over_sample: The level of oversampling applied on the spectra that were fitted.
:param bool quality_checks: Whether the quality checks to make sure a returned value is good enough
to use should be performed.
:return: An Nx3 array Quantity where N is the number of sources. First column is the luminosity, second
column is the -err, and 3rd column is the +err. If a fit failed then that entry will be NaN
:rtype: Quantity
"""
quality_checks)

[docs]    def Tx(self, outer_radius: Union[str, Quantity] = 'r500', model: str = 'constant*tbabs*apec',
inner_radius: Union[str, Quantity] = Quantity(0, 'arcsec'), group_spec: bool = True, min_counts: int = 5,
min_sn: float = None, over_sample: float = None, quality_checks: bool = True):
"""
A get method for temperatures measured for the constituent clusters of this sample. An error will be
thrown if temperatures haven't been measured for the given region (the default is R_500) and model (default
is the constant*tbabs*apec model which single_temp_apec fits to cluster spectra). Any clusters for which
temperature fits failed will return NaN temperatures, and with temperature greater than 25keV is considered
failed, any temperature with a negative error value is considered failed, any temperature where the Tx-low
err is less than zero isn't returned, and any temperature where one of the errors is more than three times
larger than the other is considered failed (if quality checks are on).

:param str model: The name of the fitted model that you're requesting the results
from (e.g. constant*tbabs*apec).
:param str/Quantity outer_radius: The name or value of the outer radius that was used for the generation of
the spectra which were fitted to produce the desired result (for instance 'r200' would be acceptable
for a GalaxyCluster, or Quantity(1000, 'kpc')). If 'region' is chosen (to use the regions in
region files), then any inner radius will be ignored. You may also pass a quantity containing radius
values, with one value for each source in this sample. The default for this method is r500.
:param str/Quantity inner_radius: The name or value of the inner radius that was used for the generation of
the spectra which were fitted to produce the desired result (for instance 'r500' would be acceptable
for a GalaxyCluster, or Quantity(300, 'kpc')). By default this is zero arcseconds, resulting in a
circular spectrum. You may also pass a quantity containing radius values, with one value for each
source in this sample.
:param bool group_spec: Whether the spectra that were fitted for the desired result were grouped.
:param float min_counts: The minimum counts per channel, if the spectra that were fitted for the
desired result were grouped by minimum counts.
:param float min_sn: The minimum signal to noise per channel, if the spectra that were fitted for the
desired result were grouped by minimum signal to noise.
:param float over_sample: The level of oversampling applied on the spectra that were fitted.
:param bool quality_checks: Whether the quality checks to make sure a returned value is good enough
to use should be performed.
:return: An Nx3 array Quantity where N is the number of clusters. First column is the temperature, second
column is the -err, and 3rd column is the +err. If a fit failed then that entry will be NaN.
:rtype: Quantity
"""
# Has to be here to prevent circular import unfortunately
from ..sas.spec import region_setup

# This just parses the input inner and outer radii into something predictable
else:
raise NotImplementedError("Sorry region fitting is currently not supported")

temps = []
for src_ind, gcs in enumerate(self._sources.values()):
try:
# Fetch the temperature from a given cluster using the dedicated method
min_sn, over_sample).value

# If the measured temperature is 64keV I know that's a failure condition of the XSPEC fit,
#  so its set to NaN
if quality_checks and gcs_temp[0] > 25:
gcs_temp = np.array([np.NaN, np.NaN, np.NaN])
warn("A temperature of {m}keV was measured for {s}, anything over 30keV considered a failed "
"fit by XGA".format(s=gcs.name, m=gcs_temp))
elif quality_checks and gcs_temp.min() < 0:
gcs_temp = np.array([np.NaN, np.NaN, np.NaN])
warn("A negative value was detected in the temperature array for {s}, this is considered a failed "
"measurement".format(s=gcs.name))
elif quality_checks and ((gcs_temp[0] - gcs_temp[1]) <= 0):
gcs_temp = np.array([np.NaN, np.NaN, np.NaN])
warn("The temperature value - the lower error goes below zero for {s}, this makes the temperature"
" hard to use for scaling relations as values are often logged.".format(s=gcs.name))
elif quality_checks and ((gcs_temp[1] / gcs_temp[2]) > 3 or (gcs_temp[1] / gcs_temp[2]) < 0.33):
gcs_temp = np.array([np.NaN, np.NaN, np.NaN])
warn("One of the temperature uncertainty values for {s} is more than three times larger than "
"the other, this means the fit quality is suspect.".format(s=gcs.name))
elif quality_checks and ((gcs_temp[0] - gcs_temp[1:].mean()) < 0):
gcs_temp = np.array([np.NaN, np.NaN, np.NaN])
warn("The temperature value - the average error goes below zero for {s}, this makes the "
"temperature hard to use for scaling relations as values are often logged".format(s=gcs.name))
temps.append(gcs_temp)

except (ValueError, ModelNotAssociatedError, ParameterNotAssociatedError) as err:
# If any of the possible errors are thrown, we print the error as a warning and replace
#  that entry with a NaN
warn(str(err))
temps.append(np.array([np.NaN, np.NaN, np.NaN]))

# Turn the list of 3 element arrays into an Nx3 array which is then turned into an astropy Quantity
temps = Quantity(np.array(temps), 'keV')

# We're going to throw an error if all the temperatures are NaN, because obviously something is wrong
check_temps = temps[~np.isnan(temps)]
if len(check_temps) == 0:
raise ValueError("All temperatures appear to be NaN.")

return temps

[docs]    def gas_mass(self, rad_name: str, dens_model: str, method: str, prof_outer_rad: Union[Quantity, str] = None,
pix_step: int = 1, min_snr: Union[float, int] = 0.0, psf_corr: bool = True,
psf_model: str = "ELLBETA", psf_bins: int = 4, psf_algo: str = "rl", psf_iter: int = 15,
set_ids: List[int] = None, quality_checks: bool = True) -> Quantity:
"""
A convenient get method for gas masses measured for the constituent clusters of this sample, though the
arguments that can  be passed to retrieve gas density profiles are limited to tone-down the complexity.
Largely this method assumes you have measured density profiles from combined surface brightness
profiles, though if you have generated density profiles from annular spectra and know their set ids you may
pass them. Any more nuanced access to density profiles will have to be done yourself.

If the specified density model hasn't been fitted to the density profile, this method will run a fit using
the default settings of the fit method of XGA profiles.

A gas mass will be set to NaN if either of the uncertainties are larger than the gas mass value, if
the gas mass value is less than 1e+9 solar masses, if the gas mass value is greater than 1e+16 solar
masses, if quality checks are on.

:param str rad_name: The name of the radius (e.g. r500) to calculate the gas mass within.
:param str dens_model: The model fit to the density profiles to be used to calculate gas mass. If a fit
doesn't already exist then one will be performed with default settings.
:param str method: The method used to generate the density profile. For a profile created by fitting a model
to a surface brightness profile this should be the name of the model, for a profile from annular spectra
this should be 'spec', and for a profile generated directly from the data of a surface brightness profile
this should be 'onion'.
:param Quantity/str prof_outer_rad: The outer radii of the density profiles, either a single radius name or a
Quantity containing an outer radius for each cluster. For instance if you defined a ClusterSample called
srcs you could pass srcs.r500 here.
:param int pix_step: The width of each annulus in pixels used to generate the profile, for profiles based on
surface brightness.
:param float min_snr: The minimum signal to noise imposed upon the profile, for profiles based on
surface brightness.
:param bool psf_corr: Is the brightness profile corrected for PSF effects, for profiles based on
surface brightness.
:param str psf_model: If PSF corrected, the PSF model used, for profiles based on surface brightness.
:param int psf_bins: If PSF corrected, the number of bins per side, for profiles based on surface brightness.
:param str psf_algo: If PSF corrected, the algorithm used, for profiles based on surface brightness.
:param int psf_iter: If PSF corrected, the number of algorithm iterations, for profiles based on
surface brightness.
:param List[int] set_ids: A list of AnnularSpectra set IDs used to generate the density profiles, if you wish
to use spectrum based density profiles here.
:param bool quality_checks: Whether the quality checks to make sure a returned value is good enough
to use should be performed.
:return: An Nx3 array Quantity where N is the number of clusters. First column is the gas mass, second
column is the -err, and 3rd column is the +err. If a fit failed then that entry will be NaN.
:rtype: Quantity
"""
# Has to be here to prevent circular import unfortunately
from ..sas.spec import region_setup

gms = []
else:

if set_ids is not None and len(set_ids) != len(self):
raise ValueError("If you wish to use density profiles generated from spectra you must supply a list of "
"set IDs with an entry for each cluster in this sample.")
elif set_ids is None:
set_ids = [None]*len(self)

# Iterate through all of our Galaxy Clusters
for gcs_ind, gcs in enumerate(self._sources.values()):
try:
pix_step=pix_step, min_snr=min_snr, psf_corr=psf_corr,
psf_model=psf_model, psf_bins=psf_bins, psf_algo=psf_algo,
psf_iter=psf_iter, set_id=set_ids[gcs_ind])
else:
pix_step=pix_step, min_snr=min_snr, psf_corr=psf_corr,
psf_model=psf_model, psf_bins=psf_bins, psf_algo=psf_algo,
psf_iter=psf_iter, set_id=set_ids[gcs_ind])

if isinstance(dens_profs, GasDensity3D):
if dens_model not in dens_profs.good_model_fits:
dens_profs.fit(dens_model)

try:
if quality_checks and (cur_gmass[1] > cur_gmass[0] or cur_gmass[2] > cur_gmass[0]):
gms.append([np.NaN, np.NaN, np.NaN])
elif quality_checks and cur_gmass[0] < Quantity(1e+9, 'Msun'):
gms.append([np.NaN, np.NaN, np.NaN])
warn("{s}'s gas mass is less than 1e+12 solar masses")
elif quality_checks and cur_gmass[0] > Quantity(1e+16, 'Msun'):
gms.append([np.NaN, np.NaN, np.NaN])
warn("{s}'s gas mass is greater than 1e+16 solar masses")
else:
gms.append(cur_gmass.value)
except ModelNotAssociatedError:
gms.append([np.NaN, np.NaN, np.NaN])
except ValueError:
gms.append([np.NaN, np.NaN, np.NaN])
warn("{s}'s gas mass is negative")

else:
warn("Somehow there multiple matches for {s}'s density profile, this is the developer's "
"fault.".format(s=gcs.name))
gms.append([np.NaN, np.NaN, np.NaN])

except NoProductAvailableError:
# If no dens_prof has been run or something goes wrong then NaNs are added
gms.append([np.NaN, np.NaN, np.NaN])
warn("{s} doesn't have a density profile associated, please look at "
"sourcetools.density.".format(s=gcs.name))

gms = np.array(gms)

# We're going to throw an error if all the gas masses are NaN, because obviously something is wrong
check_gms = gms[~np.isnan(gms)]
if len(check_gms) == 0:
raise ValueError("All gas masses appear to be NaN.")

return Quantity(gms, 'Msun')

[docs]    def hydrostatic_mass(self, rad_name: str, temp_model_name: str = None, dens_model_name: str = None,
quality_checks: bool = True) -> Quantity:
"""
A simple method for fetching hydrostatic masses of this sample of clusters. This function is limited, and if
you have generated multiple hydrostatic mass profiles you may have to use the get_hydrostatic_mass_profiles
function of each source directly, or use the returned profiles from the function that generated them.

If only one hydrostatic mass profile has been generated for each source, then you do not need to specify model
names, but if the same temperature and density profiles have been used to make a hydrostatic mass profile but
with different models then you may use them.

A mass will be set to NaN if either of the uncertainties are larger than the mass value, if the mass value
is less than 1e+12 solar masses, if the mass value is greater than 1e+16 solar masses (if quality checks
are on), or if no hydrostatic mass profile is available.

:param str rad_name: The name of the radius (e.g. r500) to calculate the hydrostatic mass within.
:param str temp_model_name: The name of the model used to fit the temperature profile used to generate the
required hydrostatic mass profile, default is None.
:param str dens_model_name: The name of the model used to fit the density profile used to generate the
required hydrostatic mass profile, default is None.
:param bool quality_checks: Whether the quality checks to make sure a returned value is good enough
to use should be performed.
:return: An Nx3 array Quantity where N is the number of clusters. First column is the hydrostatic mass, second
column is the -err, and 3rd column is the +err. If a fit failed then that entry will be NaN.
:rtype: Quantity
"""
ms = []

# Iterate through all of our Galaxy Clusters
for gcs_ind, gcs in enumerate(self._sources.values()):
try:
mass_profs = gcs.get_hydrostatic_mass_profiles(temp_model_name=temp_model_name,
dens_model_name=dens_model_name)
if isinstance(mass_profs, list):
raise ValueError("There are multiple matching hydrostatic mass profiles associated with {}, "
"you will have to retrieve masses manually.")
else:
try:
if quality_checks and (cur_mass[1] > cur_mass[0] or cur_mass[2] > cur_mass[0]):
ms.append([np.NaN, np.NaN, np.NaN])
warn("{s}'s mass uncertainties are larger than the mass value.")
elif quality_checks and cur_mass[0] < Quantity(1e+12, 'Msun'):
ms.append([np.NaN, np.NaN, np.NaN])
warn("{s}'s mass is less than 1e+12 solar masses")
elif quality_checks and cur_mass[0] > Quantity(1e+16, 'Msun'):
ms.append([np.NaN, np.NaN, np.NaN])
warn("{s}'s mass is greater than 1e+16 solar masses")
else:
ms.append(cur_mass.value)
except ValueError:
warn("{s}'s mass is negative")
ms.append([np.NaN, np.NaN, np.NaN])

except NoProductAvailableError:
# If no dens_prof has been run or something goes wrong then NaNs are added
ms.append([np.NaN, np.NaN, np.NaN])
warn("{s} doesn't have a matching hydrostatic mass profile associated".format(s=gcs.name))

ms = np.array(ms)
# We're going to throw an error if all the masses are NaN, because obviously something is wrong
check_ms = ms[~np.isnan(ms)]
if len(check_ms) == 0:
raise ValueError("All hydrostatic masses appear to be NaN.")

return Quantity(ms, 'Msun')

[docs]    def calc_overdensity_radii(self, delta: int, temp_model_name: str = None, dens_model_name: str = None) -> Quantity:
"""
A convenience method that allows for the calculation of overdensity radii from hydrostatic mass profiles
measured for sources in this sample. This method uses the 'overdensity_radius' method of each mass profile
to find the radius that corresponds to the user-supplied overdensity - common choices for cluster analysis
are Δ=2500, 500, and 200. Overdensity radii are defined as the radius at which the density is Δ times the
critical density of the Universe at the cluster redshift.

This function is limited, and if  you have generated multiple hydrostatic mass profiles you may have to use
the get_hydrostatic_mass_profiles function of each source directly, or use the returned profiles from the
function that generated them, then use 'overdensity_radius' yourself.

If only one hydrostatic mass profile has been generated for each source, then you do not need to specify model
names, but if the same temperature and density profiles have been used to make a hydrostatic mass profile but
with different models then you may use them.

:param int delta: The overdensity factor for which a radius is to be calculated.
:param str temp_model_name: The name of the model used to fit the temperature profile used to generate the
hydrostatic mass profile required for measuring overdensity radii, default is None.
:param str dens_model_name: The name of the model used to fit the density profile used to generate the
hydrostatic mass profile required for measuring overdensity radii, default is None.
:return: An astropy quantity array of the calculated radii, in kpc.
:rtype: Quantity
"""
# Just a list to store the radii in as they're being calculated - turned into an array quantity at the end
rs = []
# Iterating over the galaxy clusters in this sample
for gcs_ind, gcs in enumerate(self._sources.values()):
# First off, we try to fetch hydrostatic mass profile(s), and catch the exception if there
#  aren't any matching profiles
try:
mass_profs = gcs.get_hydrostatic_mass_profiles(temp_model_name=temp_model_name,
dens_model_name=dens_model_name)
# As I just ask for temperature and density model names, it's entirely possible that there are
#  multiple hydrostatic mass profiles that use those two models. If there are then the user
#  has to do this the long way around.
if isinstance(mass_profs, list):
raise ValueError("There are multiple matching hydrostatic mass profiles associated with {}, "
"you will have to retrieve profiles and calculate radii "
"manually.".format(gcs.name))

try:
# Simply calculate the overdensity radius for the delta requested by the user
except ValueError:
warn("Overdensity radius calculation for {s} failed because the default starting radii "
"didn't bracket the requested overdensity radius. See the docs of overdensity_radius "

rs.append(np.NaN)

except NoProductAvailableError:
# If no dens_prof has been run or something goes wrong then NaNs are added
rs.append(np.NaN)
warn("{s} doesn't have a matching hydrostatic mass profile associated".format(s=gcs.name))

# Turn the radii list into a quantity and return it
rs = Quantity(rs)
return rs

[docs]    def gm_richness(self, rad_name: str, dens_model: str, prof_outer_rad: Union[Quantity, str], dens_method: str,
x_norm: Quantity = Quantity(60), y_norm: Quantity = Quantity(1e+12, 'solMass'),
fit_method: str = 'odr', start_pars: list = None, pix_step: int = 1,
min_snr: Union[float, int] = 0.0, psf_corr: bool = True, psf_model: str = "ELLBETA",
psf_bins: int = 4, psf_algo: str = "rl", psf_iter: int = 15, set_ids: List[int] = None,
inv_efunc: bool = False) -> ScalingRelation:
"""
This generates a Gas Mass vs Richness scaling relation for this sample of Galaxy Clusters.

:param str rad_name: The name of the radius (e.g. r500) to measure gas mass within.
:param str dens_model: The model fit to the density profiles to be used to calculate gas mass. If a fit
doesn't already exist then one will be performed with default settings.
:param Quantity/str prof_outer_rad: The outer radii of the density profiles, either a single radius name or a
Quantity containing an outer radius for each cluster. For instance if you defined a ClusterSample called
srcs you could pas srcs.r500 here.
:param str dens_method: The method used to generate the density profile. For a profile created by fitting a
model to a surface brightness profile this should be the name of the model, for a profile from
annular spectra this should be 'spec', and for a profile generated directly from the data of a surface
brightness profile this should be 'onion'.
:param Quantity x_norm: Quantity to normalise the x data by.
:param Quantity y_norm: Quantity to normalise the y data by.
:param str fit_method: The name of the fit method to use to generate the scaling relation.
:param list start_pars: The start parameters for the fit run.
:param int pix_step: The width of each annulus in pixels used to generate the profile, for profiles based on
surface brightness.
:param float min_snr: The minimum signal to noise imposed upon the profile, for profiles based on
surface brightness.
:param bool psf_corr: Is the brightness profile corrected for PSF effects, for profiles based on
surface brightness.
:param str psf_model: If PSF corrected, the PSF model used, for profiles based on surface brightness.
:param int psf_bins: If PSF corrected, the number of bins per side, for profiles based on surface brightness.
:param str psf_algo: If PSF corrected, the algorithm used, for profiles based on surface brightness.
:param int psf_iter: If PSF corrected, the number of algorithm iterations, for profiles based on
surface brightness.
:param List[int] set_ids: A list of AnnularSpectra set IDs used to generate the density profiles, if you wish
to use spectrum based density profiles here.
:param bool inv_efunc: Should the inverse E(z) function be applied to the y-axis, if False then the
non-inverse will be applied.
:return: The XGA ScalingRelation object generated for this sample.
:rtype: ScalingRelation
"""
if rad_name in ['r200', 'r500', 'r2500']:
else:

if inv_efunc:
y_name = "E(z)$^{-1}$M$_{g," + rn + "}$"
e_factor = self.cosmo.inv_efunc(self.redshifts)
else:
y_name = "E(z)M$_{g," + rn + "}$"
e_factor = self.cosmo.efunc(self.redshifts)

# Just make sure fit method is lower case
fit_method = fit_method.lower()

# Read out the richness values into variables just for convenience sake
r_data = self.richness[:, 0]
r_errs = self.richness[:, 1]

# Read out the gas mass values, and multiply by the inverse e function for each cluster
psf_model, psf_bins, psf_algo, psf_iter, set_ids)
gm_vals *= e_factor[..., None]
gm_data = gm_vals[:, 0]
gm_err = gm_vals[:, 1:]

if fit_method == 'curve_fit':
scale_rel = scaling_relation_curve_fit(power_law, gm_data, gm_err, r_data, r_errs, y_norm, x_norm,
start_pars=start_pars, y_name=y_name, x_name=r"$\lambda$")
elif fit_method == 'odr':
scale_rel = scaling_relation_odr(power_law, gm_data, gm_err, r_data, r_errs, y_norm, x_norm,
start_pars=start_pars, y_name=y_name, x_name=r"$\lambda$")
elif fit_method == 'lira':
scale_rel = scaling_relation_lira(gm_data, gm_err, r_data, r_errs, y_norm, x_norm,
y_name=y_name, x_name=r"$\lambda$")
elif fit_method == 'emcee':
scaling_relation_emcee()
else:
raise ValueError('{e} is not a valid fitting method, please choose one of these: '
'{a}'.format(e=fit_method, a=' '.join(ALLOWED_FIT_METHODS)))

return scale_rel

# I don't allow the user to supply an inner radius here because I cannot think of a reason why you'd want to
#  make a scaling relation with a core excised temperature.
[docs]    def gm_Tx(self, rad_name: str, dens_model: str, prof_outer_rad: Union[Quantity, str], dens_method: str,
x_norm: Quantity = Quantity(4, 'keV'), y_norm: Quantity = Quantity(1e+12, 'solMass'),
fit_method: str = 'odr', start_pars: list = None, model: str = 'constant*tbabs*apec',
group_spec: bool = True, min_counts: int = 5, min_sn: float = None, over_sample: float = None,
pix_step: int = 1, min_snr: Union[float, int] = 0.0, psf_corr: bool = True, psf_model: str = "ELLBETA",
psf_bins: int = 4, psf_algo: str = "rl", psf_iter: int = 15, set_ids: List[int] = None,
inv_efunc: bool = False) -> ScalingRelation:
"""
This generates a Gas Mass vs Tx scaling relation for this sample of Galaxy Clusters.

:param str rad_name: The name of the radius (e.g. r500) to get values for.
:param str dens_model: The model fit to the density profiles to be used to calculate gas mass. If a fit
doesn't already exist then one will be performed with default settings.
:param Quantity/str prof_outer_rad: The outer radii of the density profiles, either a single radius name or a
Quantity containing an outer radius for each cluster. For instance if you defined a ClusterSample called
srcs you could pas srcs.r500 here.
:param str dens_method: The method used to generate the density profile. For a profile created by fitting a
model to a surface brightness profile this should be the name of the model, for a profile from
annular spectra this should be 'spec', and for a profile generated directly from the data of a surface
brightness profile this should be 'onion'.
:param Quantity x_norm: Quantity to normalise the x data by.
:param Quantity y_norm: Quantity to normalise the y data by.
:param str fit_method: The name of the fit method to use to generate the scaling relation.
:param list start_pars: The start parameters for the fit run.
:param str model: The name of the model that the temperatures were measured with.
:param bool group_spec: Whether the spectra that were fitted for the Tx values were grouped.
:param float min_counts: The minimum counts per channel, if the spectra that were fitted for the
Tx values were grouped by minimum counts.
:param float min_sn: The minimum signal to noise per channel, if the spectra that were fitted for the
Tx values were grouped by minimum signal to noise.
:param float over_sample: The level of oversampling applied on the spectra that were fitted.
:param int pix_step: The width of each annulus in pixels used to generate the profile, for profiles based on
surface brightness.
:param float min_snr: The minimum signal to noise imposed upon the profile, for profiles based on
surface brightness.
:param bool psf_corr: Is the brightness profile corrected for PSF effects, for profiles based on
surface brightness.
:param str psf_model: If PSF corrected, the PSF model used, for profiles based on surface brightness.
:param int psf_bins: If PSF corrected, the number of bins per side, for profiles based on surface brightness.
:param str psf_algo: If PSF corrected, the algorithm used, for profiles based on surface brightness.
:param int psf_iter: If PSF corrected, the number of algorithm iterations, for profiles based on
surface brightness.
:param List[int] set_ids: A list of AnnularSpectra set IDs used to generate the density profiles, if you wish
to use spectrum based density profiles here.
:return: The XGA ScalingRelation object generated for this sample.
:param bool inv_efunc: Should the inverse E(z) function be applied to the y-axis, if False then the
non-inverse will be applied.
:rtype: ScalingRelation
"""

if rad_name in ['r200', 'r500', 'r2500']:
else:

if inv_efunc:
e_factor = self.cosmo.inv_efunc(self.redshifts)
y_name = r"E(z)$^{-1}$M$_{\rm{g}," + rn + "}$"
else:
e_factor = self.cosmo.efunc(self.redshifts)
y_name = r"E(z)M$_{\rm{g}," + rn + "}$"

# Just make sure fit method is lower case
fit_method = fit_method.lower()

# Read out the temperature values into variables just for convenience sake
t_vals = self.Tx(rad_name, model, Quantity(0, 'deg'), group_spec, min_counts, min_sn, over_sample)
t_data = t_vals[:, 0]
t_errs = t_vals[:, 1:]

# Read out the mass values, and multiply by the inverse e function for each cluster
psf_model, psf_bins, psf_algo, psf_iter, set_ids)
gm_vals *= e_factor[..., None]
gm_data = gm_vals[:, 0]
gm_err = gm_vals[:, 1:]

x_name = r"T$_{\rm{x}," + rn + '}$'
if fit_method == 'curve_fit':
scale_rel = scaling_relation_curve_fit(power_law, gm_data, gm_err, t_data, t_errs, y_norm, x_norm,
start_pars=start_pars, y_name=y_name, x_name=x_name)
elif fit_method == 'odr':
scale_rel = scaling_relation_odr(power_law, gm_data, gm_err, t_data, t_errs, y_norm, x_norm,
start_pars=start_pars, y_name=y_name, x_name=x_name)
elif fit_method == 'lira':
scale_rel = scaling_relation_lira(gm_data, gm_err, t_data, t_errs, y_norm, x_norm,
y_name=y_name, x_name=x_name)
elif fit_method == 'emcee':
scaling_relation_emcee()
else:
raise ValueError('{e} is not a valid fitting method, please choose one of these: '
'{a}'.format(e=fit_method, a=' '.join(ALLOWED_FIT_METHODS)))

return scale_rel

[docs]    def Lx_richness(self, outer_radius: str = 'r500', x_norm: Quantity = Quantity(60),
y_norm: Quantity = Quantity(1e+44, 'erg/s'), fit_method: str = 'odr', start_pars: list = None,
model: str = 'constant*tbabs*apec', lo_en: Quantity = Quantity(0.5, 'keV'),
hi_en: Quantity = Quantity(2.0, 'keV'), inner_radius: Union[str, Quantity] = Quantity(0, 'arcsec'),
group_spec: bool = True, min_counts: int = 5, min_sn: float = None,
over_sample: float = None, inv_efunc: bool = True) -> ScalingRelation:
"""
This generates a Lx vs richness scaling relation for this sample of Galaxy Clusters. If you have run fits
to find core excised luminosity, and wish to use it in this scaling relation, then please don't forget
to supply an inner_radius to the method call.

:param str outer_radius: The name of the radius (e.g. r500) to get values for.
:param Quantity x_norm: Quantity to normalise the x data by.
:param Quantity y_norm: Quantity to normalise the y data by.
:param str fit_method: The name of the fit method to use to generate the scaling relation.
:param list start_pars: The start parameters for the fit run.
:param str model: The name of the model that the luminosities were measured with.
:param Quantity lo_en: The lower energy limit for the desired luminosity measurement.
:param Quantity hi_en: The upper energy limit for the desired luminosity measurement.
:param str/Quantity inner_radius: The name or value of the inner radius that was used for the generation of
the spectra which were fitted to produce the desired result (for instance 'r500' would be acceptable
for a GalaxyCluster, or Quantity(300, 'kpc')). By default this is zero arcseconds, resulting in a
circular spectrum. You may also pass a quantity containing radius values, with one value for each
source in this sample.
:param bool group_spec: Whether the spectra that were fitted for the desired result were grouped.
:param float min_counts: The minimum counts per channel, if the spectra that were fitted for the
desired result were grouped by minimum counts.
:param float min_sn: The minimum signal to noise per channel, if the spectra that were fitted for the
desired result were grouped by minimum signal to noise.
:param float over_sample: The level of oversampling applied on the spectra that were fitted.
:return: The XGA ScalingRelation object generated for this sample.
:param bool inv_efunc: Should the inverse E(z) function be applied to the y-axis, if False then the
non-inverse will be applied.
:rtype: ScalingRelation
"""
if outer_radius in ['r200', 'r500', 'r2500']:
else:
raise ValueError("As this is a method for a whole population, please use a named radius such as "
"r200, r500, or r2500.")

if inv_efunc:
y_name = "E(z)$^{-1}$L$_{x," + rn + ',' + str(lo_en.value) + '-' + str(hi_en.value) + "}$"
e_factor = self.cosmo.inv_efunc(self.redshifts)
else:
y_name = "E(z)L$_{x," + rn + ',' + str(lo_en.value) + '-' + str(hi_en.value) + "}$"
e_factor = self.cosmo.efunc(self.redshifts)

# Just make sure fit method is lower case
fit_method = fit_method.lower()

# Read out the richness values into variables just for convenience sake
r_data = self.richness[:, 0]
r_errs = self.richness[:, 1]

# Read out the luminosity values, and multiply by the inverse e function for each cluster
over_sample) * e_factor[..., None]
lx_data = lx_vals[:, 0]
lx_err = lx_vals[:, 1:]

if fit_method == 'curve_fit':
scale_rel = scaling_relation_curve_fit(power_law, lx_data, lx_err, r_data, r_errs, y_norm, x_norm,
start_pars=start_pars, y_name=y_name,
x_name=r"$\lambda$")
elif fit_method == 'odr':
scale_rel = scaling_relation_odr(power_law, lx_data, lx_err, r_data, r_errs, y_norm, x_norm,
start_pars=start_pars, y_name=y_name, x_name=r"$\lambda$")
elif fit_method == 'lira':
scale_rel = scaling_relation_lira(lx_data, lx_err, r_data, r_errs, y_norm, x_norm,
y_name=y_name, x_name=r"$\lambda$")
elif fit_method == 'emcee':
scaling_relation_emcee()
else:
raise ValueError('{e} is not a valid fitting method, please choose one of these: '
'{a}'.format(e=fit_method, a=' '.join(ALLOWED_FIT_METHODS)))

return scale_rel

[docs]    def Lx_Tx(self, outer_radius: str = 'r500', x_norm: Quantity = Quantity(4, 'keV'),
y_norm: Quantity = Quantity(1e+44, 'erg/s'), fit_method: str = 'odr', start_pars: list = None,
model: str = 'constant*tbabs*apec', lo_en: Quantity = Quantity(0.5, 'keV'),
hi_en: Quantity = Quantity(2.0, 'keV'), tx_inner_radius: Union[str, Quantity] = Quantity(0, 'arcsec'),
lx_inner_radius: Union[str, Quantity] = Quantity(0, 'arcsec'), group_spec: bool = True,
min_counts: int = 5, min_sn: float = None, over_sample: float = None,
inv_efunc: bool = True) -> ScalingRelation:
"""
This generates a Lx vs Tx scaling relation for this sample of Galaxy Clusters. If you have run fits
to find core excised luminosity, and wish to use it in this scaling relation, then you can specify the inner
radius of those spectra using lx_inner_radius, as well as ensuring that you use the temperature
fit you want by setting tx_inner_radius.

:param str outer_radius: The name of the radius (e.g. r500) to get values for.
:param Quantity x_norm: Quantity to normalise the x data by.
:param Quantity y_norm: Quantity to normalise the y data by.
:param str fit_method: The name of the fit method to use to generate the scaling relation.
:param list start_pars: The start parameters for the fit run.
:param str model: The name of the model that the luminosities and temperatures were measured with.
:param Quantity lo_en: The lower energy limit for the desired luminosity measurement.
:param Quantity hi_en: The upper energy limit for the desired luminosity measurement.
:param str/Quantity tx_inner_radius: The name or value of the inner radius that was used for the generation of
the spectra which were fitted to produce the temperature (for instance 'r500' would be acceptable
for a GalaxyCluster, or Quantity(300, 'kpc')). By default this is zero arcseconds, resulting in a
circular spectrum. You may also pass a quantity containing radius values, with one value for each
source in this sample.
:param str/Quantity lx_inner_radius: The name or value of the inner radius that was used for the generation of
the spectra which were fitted to produce the Lx. The same rules as tx_inner_radius apply, and this option
is particularly useful if you have measured core-excised luminosity an wish to use it in a scaling relation.
:param bool group_spec: Whether the spectra that were fitted for the desired result were grouped.
:param float min_counts: The minimum counts per channel, if the spectra that were fitted for the
desired result were grouped by minimum counts.
:param float min_sn: The minimum signal to noise per channel, if the spectra that were fitted for the
desired result were grouped by minimum signal to noise.
:param float over_sample: The level of oversampling applied on the spectra that were fitted.
:param bool inv_efunc: Should the inverse E(z) function be applied to the y-axis, if False then the
non-inverse will be applied.
:return: The XGA ScalingRelation object generated for this sample.
:rtype: ScalingRelation
"""

if outer_radius in ['r200', 'r500', 'r2500']:
else:
raise ValueError("As this is a method for a whole population, please use a named radius such as "
"r200, r500, or r2500.")

lx_rn = "Core-Excised " + rn
else:
lx_rn = rn

if inv_efunc:
y_name = "E(z)$^{-1}$L$_{x," + lx_rn + ',' + str(lo_en.value) + '-' + str(hi_en.value) + "}$"
e_factor = self.cosmo.inv_efunc(self.redshifts)
else:
y_name = "E(z)L$_{x," + lx_rn + ',' + str(lo_en.value) + '-' + str(hi_en.value) + "}$"
e_factor = self.cosmo.efunc(self.redshifts)

# Just make sure fit method is lower case
fit_method = fit_method.lower()

# Read out the luminosity values, and multiply by the inverse e function for each cluster
over_sample) * e_factor[..., None]
lx_data = lx_vals[:, 0]
lx_err = lx_vals[:, 1:]

# Read out the temperature values into variables just for convenience sake
t_data = t_vals[:, 0]
t_errs = t_vals[:, 1:]

x_name = r"T$_{x," + rn + '}$'
if fit_method == 'curve_fit':
scale_rel = scaling_relation_curve_fit(power_law, lx_data, lx_err, t_data, t_errs, y_norm, x_norm,
start_pars=start_pars, y_name=y_name,
x_name=x_name)
elif fit_method == 'odr':
scale_rel = scaling_relation_odr(power_law, lx_data, lx_err, t_data, t_errs, y_norm, x_norm,
start_pars=start_pars, y_name=y_name, x_name=x_name)
elif fit_method == 'lira':
scale_rel = scaling_relation_lira(lx_data, lx_err, t_data, t_errs, y_norm, x_norm,
y_name=y_name, x_name=x_name)
elif fit_method == 'emcee':
scaling_relation_emcee()
else:
raise ValueError('{e} is not a valid fitting method, please choose one of these: '
'{a}'.format(e=fit_method, a=' '.join(ALLOWED_FIT_METHODS)))

return scale_rel

[docs]    def mass_Tx(self, outer_radius: str = 'r500', x_norm: Quantity = Quantity(4, 'keV'),
y_norm: Quantity = Quantity(5e+14, 'Msun'), fit_method: str = 'odr', start_pars: list = None,
model: str = 'constant*tbabs*apec', tx_inner_radius: Union[str, Quantity] = Quantity(0, 'arcsec'),
group_spec: bool = True, min_counts: int = 5, min_sn: float = None, over_sample: float = None,
temp_model_name: str = None, dens_model_name: str = None, inv_efunc: bool = False) -> ScalingRelation:
"""
A convenience function to generate a hydrostatic mass-temperature relation for this sample of galaxy clusters.

out to which you wish to measure mass.
:param Quantity x_norm: Quantity to normalise the x data by.
:param Quantity y_norm: Quantity to normalise the y data by.
:param str fit_method: The name of the fit method to use to generate the scaling relation.
:param list start_pars: The start parameters for the fit run.
:param str model: The name of the model that the luminosities and temperatures were measured with.
:param str/Quantity tx_inner_radius: The name or value of the inner radius that was used for the generation of
the spectra which were fitted to produce the temperature (for instance 'r500' would be acceptable
for a GalaxyCluster, or Quantity(300, 'kpc')). By default this is zero arcseconds, resulting in a
circular spectrum. You may also pass a quantity containing radius values, with one value for each
source in this sample.
:param bool group_spec: Whether the spectra that were fitted for the desired result were grouped.
:param float min_counts: The minimum counts per channel, if the spectra that were fitted for the
desired result were grouped by minimum counts.
:param float min_sn: The minimum signal to noise per channel, if the spectra that were fitted for the
desired result were grouped by minimum signal to noise.
:param float over_sample: The level of oversampling applied on the spectra that were fitted.
:param str temp_model_name: The name of the model used to fit the temperature profile used to generate the
required hydrostatic mass profile, default is None.
:param str dens_model_name: The name of the model used to fit the density profile used to generate the
required hydrostatic mass profile, default is None.
:param bool inv_efunc: Should the inverse E(z) function be applied to the y-axis, if False then the
non-inverse will be applied.
:return: The XGA ScalingRelation object generated for this sample.
:rtype: ScalingRelation
"""

if outer_radius in ['r200', 'r500', 'r2500']:
else:
raise ValueError("As this is a method for a whole population, please use a named radius such as "
"r200, r500, or r2500.")

if inv_efunc:
y_name = r"E(z)$^{-1}$M$_{\rm{hydro," + rn + "}}$"
e_factor = self.cosmo.inv_efunc(self.redshifts)
else:
y_name = r"E(z)M$_{\rm{hydro," + rn + "}}$"
e_factor = self.cosmo.efunc(self.redshifts)

# Just make sure fit method is lower case
fit_method = fit_method.lower()

# Read out the luminosity values, and multiply by the inverse e function for each cluster
m_vals = self.hydrostatic_mass(outer_radius, temp_model_name, dens_model_name) * e_factor[..., None]
m_data = m_vals[:, 0]
m_err = m_vals[:, 1:]

# Read out the temperature values into variables just for convenience sake
t_data = t_vals[:, 0]
t_errs = t_vals[:, 1:]

x_name = r"T$_{\rm{x," + rn + '}}$'
if fit_method == 'curve_fit':
scale_rel = scaling_relation_curve_fit(power_law, m_data, m_err, t_data, t_errs, y_norm, x_norm,
start_pars=start_pars, y_name=y_name,
x_name=x_name)
elif fit_method == 'odr':
scale_rel = scaling_relation_odr(power_law, m_data, m_err, t_data, t_errs, y_norm, x_norm,
start_pars=start_pars, y_name=y_name, x_name=x_name)
elif fit_method == 'lira':
scale_rel = scaling_relation_lira(m_data, m_err, t_data, t_errs, y_norm, x_norm,
y_name=y_name, x_name=x_name)
elif fit_method == 'emcee':
scaling_relation_emcee()
else:
raise ValueError('{e} is not a valid fitting method, please choose one of these: '
'{a}'.format(e=fit_method, a=' '.join(ALLOWED_FIT_METHODS)))

return scale_rel

[docs]    def mass_richness(self, outer_radius: str = 'r500', x_norm: Quantity = Quantity(60),
y_norm: Quantity = Quantity(5e+14, 'Msun'), fit_method: str = 'odr', start_pars: list = None,
temp_model_name: str = None, dens_model_name: str = None, inv_efunc: bool = False) -> ScalingRelation:
"""
A convenience function to generate a hydrostatic mass-richness relation for this sample of galaxy clusters.

:param str outer_radius: The name of the radius (e.g. r500) to get values for.
:param Quantity x_norm: Quantity to normalise the x data by.
:param Quantity y_norm: Quantity to normalise the y data by.
:param str fit_method: The name of the fit method to use to generate the scaling relation.
:param list start_pars: The start parameters for the fit run.
:param str temp_model_name: The name of the model used to fit the temperature profile used to generate the
required hydrostatic mass profile, default is None.
:param str dens_model_name: The name of the model used to fit the density profile used to generate the
required hydrostatic mass profile, default is None.
:param bool inv_efunc: Should the inverse E(z) function be applied to the y-axis, if False then the
non-inverse will be applied.
:return: The XGA ScalingRelation object generated for this sample.
:rtype: ScalingRelation
"""
if outer_radius in ['r200', 'r500', 'r2500']:
else:
raise ValueError("As this is a method for a whole population, please use a named radius such as "
"r200, r500, or r2500.")

if inv_efunc:
y_name = r"E(z)$^{-1}$M$_{\rm{hydro," + rn + "}}$"
e_factor = self.cosmo.inv_efunc(self.redshifts)
else:
y_name = r"E(z)M$_{\rm{hydro," + rn + "}}$"
e_factor = self.cosmo.efunc(self.redshifts)

# Just make sure fit method is lower case
fit_method = fit_method.lower()

# Read out the richness values into variables just for convenience sake
r_data = self.richness[:, 0]
r_errs = self.richness[:, 1]

# Read out the luminosity values, and multiply by the inverse e function for each cluster
m_vals = self.hydrostatic_mass(outer_radius, temp_model_name, dens_model_name) * e_factor[..., None]
m_data = m_vals[:, 0]
m_err = m_vals[:, 1:]

if fit_method == 'curve_fit':
scale_rel = scaling_relation_curve_fit(power_law, m_data, m_err, r_data, r_errs, y_norm, x_norm,
start_pars=start_pars, y_name=y_name,
x_name=r"$\lambda$")
elif fit_method == 'odr':
scale_rel = scaling_relation_odr(power_law, m_data, m_err, r_data, r_errs, y_norm, x_norm,
start_pars=start_pars, y_name=y_name, x_name=r"$\lambda$")
elif fit_method == 'lira':
scale_rel = scaling_relation_lira(m_data, m_err, r_data, r_errs, y_norm, x_norm,
y_name=y_name, x_name=r"$\lambda$")
elif fit_method == 'emcee':
scaling_relation_emcee()
else:
raise ValueError('{e} is not a valid fitting method, please choose one of these: '
'{a}'.format(e=fit_method, a=' '.join(ALLOWED_FIT_METHODS)))

return scale_rel

[docs]    def mass_Lx(self, outer_radius: str = 'r500', x_norm: Quantity = Quantity(1e+44, 'erg/s'),
y_norm: Quantity = Quantity(5e+14, 'Msun'), fit_method: str = 'odr', start_pars: list = None,
model: str = 'constant*tbabs*apec', lo_en: Quantity = Quantity(0.5, 'keV'),
hi_en: Quantity = Quantity(2.0, 'keV'), lx_inner_radius: Union[str, Quantity] = Quantity(0, 'arcsec'),
group_spec: bool = True, min_counts: int = 5, min_sn: float = None, over_sample: float = None,
temp_model_name: str = None, dens_model_name: str = None, inv_efunc: bool = False) -> ScalingRelation:
"""
This generates a mass vs Lx scaling relation for this sample of Galaxy Clusters. If you have run fits
to find core excised luminosity, and wish to use it in this scaling relation, then you can specify the inner

:param str outer_radius: The name of the radius (e.g. r500) to get values for.
:param Quantity x_norm: Quantity to normalise the x data by.
:param Quantity y_norm: Quantity to normalise the y data by.
:param str fit_method: The name of the fit method to use to generate the scaling relation.
:param list start_pars: The start parameters for the fit run.
:param str model: The name of the model that the luminosities and temperatures were measured with.
:param Quantity lo_en: The lower energy limit for the desired luminosity measurement.
:param Quantity hi_en: The upper energy limit for the desired luminosity measurement.
:param str/Quantity lx_inner_radius: The name or value of the inner radius that was used for the generation of
the spectra which were fitted to produce the Lx. The same rules as tx_inner_radius apply, and this option
is particularly useful if you have measured core-excised luminosity an wish to use it in a scaling relation.
:param bool group_spec: Whether the spectra that were fitted for the desired result were grouped.
:param float min_counts: The minimum counts per channel, if the spectra that were fitted for the
desired result were grouped by minimum counts.
:param float min_sn: The minimum signal to noise per channel, if the spectra that were fitted for the
desired result were grouped by minimum signal to noise.
:param float over_sample: The level of oversampling applied on the spectra that were fitted.
:param str temp_model_name: The name of the model used to fit the temperature profile used to generate the
required hydrostatic mass profile, default is None.
:param str dens_model_name: The name of the model used to fit the density profile used to generate the
required hydrostatic mass profile, default is None.
:param bool inv_efunc: Should the inverse E(z) function be applied to the y-axis, if False then the
non-inverse will be applied.
:return: The XGA ScalingRelation object generated for this sample.
:rtype: ScalingRelation
"""
if outer_radius in ['r200', 'r500', 'r2500']:
else:
raise ValueError("As this is a method for a whole population, please use a named radius such as "
"r200, r500, or r2500.")

lx_rn = "Core-Excised " + rn
else:
lx_rn = rn

if inv_efunc:
y_name = r"E(z)$^{-1}$M$_{\rm{hydro," + rn + "}}$"
e_factor = self.cosmo.inv_efunc(self.redshifts)
else:
y_name = r"E(z)M$_{\rm{hydro," + rn + "}}$"
e_factor = self.cosmo.efunc(self.redshifts)

# Just make sure fit method is lower case
fit_method = fit_method.lower()

# Read out the luminosity values, and multiply by the inverse e function for each cluster
m_vals = self.hydrostatic_mass(outer_radius, temp_model_name, dens_model_name) * e_factor[..., None]
m_data = m_vals[:, 0]
m_err = m_vals[:, 1:]

# Read out the luminosity values, and multiply by the inverse e function for each cluster
over_sample)
lx_data = lx_vals[:, 0]
lx_err = lx_vals[:, 1:]

x_name = r"L$_{\rm{x," + lx_rn + ',' + str(lo_en.value) + '-' + str(hi_en.value) + "}}$"
if fit_method == 'curve_fit':
scale_rel = scaling_relation_curve_fit(power_law, m_data, m_err, lx_data, lx_err, y_norm, x_norm,
start_pars=start_pars, y_name=y_name,
x_name=x_name)
elif fit_method == 'odr':
scale_rel = scaling_relation_odr(power_law, m_data, m_err, lx_data, lx_err, y_norm, x_norm,
start_pars=start_pars, y_name=y_name, x_name=x_name)
elif fit_method == 'lira':
scale_rel = scaling_relation_lira(m_data, m_err, lx_data, lx_err, y_norm, x_norm,
y_name=y_name, x_name=x_name)
elif fit_method == 'emcee':
scaling_relation_emcee()
else:
raise ValueError('{e} is not a valid fitting method, please choose one of these: '
'{a}'.format(e=fit_method, a=' '.join(ALLOWED_FIT_METHODS)))

return scale_rel