From 6707bf967a15c8017445fc8eab6a1c83b44446c6 Mon Sep 17 00:00:00 2001 From: David Turner Date: Mon, 18 Nov 2024 22:49:08 -0500 Subject: [PATCH 01/85] Began the creation of the revamped hydrostatic mass profile - essentially will be a carbon copy of how the entropy profile works (I revamped that some months ago). The init is simply copied over, and no other methods are currently present (class is named NewHydrostaticMass for now, so I can easily look at the old one). --- xga/products/profile.py | 323 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 320 insertions(+), 3 deletions(-) diff --git a/xga/products/profile.py b/xga/products/profile.py index 7f83463d..d9ce081f 100644 --- a/xga/products/profile.py +++ b/xga/products/profile.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 07/08/2024, 14:48. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 18/11/2024, 22:49. Copyright (c) The Contributors from copy import copy from typing import Tuple, Union, List @@ -2127,10 +2127,327 @@ def rad_check(self, rad: Quantity): "you will be extrapolating based on the model fits.", stacklevel=2) + + + + +class NewHydrostaticMass(BaseProfile1D): + """ + A profile product which uses input temperature and density profiles to calculate a cumulative hydrostatic mass + profile - used in galaxy cluster analyses (https://ui.adsabs.harvard.edu/abs/2024arXiv240307982T/abstract + for instance). Similar in function to the SpecificEntropy profile class, in that hydrostatic mass values are + calculated during the declaration of this class from multiple other profiles, rather than being passed in directly. + + The hydrostatic mass profile can be used with several different kinds of input profiles, reflecting some of + the different ways that they are calculated in the literature, and the practical limitations of + generating 'de-projected' profiles. In short, this profile can be used in the following different ways: + + * Either projected, or de-projected (inferred 3D profiles) can be passed to this profile; the temperature and + density profiles also do not need to both be projected or both be de-projected. Clearly, from a purely physical + point of view, it would be better to pass 3D profiles, but practically de-projection processes often cause a lot + of problems, so the choice is left to the user. + * The hydrostatic mass values can be calculated either from models fit to the input profiles, or from the data + points of the input profiles. This means that the user can choose between a 'cleaner' profile from generated + from smooth models, or a data-driven profile that might better represent the intricacies of the particular + galaxy cluster. + * If data points are being used rather than models, and the radial binning is different between the temperature + and density profiles, then the data points on the profile with wider bins can either be interpolated, or matched + to the data points of the other profile that they cover. + + :param GasTemperature3D/ProjectedGasTemperature1D temperature_profile: The XGA 3D or projected + temperature profile to take temperature information from. + :param str/BaseModel1D temperature_model: The model to fit to the temperature profile (if smooth models are to + be used to calculate the hydrostatic mass profile), either a name or an instance of an XGA temperature + model class. Default is None, in which case this class will use profile data points to calculate + hydrostatic mass. + :param GasDensity3D density_profile: The XGA 3D density profile to take density information from. + :param str/BaseModel1D density_model: The model to fit to the density profile (if smooth models are to + be used to calculate the hydrostatic mass profile), either a name or an instance of an XGA density model class. + Default is None, in which case this class will use profile data points to calculate hydrostatic mass. + :param Quantity radii: The radii at which to measure the hydrostatic mass - this is only necessary if model fits + are being used to calculate hydrostatic mass, otherwise profile radii will be used. + :param Quantity radii_err: The uncertainties on the radii - this is only necessary if model fits are + being used to calculate hydrostatic mass, otherwise profile radii errors will be used. + :param Quantity deg_radii: The radii values, but in units of degrees - this is only necessary if model + fits are being used to calculate hydrostatic mass, otherwise profile radii will be used. + :param str fit_method: The name of the fit method to use for the fitting of the profiles, default is 'mcmc'. + :param int num_walkers: If the fit method is 'mcmc' then this will set the number of walkers for the emcee + sampler to set up. + :param list/int num_steps: If the fit method is 'mcmc' this will set the number of steps for each sampler + to take. If a single number is passed then that number of steps is used for both profiles, otherwise + if a list is passed the first entry is used for the temperature fit, and the second for the + density fit. + :param int num_samples: The number of random samples to be drawn from the posteriors of the fit results. + :param bool show_warn: Controls whether warnings produced the fitting processes are displayed. + :param bool progress: Controls whether fit progress bars are displayed. + :param bool interp_data: If the hydrostatic mass profile is to be derived from data points rather than fitted + models, this controls whether the data profile with the coarser bins is interpolated, or whether the other + profile's data points are matched with the value that was measured for the radial region they + are in (the default). + :param bool auto_save: Whether the profile should automatically save itself to disk at any point. The default is + False, but all profiles generated through XGA processes acting on XGA sources will auto-save. + """ + + def __init__(self, temperature_profile: Union[GasTemperature3D, ProjectedGasTemperature1D], + density_profile: GasDensity3D, temperature_model: Union[str, BaseModel1D] = None, + density_model: Union[str, BaseModel1D] = None, radii: Quantity = None, radii_err: Quantity = None, + deg_radii: Quantity = None, fit_method: str = "mcmc", num_walkers: int = 20, + num_steps: [int, List[int]] = 20000, num_samples: int = 1000, show_warn: bool = True, + progress: bool = True, interp_data: bool = False, auto_save: bool = False): + """ + A profile product which uses input temperature and density profiles to calculate a cumulative hydrostatic mass + profile - used in galaxy cluster analyses (https://ui.adsabs.harvard.edu/abs/2024arXiv240307982T/abstract + for instance). Similar in function to the SpecificEntropy profile class, in that hydrostatic mass values are + calculated during the declaration of this class from multiple other profiles, rather than being passed in + directly. + + The hydrostatic mass profile can be used with several different kinds of input profiles, reflecting some of + the different ways that they are calculated in the literature, and the practical limitations of + generating 'de-projected' profiles. In short, this profile can be used in the following different ways: + + * Either projected, or de-projected (inferred 3D profiles) can be passed to this profile; the temperature and + density profiles also do not need to both be projected or both be de-projected. Clearly, from a purely + physical point of view, it would be better to pass 3D profiles, but practically de-projection processes + often cause a lot of problems, so the choice is left to the user. + * The hydrostatic mass values can be calculated either from models fit to the input profiles, or from the data + points of the input profiles. This means that the user can choose between a 'cleaner' profile from generated + from smooth models, or a data-driven profile that might better represent the intricacies of the particular + galaxy cluster. + * If data points are being used rather than models, and the radial binning is different between the temperature + and density profiles, then the data points on the profile with wider bins can either be interpolated, or + matched to the data points of the other profile that they cover. + + :param GasTemperature3D/ProjectedGasTemperature1D temperature_profile: The XGA 3D or projected + temperature profile to take temperature information from. + :param str/BaseModel1D temperature_model: The model to fit to the temperature profile (if smooth models are to + be used to calculate the hydrostatic mass profile), either a name or an instance of an XGA temperature + model class. Default is None, in which case this class will use profile data points to calculate + hydrostatic mass. + :param GasDensity3D density_profile: The XGA 3D density profile to take density information from. + :param str/BaseModel1D density_model: The model to fit to the density profile (if smooth models are to + be used to calculate the hydrostatic mass profile), either a name or an instance of an XGA density + model class. Default is None, in which case this class will use profile data points to calculate + hydrostatic mass. + :param Quantity radii: The radii at which to measure the hydrostatic mass - this is only necessary if + model fits are being used to calculate hydrostatic mass, otherwise profile radii will be used. + :param Quantity radii_err: The uncertainties on the radii - this is only necessary if model fits are + being used to calculate hydrostatic mass, otherwise profile radii errors will be used. + :param Quantity deg_radii: The radii values, but in units of degrees - this is only necessary if model + fits are being used to calculate hydrostatic mass, otherwise profile radii will be used. + :param str fit_method: The name of the fit method to use for the fitting of the profiles, default is 'mcmc'. + :param int num_walkers: If the fit method is 'mcmc' then this will set the number of walkers for the emcee + sampler to set up. + :param list/int num_steps: If the fit method is 'mcmc' this will set the number of steps for each sampler + to take. If a single number is passed then that number of steps is used for both profiles, otherwise + if a list is passed the first entry is used for the temperature fit, and the second for the + density fit. + :param int num_samples: The number of random samples to be drawn from the posteriors of the fit results. + :param bool show_warn: Controls whether warnings produced the fitting processes are displayed. + :param bool progress: Controls whether fit progress bars are displayed. + :param bool interp_data: If the hydrostatic mass profile is to be derived from data points rather than + fitted models, this controls whether the data profile with the coarser bins is interpolated, or whether + the other profile's data points are matched with the value that was measured for the radial region they + are in (the default). + :param bool auto_save: Whether the profile should automatically save itself to disk at any point. The + default is False, but all profiles generated through XGA processes acting on XGA sources will auto-save. + """ + # This init and the SpecificEntropy init share the same DNA - lots of duplicated code unfortunately + + # We check whether the temperature profile passed is actually the type of profile we need + if not isinstance(temperature_profile, (GasTemperature3D, ProjectedGasTemperature1D)): + raise TypeError("The {} class is not an accepted input for 'temperature_profile'; only a GasTemperature3D " + "or ProjectedGasTemperature1D instance may be " + "passed.".format(str(type(temperature_profile)))) + + # We repeat this process with the density profile + # TODO Add a check for projected density, if I ever implement such a thing + if not isinstance(density_profile, GasDensity3D): + raise TypeError("The {} class is not an accepted input for 'density_profile'; only a GasDensity3D " + "instance may be passed.".format(str(type(density_profile)))) + + # We also need to check that someone hasn't done something dumb like pass profiles from two different + # clusters, so we'll compare source names. + if temperature_profile.src_name != density_profile.src_name: + raise ValueError("You have passed temperature and density profiles from two different " + "sources, any resulting hydrostatic mass measurements would not be valid, so this is not " + "allowed.") + # And check they were generated with the same central coordinate, otherwise they may not be valid. I + # considered only raising a warning, but I need a consistent central coordinate to pass to the super init + elif np.any(temperature_profile.centre != density_profile.centre): + raise ValueError("The temperature and density profiles do not have the same central coordinate.") + # Same reasoning with the ObsID and instrument + elif temperature_profile.obs_id != density_profile.obs_id: + warn("The temperature and density profiles do not have the same associated ObsID.", stacklevel=2) + elif temperature_profile.instrument != density_profile.instrument: + warn("The temperature and density profiles do not have the same associated instrument.", stacklevel=2) + + # Now we check whether the right combination of information has been passed depending on whether we are + # going to be using model fits or not (we need passed radii if a model is to be used). + if ((temperature_model is not None or density_model is not None) and + (radii is None or radii_err is None or deg_radii is None)): + raise ValueError("Radii at which to calculate hydrostatic mass (the 'radii', 'radii_err', and " + "'deg_radii' arguments) must be passed if 'temperature_model' or 'density_model' is set.") + else: + if len(temperature_profile) > len(density_profile): + # We restrict the radii to being within the bounds of the other profile if we are not interpolating + if not interp_data: + within_bnds = np.where((temperature_profile.radii >= density_profile.annulus_bounds.min()) & + (temperature_profile.radii <= density_profile.annulus_bounds.max()))[0] + else: + within_bnds = np.arange(0, len(temperature_profile.radii)) + + if len(within_bnds) != len(temperature_profile.radii): + warn("The radii extracted from the temperature profile for the creation of the hydrostatic mass " + "profile have been truncated to match the radius range of the density " + "profile.", stacklevel=2) + radii = temperature_profile.radii[within_bnds] + radii_err = temperature_profile.radii_err[within_bnds] + deg_radii = temperature_profile.deg_radii[within_bnds] + else: + # We restrict the radii to being within the bounds of the other profile if we are not interpolating + if not interp_data: + within_bnds = np.where((density_profile.radii >= temperature_profile.annulus_bounds.min()) & + (density_profile.radii <= temperature_profile.annulus_bounds.max()))[0] + else: + within_bnds = np.arange(0, len(density_profile.radii)) + + if len(within_bnds) != len(density_profile.radii): + warn("The radii extracted from the density profile for the creation of the hydrostatic mass " + "profile have been truncated to match the radius range of the temperature " + "profile.", stacklevel=2) + + radii = density_profile.radii[within_bnds] + radii_err = density_profile.radii_err[within_bnds] + deg_radii = density_profile.deg_radii[within_bnds] + + # Set the attribute which lets the entropy calculation method know whether to interpolate any data points + # or not, if smooth fitted models are not going to be used + self._interp_data = interp_data + + # We see if either of the profiles have an associated spectrum + if temperature_profile.set_ident is None and density_profile.set_ident is None: + set_id = None + set_store = None + elif temperature_profile.set_ident is None and density_profile.set_ident is not None: + set_id = density_profile.set_ident + set_store = density_profile.associated_set_storage_key + elif temperature_profile.set_ident is not None and density_profile.set_ident is None: + set_id = temperature_profile.set_ident + set_store = temperature_profile.associated_set_storage_key + elif temperature_profile.set_ident is not None and density_profile.set_ident is not None: + if temperature_profile.set_ident != density_profile.set_ident: + warn("The temperature and density profile you passed were generated from different sets of annular" + " spectra, the hydrostatic mass profile's associated set ident will be set to None.", stacklevel=2) + set_id = None + set_store = None + else: + set_id = temperature_profile.set_ident + set_store = temperature_profile.associated_set_storage_key + + self._temp_prof = temperature_profile + self._dens_prof = density_profile + + if not radii.unit.is_equivalent("kpc"): + raise UnitConversionError("Radii unit cannot be converted to kpc") + else: + radii = radii.to('kpc') + radii_err = radii_err.to('kpc') + # This will be overwritten by the super() init call, but it allows rad_check to work + self._radii = radii + + # We won't REQUIRE that the profiles have data point generated at the same radii, as we're gonna + # measure entropy from the models, but I do need to check that the passed radii are within the radii of the + # and warn the user if they aren't + self.rad_check(radii) + + if isinstance(num_steps, int): + temp_steps = num_steps + dens_steps = num_steps + elif isinstance(num_steps, list) and len(num_steps) == 2: + temp_steps = num_steps[0] + dens_steps = num_steps[1] + else: + raise ValueError("If a list is passed for num_steps then it must have two entries, the first for the " + "temperature profile fit and the second for the density profile fit") + + # If models are passed then we're going to make sure that they're fit here - starting with temperature. We'll + # also retrieve the model object. The if statements are separate because we may allow for the fitting of + # one model and not another, using a combination of model and datapoints to calculate entropy + if temperature_model is not None: + temperature_model = temperature_profile.fit(temperature_model, fit_method, num_samples, temp_steps, + num_walkers, progress, show_warn) + key_temp_mod_part = "tm{t}".format(t=temperature_model.name) + # Have to check whether the fits were actually successful, as the fit method will return a model instance + # either way + if not temperature_model.success: + raise XGAFitError("The fit to the temperature was unsuccessful, cannot define hydrostatic mass " + "profile.") + elif interp_data: + key_temp_mod_part = "tmdatainterp" + else: + key_temp_mod_part = "tmdata" + + if density_model is not None: + density_model = density_profile.fit(density_model, fit_method, num_samples, dens_steps, num_walkers, + progress, show_warn) + key_dens_mod_part = "dm{d}".format(d=density_model.name) + # Have to check whether the fits were actually successful, as the fit method will return a model instance + # either way + if not density_model.success: + raise XGAFitError("The fit to the density was unsuccessful, cannot define hydrostatic mass profile.") + elif interp_data: + key_dens_mod_part = "dmdatainterp" + else: + key_dens_mod_part = "dmdata" + + self._temp_model = temperature_model + self._dens_model = density_model + + # We set an attribute with the 'num_samples' parameter - it has been passed into the model fits already but + # we also use that value for the number of data realisations if the user has opted for a data point derived + # entropy profile rather than model derived. + self._num_samples = num_samples + + ent, ent_dist = self.entropy(radii, conf_level=68) + ent_vals = ent[0, :] + ent_errs = np.mean(ent[1:, :], axis=0) + + super().__init__(radii, ent_vals, self._temp_prof.centre, self._temp_prof.src_name, self._temp_prof.obs_id, + self._temp_prof.instrument, radii_err, ent_errs, set_id, set_store, deg_radii, + auto_save=auto_save) + + # Need a custom storage key for this entropy profile, incorporating all the information we have about what + # went into it, density profile, temperature profile, radii, density and temperature models - identical to + # the form used by HydrostaticMass profiles. + dens_part = "dprof_{}".format(self._dens_prof.storage_key) + temp_part = "tprof_{}".format(self._temp_prof.storage_key) + cur_part = self.storage_key + + whole_new = "{ntm}_{ndm}_{c}_{t}_{d}".format(ntm=key_temp_mod_part, ndm=key_dens_mod_part, c=cur_part, + t=temp_part, d=dens_part) + self._storage_key = whole_new + + # Setting the type + self._prof_type = "hydrostatic_mass" + + # This is what the y-axis is labelled as during plotting + self._y_axis_name = r"M$_{\rm{hydro}}$" + + # Setting up a dictionary to store mass results in. + self._masses = {} + + + + + + + + + class SpecificEntropy(BaseProfile1D): """ A profile product which uses input temperature and density profiles to calculate a specific entropy profile of - the kind often uses in galaxy cluster analyses (https://ui.adsabs.harvard.edu/abs/2009ApJS..182...12C/abstract + the kind often used in galaxy cluster analyses (https://ui.adsabs.harvard.edu/abs/2009ApJS..182...12C/abstract for instance). Somewhat similar in function to the HydrostaticMass profile class, in that entropy values are calculated during the declaration of this class, rather than being passed in. @@ -2161,7 +2478,7 @@ class SpecificEntropy(BaseProfile1D): :param Quantity radii: The radii at which to measure the entropy - this is only necessary if model fits are being used to calculate entropy, otherwise profile radii will be used. :param Quantity radii_err: The uncertainties on the radii - this is only necessary if model fits are - being used to calculate entropy, otherwise profile radii will be used. + being used to calculate entropy, otherwise profile radii errors will be used. :param Quantity deg_radii: The radii values, but in units of degrees - this is only necessary if model fits are being used to calculate entropy, otherwise profile radii will be used. :param str fit_method: The name of the fit method to use for the fitting of the profiles, default is 'mcmc'. From 9e3cb442986544e105e81c7d64d04008fa530069 Mon Sep 17 00:00:00 2001 From: David Turner Date: Tue, 19 Nov 2024 09:43:33 -0500 Subject: [PATCH 02/85] Building out the bones of the revamped hydrostatic mass profile - also improved some docstrings in the SpecificEntropy profile class. --- xga/products/profile.py | 855 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 773 insertions(+), 82 deletions(-) diff --git a/xga/products/profile.py b/xga/products/profile.py index d9ce081f..54c6957c 100644 --- a/xga/products/profile.py +++ b/xga/products/profile.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 18/11/2024, 22:49. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 19/11/2024, 09:43. Copyright (c) The Contributors from copy import copy from typing import Tuple, Union, List @@ -2320,8 +2320,8 @@ def __init__(self, temperature_profile: Union[GasTemperature3D, ProjectedGasTemp radii_err = density_profile.radii_err[within_bnds] deg_radii = density_profile.deg_radii[within_bnds] - # Set the attribute which lets the entropy calculation method know whether to interpolate any data points - # or not, if smooth fitted models are not going to be used + # Set the attribute which lets the hydrostatic mass calculation method know whether to interpolate any + # data points or not, if smooth fitted models are not going to be used self._interp_data = interp_data # We see if either of the profiles have an associated spectrum @@ -2344,101 +2344,792 @@ def __init__(self, temperature_profile: Union[GasTemperature3D, ProjectedGasTemp set_id = temperature_profile.set_ident set_store = temperature_profile.associated_set_storage_key - self._temp_prof = temperature_profile - self._dens_prof = density_profile + self._temp_prof = temperature_profile + self._dens_prof = density_profile + + if not radii.unit.is_equivalent("kpc"): + raise UnitConversionError("Radii unit cannot be converted to kpc") + else: + radii = radii.to('kpc') + radii_err = radii_err.to('kpc') + # This will be overwritten by the super() init call, but it allows rad_check to work + self._radii = radii + + # We won't REQUIRE that the profiles have data point generated at the same radii, as we're gonna + # measure entropy from the models, but I do need to check that the passed radii are within the radii of the + # and warn the user if they aren't + self.rad_check(radii) + + if isinstance(num_steps, int): + temp_steps = num_steps + dens_steps = num_steps + elif isinstance(num_steps, list) and len(num_steps) == 2: + temp_steps = num_steps[0] + dens_steps = num_steps[1] + else: + raise ValueError("If a list is passed for num_steps then it must have two entries, the first for the " + "temperature profile fit and the second for the density profile fit.") + + # If models are passed then we're going to make sure that they're fit here - starting with temperature. We'll + # also retrieve the model object. The if statements are separate because we may allow for the fitting of + # one model and not another, using a combination of model and datapoints to calculate hydrostatic mass + if temperature_model is not None: + temperature_model = temperature_profile.fit(temperature_model, fit_method, num_samples, temp_steps, + num_walkers, progress, show_warn) + key_temp_mod_part = "tm{t}".format(t=temperature_model.name) + # Have to check whether the fits were actually successful, as the fit method will return a model instance + # either way + if not temperature_model.success: + raise XGAFitError("The fit to the temperature was unsuccessful, cannot define hydrostatic mass " + "profile.") + elif interp_data: + key_temp_mod_part = "tmdatainterp" + else: + key_temp_mod_part = "tmdata" + + if density_model is not None: + density_model = density_profile.fit(density_model, fit_method, num_samples, dens_steps, num_walkers, + progress, show_warn) + key_dens_mod_part = "dm{d}".format(d=density_model.name) + # Have to check whether the fits were actually successful, as the fit method will return a model instance + # either way + if not density_model.success: + raise XGAFitError("The fit to the density was unsuccessful, cannot define hydrostatic mass profile.") + elif interp_data: + key_dens_mod_part = "dmdatainterp" + else: + key_dens_mod_part = "dmdata" + + self._temp_model = temperature_model + self._dens_model = density_model + + # We set an attribute with the 'num_samples' parameter - it has been passed into the model fits already, but + # we also use that value for the number of data realizations if the user has opted for a data point derived + # hydrostatic mass profile rather than model derived. + self._num_samples = num_samples + + ent, ent_dist = self.entropy(radii, conf_level=68) + ent_vals = ent[0, :] + ent_errs = np.mean(ent[1:, :], axis=0) + + super().__init__(radii, ent_vals, self._temp_prof.centre, self._temp_prof.src_name, self._temp_prof.obs_id, + self._temp_prof.instrument, radii_err, ent_errs, set_id, set_store, deg_radii, + auto_save=auto_save) + + # Need a custom storage key for this entropy profile, incorporating all the information we have about what + # went into it, density profile, temperature profile, radii, density and temperature models - identical to + # the form used by HydrostaticMass profiles. + dens_part = "dprof_{}".format(self._dens_prof.storage_key) + temp_part = "tprof_{}".format(self._temp_prof.storage_key) + cur_part = self.storage_key + + whole_new = "{ntm}_{ndm}_{c}_{t}_{d}".format(ntm=key_temp_mod_part, ndm=key_dens_mod_part, c=cur_part, + t=temp_part, d=dens_part) + self._storage_key = whole_new + + # Setting the type + self._prof_type = "hydrostatic_mass" + + # This is what the y-axis is labelled as during plotting + self._y_axis_name = r"M$_{\rm{hydro}}$" + + # Setting up a dictionary to store mass results in. + self._masses = {} + + def mass(self, radius: Quantity, conf_level: float = 68.2, radius_err: Quantity = None) -> Union[Quantity, Quantity]: + """ + A method which will measure a hydrostatic mass and hydrostatic mass uncertainty within the given + radius/radii. No corrections are applied to the values calculated by this method, it is just the vanilla + hydrostatic mass. + + If the models for temperature and density have analytical solutions to their derivative wrt to radius then + those will be used to calculate the gradients at radius, but if not then a numerical method will be used for + which dx will be set to radius/1e+6. + + :param Quantity radius: An astropy quantity containing the radius/radii that you wish to calculate the + mass within. + :param float conf_level: The confidence level for the mass uncertainties, the default is 68.2% (~1σ). + :param Quantity radius_err: A standard deviation on radius, which will be taken into account during the + calculation of hydrostatic mass. + :return: An astropy quantity containing the mass/masses, lower and upper uncertainties, and another containing + the mass realization distribution. + :rtype: Union[Quantity, Quantity] + """ + raise NotImplementedError + # Setting the upper and lower confidence limits + upper = 50 + (conf_level / 2) + lower = 50 - (conf_level / 2) + + # Prints a warning if the radius at which to calculate the entropy is outside the range of the data + self.rad_check(radius) + + # If a particular radius already has a result in the profiles storage structure then we'll just grab that + # rather than redoing a calculation unnecessarily. + if radius.isscalar and radius in self._entropies: + already_run = True + ent_dist = self._entropies[radius] + else: + already_run = False + + # Here, if we haven't already identified a previously calculated entropy for the radius, we start to + # prepare the data we need (i.e. temperature and density). This is complicated slightly by the different + # ways of calculating entropy we support (using smooth models, using data points, using interpolated data + # points). First of all we deal with the case of there being a density model to draw from + if not already_run and self.density_model is not None: + # If the density model fit didn't work then we give up and throw an error + if not self.density_model.success: + raise XGAFitError("The density model fit was not successful, as such we cannot calculate entropy " + "using a smooth density model.") + # Getting a bunch of realisations (with the number set by the 'num_samples' argument that was passed on + # the definition of this source of the model. + dens = self._dens_model.get_realisations(radius) + + # In this rare case (inspired by how ACCEPT packaged their profiles, see issue #1176) the radii for the + # temperature and density profiles are identical, and so we just get some realisations + elif (not already_run and (len(self.density_profile) == len(self.temperature_profile)) and + (self.density_profile.radii == self.temperature_profile.radii).all()): + dens = self.density_profile.generate_data_realisations(self._num_samples).T + + elif not already_run and self._interp_data: + # This uses the density profile y-axis values (and their uncertainties) to draw N realisations of the + # data points - we'll use this to create N realisations of the interpolations as well + dens_data_real = self.density_profile.generate_data_realisations(self._num_samples) + # TODO This unfortunately may be removed from scipy soon, but the np.interp linear interpolation method + # doesn't currently support interpolating along a particular axis. Also considering more sophisticated + # scipy interpolation methods (see issue #1168) but cubic splines don't seem to behave amazingly well + # for temperature profiles with larger uncertainties on then outskirts, so we're doing this for now + # We make sure to turn on extrapolation, and make sure this is no out-of-bounds error issued + dens_interp = interp1d(self.density_profile.radii, dens_data_real, axis=1, assume_sorted=True, + fill_value='extrapolate', bounds_error=False) + # Restore the interpolated density profile realisations to an astropy quantity array + dens = Quantity(dens_interp(self.radii).T, self.density_profile.values_unit) + + # This particular combination means that we are doing a data-point based profile, but without interpolation, + # and that the density profile has more bins than the temperature (going to be true in most cases). So we + # just read out the density data points (and make N realisations of them) with no funny business required + elif not already_run and not self._interp_data and len(self.density_profile) == len(self.radii): + dens = self.density_profile.generate_data_realisations(self._num_samples).T + else: + d_bnds = np.vstack([self.density_profile.annulus_bounds[0:-1], + self.density_profile.annulus_bounds[1:]]).T + + d_inds = np.where((self.radii[..., None] >= d_bnds[:, 0]) & (self.radii[..., None] < d_bnds[:, 1]))[1] + + dens_data_real = self.density_profile.generate_data_realisations(self._num_samples) + dens = dens_data_real[:, d_inds].T + + # Finally, whatever way we got the densities, we make sure they are in the right unit + if not already_run and not dens.unit.is_equivalent('1/cm^3'): + dens = dens / (MEAN_MOL_WEIGHT * m_p) + + # We now essentially repeat the process we just did with the density profiles, constructing the temperature + # values that we are going to use in our entropy measurements; from models, data points, or interpolating + # from data points + if not already_run and self.temperature_model is not None: + if not self.temperature_model.success: + raise XGAFitError("The temperature model fit was not successful, as such we cannot calculate entropy " + "using a smooth temperature model.") + # Getting a bunch of realisations (with the number set by the 'num_samples' argument that was passed on + # the definition of this source of the model. + temp = self._temp_model.get_realisations(radius) + + # In this rare case (inspired by how ACCEPT packaged their profiles, see issue #1176) the radii for the + # temperature and density profiles are identical, and so we just get some realisations + elif (not already_run and (len(self.density_profile) == len(self.temperature_profile)) and + (self.density_profile.radii == self.temperature_profile.radii).all()): + temp = self.temperature_profile.generate_data_realisations(self._num_samples).T + + elif not already_run and self._interp_data: + # This uses the temperature profile y-axis values (and their uncertainties) to draw N realisations of the + # data points - we'll use this to create N realisations of the interpolations as well + temp_data_real = self.temperature_profile.generate_data_realisations(self._num_samples) + temp_interp = interp1d(self.temperature_profile.radii, temp_data_real, axis=1, assume_sorted=True, + fill_value='extrapolate', bounds_error=False) + temp = Quantity(temp_interp(self.radii).T, self.temperature_profile.values_unit) + + # This particular combination means that we are doing a data-point based profile, but without interpolation, + # and that the temperature profile has more bins than the density (not going to happen often) + elif not already_run and not self._interp_data and len(self.temperature_profile) == len(self.radii): + temp = self.temperature_profile.generate_data_realisations(self._num_samples).T + # And here, the final option, we're doing a data-point based profile without interpolation, and we need + # to make sure that the density values (here N_denspoints > N_temppoints) each have a corresponding + # temperature value - in practise this means that each density will be paired with the temperature + # realisations whose radial coverage they fall within. + else: + t_bnds = np.vstack([self.temperature_profile.annulus_bounds[0:-1], + self.temperature_profile.annulus_bounds[1:]]).T + + t_inds = np.where((self.radii[..., None] >= t_bnds[:, 0]) & (self.radii[..., None] < t_bnds[:, 1]))[1] + + temp_data_real = self.temperature_profile.generate_data_realisations(self._num_samples) + temp = temp_data_real[:, t_inds].T + + # We ensure the temperatures are in the right unit + if not already_run and not temp.unit.is_equivalent('keV'): + temp = (temp * k_B).to('keV') + + # And now we do the actual entropy calculation + if not already_run: + ent_dist = (temp / dens**(2/3)).T + # Storing the result if it is for a single radius + if radius.isscalar: + self._entropies[radius] = ent_dist + + # Whether we just calculated the entropy, or we fetched it from storage at the beginning of this method + # call, we use the distribution to calculate median and confidence limit values + ent_med = np.nanpercentile(ent_dist, 50, axis=0) + ent_lower = ent_med - np.nanpercentile(ent_dist, lower, axis=0) + ent_upper = np.nanpercentile(ent_dist, upper, axis=0) - ent_med + + # Set up the result to return as an astropy quantity. + ent_res = Quantity(np.array([ent_med.value, ent_lower.value, ent_upper.value]), ent_dist.unit) + + if np.any(ent_res[0] < 0): + raise ValueError("A specific entropy of less than zero has been measured, which is not physical.") + + return ent_res, ent_dist + + def annular_mass(self, outer_radius: Quantity, inner_radius: Quantity, conf_level: float = 68.2): + """ + Calculate the hydrostatic mass contained within a specific 3D annulus, bounded by the outer and inner radius + supplied to this method. Annular mass is calculated by measuring the mass within the inner and outer + radii, and then subtracting the inner from the outer. Also supports calculating multiple annular masses + when inner_radius and outer_radius are non-scalar. + + WARNING - THIS METHOD INVOLVES SUBTRACTING TWO MASS DISTRIBUTIONS, WHICH CAN'T NECESSARILY BE APPROXIMATED + AS GAUSSIAN DISTRIBUTIONS, AS SUCH RESULTS FROM THIS METHOD SHOULD BE TREATED WITH SOME SUSPICION. + + :param Quantity outer_radius: Astropy containing outer radius (or radii) for the annulus (annuli) within + which you wish to measure the mass. If calculating multiple annular masses, the length of outer_radius + must be the same as inner_radius. + :param Quantity inner_radius: Astropy containing inner radius (or radii) for the annulus (annuli) within + which you wish to measure the mass. If calculating multiple annular masses, the length of inner_radius + must be the same as outer_radius. + :param float conf_level: The confidence level for the mass uncertainties, the default is 68.2% (~1σ). + :return: An astropy quantity containing a mass distribution(s). Quantity will become two-dimensional + when multiple sets of inner and outer radii are passed by the user. + :rtype: Quantity + """ + # Perform some checks to make sure that the user has passed inner and outer radii quantities that are valid + # and won't break any of the calculations that will be happening in this method + if outer_radius.isscalar != inner_radius.isscalar: + raise ValueError("The outer_radius and inner_radius Quantities must both be scalar, or both " + "be non-scalar.") + elif (not inner_radius.isscalar and inner_radius.ndim != 1) or \ + (not outer_radius.isscalar and outer_radius.ndim != 1): + raise ValueError('Non-scalar radius Quantities must have only one dimension') + elif not outer_radius.isscalar and not inner_radius.isscalar and outer_radius.shape != inner_radius.shape: + raise ValueError('The outer_radius and inner_radius Quantities must be the same shape.') + + # This just measures the masses within two radii, the outer and the inner supplied by the user. The mass() + # method will automatically deal with the input of multiple entries for each radius + outer_mass, outer_mass_dist = self.mass(outer_radius, conf_level) + inner_mass, inner_mass_dist = self.mass(inner_radius, conf_level) + + # This PROBABLY NOT AT ALL valid because they're just posterior distributions of mass + return outer_mass_dist - inner_mass_dist + + def view_mass_dist(self, radius: Quantity, conf_level: float = 68.2, figsize=(8, 8), bins: Union[str, int] = 'auto', + colour: str = "lightslategrey"): + """ + A method which will generate a histogram of the mass distribution that resulted from the mass calculation + at the supplied radius. If the mass for the passed radius has already been measured it, and the mass + distribution, will be retrieved from the storage of this product rather than re-calculated. + + :param Quantity radius: An astropy quantity containing the radius/radii that you wish to calculate the + mass within. + :param float conf_level: The confidence level for the mass uncertainties, the default is 68.2% (~1σ). + :param int/str bins: The argument to be passed to plt.hist, either a number of bins or a binning + algorithm name. + :param str colour: The desired colour of the histogram. + :param tuple figsize: The desired size of the histogram figure. + """ + if not radius.isscalar: + raise ValueError("Unfortunately this method can only display a distribution for one radius, so " + "arrays of radii are not supported.") + + # Grabbing out the mass distribution, as well as the single result that describes the mass distribution. + hy_mass, hy_dist = self.mass(radius, conf_level) + # Setting up the figure + plt.figure(figsize=figsize) + ax = plt.gca() + # Includes nicer ticks + ax.tick_params(axis='both', direction='in', which='both', top=True, right=True) + # And removing the yaxis tick labels as its just a number of values per bin + ax.yaxis.set_ticklabels([]) + + # Plot the histogram and set up labels + plt.hist(hy_dist.value, bins=bins, color=colour, alpha=0.7, density=False) + plt.xlabel(self._y_axis_name + r" M$_{\odot}$") + plt.title("Mass Distribution at {}".format(radius.to_string())) + + lab_hy_mass = hy_mass.to("10^14Msun") + vals_label = str(lab_hy_mass[0].round(2).value) + "^{+" + str(lab_hy_mass[2].round(2).value) + "}" + \ + "_{-" + str(lab_hy_mass[1].round(2).value) + "}" + res_label = r"$\rm{M_{hydro}} = " + vals_label + r"10^{14}M_{\odot}$" + + # And this just plots the 'result' on the distribution as a series of vertical lines + plt.axvline(hy_mass[0].value, color='red', label=res_label) + plt.axvline(hy_mass[0].value-hy_mass[1].value, color='red', linestyle='dashed') + plt.axvline(hy_mass[0].value+hy_mass[2].value, color='red', linestyle='dashed') + plt.legend(loc='best', prop={'size': 12}) + plt.tight_layout() + plt.show() + + def baryon_fraction(self, radius: Quantity, conf_level: float = 68.2) -> Tuple[Quantity, Quantity]: + """ + A method to use the hydrostatic mass information of this profile, and the gas density information of the + input gas density profile, to calculate a baryon fraction within the given radius. + + :param Quantity radius: An astropy quantity containing the radius/radii that you wish to calculate the + baryon fraction within. + :param float conf_level: The confidence level for the mass uncertainties, the default is 68.2% (~1σ). + :return: An astropy quantity containing the baryon fraction, -ve error, and +ve error, and another quantity + containing the baryon fraction distribution. + :rtype: Tuple[Quantity, Quantity] + """ + upper = 50 + (conf_level / 2) + lower = 50 - (conf_level / 2) + + if not radius.isscalar: + raise ValueError("Unfortunately this method can only calculate the baryon fraction within one " + "radius, multiple radii are not supported.") + + # Grab out the hydrostatic mass distribution, and the gas mass distribution + hy_mass, hy_mass_dist = self.mass(radius, conf_level) + gas_mass, gas_mass_dist = self._dens_prof.gas_mass(self._dens_model.name, radius, conf_level=conf_level, + fit_method=self._dens_model.fit_method) + + # If the distributions don't have the same number of entries (though as far I can recall they always should), + # then we just make sure we have two equal length distributions to divide + if len(hy_mass_dist) < len(gas_mass_dist): + bar_frac_dist = gas_mass_dist[:len(hy_mass_dist)] / hy_mass_dist + elif len(hy_mass_dist) > len(gas_mass_dist): + bar_frac_dist = gas_mass_dist / hy_mass_dist[:len(gas_mass_dist)] + else: + bar_frac_dist = gas_mass_dist / hy_mass_dist + + bfrac_med = np.percentile(bar_frac_dist, 50, axis=0) + bfrac_lower = bfrac_med - np.percentile(bar_frac_dist, lower, axis=0) + bfrac_upper = np.percentile(bar_frac_dist, upper, axis=0) - bfrac_med + bar_frac_res = Quantity([bfrac_med.value, bfrac_lower.value, bfrac_upper.value]) + + return bar_frac_res, bar_frac_dist + + def view_baryon_fraction_dist(self, radius: Quantity, conf_level: float = 68.2, figsize=(8, 8), + bins: Union[str, int] = 'auto', colour: str = "lightslategrey"): + """ + A method which will generate a histogram of the baryon fraction distribution that resulted from the mass + calculation at the supplied radius. If the baryon fraction for the passed radius has already been + measured it, and the baryon fraction distribution, will be retrieved from the storage of this product + rather than re-calculated. + + :param Quantity radius: An astropy quantity containing the radius/radii that you wish to calculate the + baryon fraction within. + :param float conf_level: The confidence level for the mass uncertainties, the default is 68.2% (~1σ). + :param int/str bins: The argument to be passed to plt.hist, either a number of bins or a binning + algorithm name. + :param tuple figsize: The desired size of the histogram figure. + :param str colour: The desired colour of the histogram. + """ + if not radius.isscalar: + raise ValueError("Unfortunately this method can only display a distribution for one radius, so " + "arrays of radii are not supported.") + + bar_frac, bar_frac_dist = self.baryon_fraction(radius, conf_level) + plt.figure(figsize=figsize) + ax = plt.gca() + ax.tick_params(axis='both', direction='in', which='both', top=True, right=True) + ax.yaxis.set_ticklabels([]) + + plt.hist(bar_frac_dist.value, bins=bins, color=colour, alpha=0.7) + plt.xlabel("Baryon Fraction") + plt.title("Baryon Fraction Distribution at {}".format(radius.to_string())) + + vals_label = str(bar_frac[0].round(2).value) + "^{+" + str(bar_frac[2].round(2).value) + "}" + \ + "_{-" + str(bar_frac[1].round(2).value) + "}" + res_label = r"$\rm{f_{gas}} = " + vals_label + "$" + + plt.axvline(bar_frac[0].value, color='red', label=res_label) + plt.axvline(bar_frac[0].value-bar_frac[1].value, color='red', linestyle='dashed') + plt.axvline(bar_frac[0].value+bar_frac[2].value, color='red', linestyle='dashed') + plt.legend(loc='best', prop={'size': 12}) + plt.xlim(0) + plt.tight_layout() + plt.show() + + def baryon_fraction_profile(self) -> BaryonFraction: + """ + A method which uses the baryon_fraction method to construct a baryon fraction profile at the radii of + this HydrostaticMass profile. The uncertainties on the baryon fraction are calculated at the 1σ level. + + :return: An XGA BaryonFraction object. + :rtype: BaryonFraction + """ + frac = [] + frac_err = [] + # Step through the radii of this profile + for rad in self.radii: + # Grabs the baryon fraction for the current radius + b_frac = self.baryon_fraction(rad)[0] + + # Only need the actual result, not the distribution + frac.append(b_frac[0]) + # Calculates a mean uncertainty + frac_err.append(b_frac[1:].mean()) + + # Makes them unit-less quantities, as baryon fraction is mass/mass + frac = Quantity(frac, '') + frac_err = Quantity(frac_err, '') + + return BaryonFraction(self.radii, frac, self.centre, self.src_name, self.obs_id, self.instrument, + self.radii_err, frac_err, self.set_ident, self.associated_set_storage_key, + self.deg_radii, auto_save=self.auto_save) + + def overdensity_radius(self, delta: int, redshift: float, cosmo, init_lo_rad: Quantity = Quantity(100, 'kpc'), + init_hi_rad: Quantity = Quantity(3500, 'kpc'), init_step: Quantity = Quantity(100, 'kpc'), + out_unit: Union[Unit, str] = Unit('kpc')) -> Quantity: + """ + This method uses the 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 method takes a numerical approach to the location of the requested radius. Though we have calculated + analytical hydrostatic mass models for common choices of temperature and density profile models, there are + no analytical solutions for R. + + When an overdensity radius is being calculated, we initially measure masses for a range of radii between + init_lo_rad - init_hi_rad in steps of init_step. From this we find the two radii that bracket the radius where + average density - Delta*critical density = 0. Between those two radii we perform the same test with another + range of radii (in steps of 1 kpc this time), finding the radius that corresponds to the minimum + density difference value. + + :param int delta: The overdensity factor for which a radius is to be calculated. + :param float redshift: The redshift of the cluster. + :param cosmo: The cosmology in which to calculate the overdensity. Should be an astropy cosmology instance. + :param Quantity init_lo_rad: The lower radius bound for the first radii array generated to find the wide + brackets around the requested overdensity radius. Default value is 100 kpc. + :param Quantity init_hi_rad: The upper radius bound for the first radii array generated to find the wide + brackets around the requested overdensity radius. Default value is 3500 kpc. + :param Quantity init_step: The step size for the first radii array generated to find the wide brackets + around the requested overdensity radius. Default value is 100 kpc, recommend that you don't set it + smaller than 10 kpc. + :param Unit/str out_unit: The unit that this method should output the radius with. + :return: The calculated overdensity radius. + :rtype: Quantity + """ + def turning_point(brackets: Quantity, step_size: Quantity) -> Quantity: + """ + This is the meat of the overdensity_radius method. It goes looking for radii that bracket the + requested overdensity radius. This works by calculating an array of masses, calculating densities + from them and the radius array, then calculating the difference between Delta*critical density at + source redshift. Where the difference array flips from being positive to negative is where the + bracketing radii are. + + :param Quantity brackets: The brackets within which to generate our array of radii. + :param Quantity step_size: The step size for the array of radii + :return: The bracketing radii for the requested overdensity for this search. + :rtype: Quantity + """ + # Just makes sure that the step size is definitely in the same unit as the bracket + # variable, as I take the value of step_size later + step_size = step_size.to(brackets.unit) + + # This sets up a range of radii within which to calculate masses, which in turn are used to find the + # closest value to the Delta*critical density we're looking for + rads = Quantity(np.arange(*brackets.value, step_size.value), 'kpc') + # The masses contained within the test radii, the transpose is just there because the array output + # by that function is weirdly ordered - there is an issue open that will remind to eventually change that + rad_masses = self.mass(rads)[0].T + # Calculating the density from those masses - uses the radii that the masses were measured within + rad_dens = rad_masses[:, 0] / (4 * np.pi * (rads ** 3) / 3) + # Finds the difference between the density array calculated above and the requested + # overdensity (i.e. Delta * the critical density of the Universe at the source redshift). + rad_dens_diffs = rad_dens - (delta * z_crit_dens) + + if np.all(rad_dens_diffs.value > 0) or np.all(rad_dens_diffs.value < 0): + raise ValueError("The passed lower ({l}) and upper ({u}) radii don't appear to bracket the " + "requested overdensity (Delta={d}) radius.".format(l=brackets[0], u=brackets[1], + d=delta)) + + # This finds the index of the radius where the turnover between the density difference being + # positive and negative happens. The radius of that index, and the index before it, bracket + # the requested overdensity. + turnover = np.where(rad_dens_diffs.value < 0, rad_dens_diffs.value, -np.inf).argmax() + brackets = rads[[turnover - 1, turnover]] + + return brackets + + # First perform some sanity checks to make sure that the user hasn't passed anything silly + # Check that the overdensity is a positive, non-zero (because that wouldn't make sense) integer. + if not type(delta) == int or delta <= 0: + raise ValueError("The overdensity must be a positive, non-zero, integer.") + + # The user is allowed to pass either a unit instance or a string, we make sure the out_unit is consistently + # a unit instance for the benefit of the rest of this method. + if isinstance(out_unit, str): + out_unit = Unit(out_unit) + elif not isinstance(out_unit, Unit): + raise ValueError("The out_unit argument must be either an astropy Unit instance, or a string " + "representing an astropy unit.") + + # We know that if we have arrived here then the out_unit variable is a Unit instance, so we just check + # that it's a distance unit that makes sense. I haven't allowed degrees, arcmins etc. because it would + # entail a little extra work, and I don't care enough right now. + if not out_unit.is_equivalent('kpc'): + raise UnitConversionError("The out_unit argument must be supplied with a unit that is convertible " + "to kpc. Angular units such as deg are not currently supported.") + + # Obviously redshift can't be negative, and I won't allow zero redshift because it doesn't + # make sense for clusters and completely changes how distance calculations are done. + if redshift <= 0: + raise ValueError("Redshift cannot be less than or equal to zero.") + + # This is the critical density of the Universe at the cluster redshift - this is what we compare the + # cluster density too to figure out the requested overdensity radius. + z_crit_dens = cosmo.critical_density(redshift) + + wide_bracket = turning_point(Quantity([init_lo_rad, init_hi_rad]), init_step) + if init_step != Quantity(1, 'kpc'): + # In this case I buffer the wide bracket (subtract 5 kpc from the lower bracket and add 5 kpc to the upper + # bracket) - this is a fix to help avoid errors when the turning point is equal to the upper or lower + # bracket + buffered_wide_bracket = wide_bracket + Quantity([-5, 5], 'kpc') + tight_bracket = turning_point(buffered_wide_bracket, Quantity(1, 'kpc')) + else: + tight_bracket = wide_bracket + + return ((tight_bracket[0]+tight_bracket[1])/2).to(out_unit) + + def _diag_view_prep(self, src) -> Tuple[int, RateMap, SurfaceBrightness1D]: + """ + This internal function just serves to grab the relevant photometric products (if available) and check to + see how many plots will be in the diagnostic view. The maximum is five; mass profile, temperature profile, + density profile, surface brightness profile, and ratemap. + + :param GalaxyCluster src: The source object for which this hydrostatic mass profile was created + :return: The number of plots, a RateMap (if src was pass, otherwise None), and a SB profile (if the + density profile was created with the SB method, otherwise None). + :rtype: Tuple[int, RateMap, SurfaceBrightness1D] + """ + + # This checks to make sure that the source is a galaxy cluster, I do it this way (with strings) to avoid + # annoying circular import errors. The source MUST be a galaxy cluster because you can only calculate + # hydrostatic mass profiles for galaxy clusters. + if src is not None and type(src).__name__ != 'GalaxyCluster': + raise TypeError("The src argument must be a GalaxyCluster object.") + + # This just checks to make sure that the name of the passed source is the same as the stored source name + # of this profile. Maybe in the future this won't be necessary because a reference to the source + # will be stored IN the profile. + if src is not None and src.name != self.src_name: + raise ValueError("The passed source has a different name to the source that was used to generate" + " this HydrostaticMass profile.") + + # If the hydrostatic mass profile was created using combined data then I grab a combined image + if self.obs_id == 'combined' and src is not None: + rt = src.get_combined_ratemaps(src.peak_lo_en, src.peak_hi_en) + # Otherwise we grab the specific relevant image + elif self.obs_id != 'combined' and src is not None: + rt = src.get_ratemaps(self.obs_id, self.instrument, src.peak_lo_en, src.peak_hi_en) + # If there is no source passed, then we don't get a ratemap + else: + rt = None - if not radii.unit.is_equivalent("kpc"): - raise UnitConversionError("Radii unit cannot be converted to kpc") + # Checks to see whether the generation profile of the density profile is a surface brightness + # profile. The other option is that it's an apec normalisation profile if generated from the spectra method + if type(self.density_profile.generation_profile) == SurfaceBrightness1D: + sb = self.density_profile.generation_profile + # Otherwise there is no SB profile else: - radii = radii.to('kpc') - radii_err = radii_err.to('kpc') - # This will be overwritten by the super() init call, but it allows rad_check to work - self._radii = radii + sb = None - # We won't REQUIRE that the profiles have data point generated at the same radii, as we're gonna - # measure entropy from the models, but I do need to check that the passed radii are within the radii of the - # and warn the user if they aren't - self.rad_check(radii) + # Maximum number of plots is five, this just figures out how many there are going to be based on what the + # ratemap and surface brightness profile values are + num_plots = 5 - sum([rt is None, sb is None]) - if isinstance(num_steps, int): - temp_steps = num_steps - dens_steps = num_steps - elif isinstance(num_steps, list) and len(num_steps) == 2: - temp_steps = num_steps[0] - dens_steps = num_steps[1] - else: - raise ValueError("If a list is passed for num_steps then it must have two entries, the first for the " - "temperature profile fit and the second for the density profile fit") + return num_plots, rt, sb - # If models are passed then we're going to make sure that they're fit here - starting with temperature. We'll - # also retrieve the model object. The if statements are separate because we may allow for the fitting of - # one model and not another, using a combination of model and datapoints to calculate entropy - if temperature_model is not None: - temperature_model = temperature_profile.fit(temperature_model, fit_method, num_samples, temp_steps, - num_walkers, progress, show_warn) - key_temp_mod_part = "tm{t}".format(t=temperature_model.name) - # Have to check whether the fits were actually successful, as the fit method will return a model instance - # either way - if not temperature_model.success: - raise XGAFitError("The fit to the temperature was unsuccessful, cannot define hydrostatic mass " - "profile.") - elif interp_data: - key_temp_mod_part = "tmdatainterp" - else: - key_temp_mod_part = "tmdata" + def _gen_diag_view(self, fig: Figure, src, num_plots: int, rt: RateMap, sb: SurfaceBrightness1D): + """ + This populates the diagnostic plot figure, grabbing axes from various classes of profile product. - if density_model is not None: - density_model = density_profile.fit(density_model, fit_method, num_samples, dens_steps, num_walkers, - progress, show_warn) - key_dens_mod_part = "dm{d}".format(d=density_model.name) - # Have to check whether the fits were actually successful, as the fit method will return a model instance - # either way - if not density_model.success: - raise XGAFitError("The fit to the density was unsuccessful, cannot define hydrostatic mass profile.") - elif interp_data: - key_dens_mod_part = "dmdatainterp" + :param Figure fig: The figure instance being populated. + :param GalaxyCluster src: The galaxy cluster source that this hydrostatic mass profile was created for. + :param int num_plots: The number of plots in this diagnostic view. + :param RateMap rt: A RateMap to add to this diagnostic view. + :param SurfaceBrightness1D sb: A surface brightness profile to add to this diagnostic view. + :return: The axes array of this diagnostic view. + :rtype: np.ndarray([Axes]) + """ + from ..imagetools.misc import physical_rad_to_pix + + # The preparation method has already figured out how many plots there will be, so we create those subplots + ax_arr = fig.subplots(nrows=1, ncols=num_plots) + + # If a RateMap has been passed then we need to get the view, calculate some things, and then add it to our + # diagnostic plot + if rt is not None: + # As the RateMap is the first plot, and is not guaranteed to be present, I use the offset parameter + # later in this function to shift the other plots across by 1 if it is present. + offset = 1 + # If the source was setup to use a peak coordinate, then we want to include that in the ratemap display + if src.use_peak: + ch = Quantity([src.peak, src.ra_dec]) + # I also grab the annulus boundaries from the temperature profile used to create this + # HydrostaticMass profile, then convert to pixels. That does depend on there being a source, but + # we know that we wouldn't have a RateMap at this point if the user hadn't passed a source + pix_rads = physical_rad_to_pix(rt, self.temperature_profile.annulus_bounds, src.peak, src.redshift, + src.cosmo) + + else: + # No peak means we just use the original user-passed RA-Dec + ch = src.ra_dec + pix_rads = physical_rad_to_pix(rt, self.temperature_profile.annulus_bounds, src.ra_dec, src.redshift, + src.cosmo) + + # This gets the nicely setup view from the RateMap object and adds it to our array of matplotlib axes + ax_arr[0] = rt.get_view(ax_arr[0], ch, radial_bins_pix=pix_rads.value) else: - key_dens_mod_part = "dmdata" + # In this case there is no RateMap to add, so I don't need to shift the other plots across + offset = 0 - self._temp_model = temperature_model - self._dens_model = density_model + # These simply plot the mass, temperature, and density profiles with legends turned off, residuals turned + # off, and no title + ax_arr[0+offset] = self.get_view(fig, ax_arr[0+offset], show_legend=False, custom_title='', + show_residual_ax=False)[0] + ax_arr[1+offset] = self.temperature_profile.get_view(fig, ax_arr[1+offset], show_legend=False, custom_title='', + show_residual_ax=False)[0] + ax_arr[2+offset] = self.density_profile.get_view(fig, ax_arr[2+offset], show_legend=False, custom_title='', + show_residual_ax=False)[0] + # Then if there is a surface brightness profile thats added too + if sb is not None: + ax_arr[3+offset] = sb.get_view(fig, ax_arr[3+offset], show_legend=False, custom_title='', + show_residual_ax=False)[0] - # We set an attribute with the 'num_samples' parameter - it has been passed into the model fits already but - # we also use that value for the number of data realisations if the user has opted for a data point derived - # entropy profile rather than model derived. - self._num_samples = num_samples + return ax_arr - ent, ent_dist = self.entropy(radii, conf_level=68) - ent_vals = ent[0, :] - ent_errs = np.mean(ent[1:, :], axis=0) + def diagnostic_view(self, src=None, figsize: tuple = None): + """ + This method produces a figure with the most important products that went into the creation of this + HydrostaticMass profile, for the purposes of quickly checking that everything looks sensible. The + maximum number of plots included is five; mass profile, temperature profile, density profile, + surface brightness profile, and ratemap. The RateMap will only be included if the source that this profile + was generated from is passed. - super().__init__(radii, ent_vals, self._temp_prof.centre, self._temp_prof.src_name, self._temp_prof.obs_id, - self._temp_prof.instrument, radii_err, ent_errs, set_id, set_store, deg_radii, - auto_save=auto_save) + :param GalaxyCluster src: The GalaxyCluster source that this HydrostaticMass profile was generated from. + :param tuple figsize: A tuple that sets the size of the diagnostic plot, default is None in which case + it is set automatically. + """ - # Need a custom storage key for this entropy profile, incorporating all the information we have about what - # went into it, density profile, temperature profile, radii, density and temperature models - identical to - # the form used by HydrostaticMass profiles. - dens_part = "dprof_{}".format(self._dens_prof.storage_key) - temp_part = "tprof_{}".format(self._temp_prof.storage_key) - cur_part = self.storage_key + # Run the preparatory method to get the number of plots, RateMap, and SB profile - also performs + # some common sense checks if a source has been passed. + num_plots, rt, sb = self._diag_view_prep(src) - whole_new = "{ntm}_{ndm}_{c}_{t}_{d}".format(ntm=key_temp_mod_part, ndm=key_dens_mod_part, c=cur_part, - t=temp_part, d=dens_part) - self._storage_key = whole_new + # Calculate a sensible figsize if the user didn't pass one + if figsize is None: + figsize = (7.2*num_plots, 7) - # Setting the type - self._prof_type = "hydrostatic_mass" + # Set up the figure + fig = plt.figure(figsize=figsize) + # Set up and populate the axes with plots + ax_arr = self._gen_diag_view(fig, src, num_plots, rt, sb) - # This is what the y-axis is labelled as during plotting - self._y_axis_name = r"M$_{\rm{hydro}}$" + # And show the figure + plt.tight_layout() + plt.show() - # Setting up a dictionary to store mass results in. - self._masses = {} + plt.close('all') + + def save_diagnostic_view(self, save_path: str, src=None, figsize: tuple = None): + """ + This method saves a figure (without displaying) with the most important products that went into the creation + of this HydrostaticMass profile, for the purposes of quickly checking that everything looks sensible. The + maximum number of plots included is five; mass profile, temperature profile, density profile, surface + brightness profile, and ratemap. The RateMap will only be included if the source that this profile + was generated from is passed. + + :param str save_path: The path and filename where the diagnostic figure should be saved. + :param GalaxyCluster src: The GalaxyCluster source that this HydrostaticMass profile was generated from. + :param tuple figsize: A tuple that sets the size of the diagnostic plot, default is None in which case + it is set automatically. + """ + # Run the preparatory method to get the number of plots, RateMap, and SB profile - also performs + # some common sense checks if a source has been passed. + num_plots, rt, sb = self._diag_view_prep(src) + # Calculate a sensible figsize if the user didn't pass one + if figsize is None: + figsize = (7.2*num_plots, 7) + + # Set up the figure + fig = plt.figure(figsize=figsize) + # Set up and populate the axes with plots + ax_arr = self._gen_diag_view(fig, src, num_plots, rt, sb) + + # And show the figure + plt.tight_layout() + plt.savefig(save_path) + + plt.close('all') + + @property + def temperature_profile(self) -> Union[GasTemperature3D, ProjectedGasTemperature1D]: + """ + A method to provide access to the 3D or projected temperature profile used to generate this + hydrostatic mass profile. + + :return: The input temperature profile. + :rtype: GasTemperature3D/ProjectedGasTemperature1D + """ + return self._temp_prof + + @property + def density_profile(self) -> GasDensity3D: + """ + A method to provide access to the 3D density profile used to generate this entropy profile. + + :return: The input density profile. + :rtype: GasDensity3D + """ + return self._dens_prof + + @property + def temperature_model(self) -> BaseModel1D: + """ + A method to provide access to the model that may have been fit to the temperature profile. + + :return: The fit temperature model. + :rtype: BaseModel1D + """ + return self._temp_model + + @property + def density_model(self) -> BaseModel1D: + """ + A method to provide access to the model that may have been fit to the density profile. + + :return: The fit density profile. + :rtype: BaseModel1D + """ + return self._dens_model + def rad_check(self, rad: Quantity): + """ + Very simple method that prints a warning if the radius is outside the range of data covered by the + density or temperature profiles. + :param Quantity rad: The radius to check. + """ + if not rad.unit.is_equivalent(self.radii_unit): + raise UnitConversionError("You can only check radii in units convertible to the radius units of " + "the profile ({}).".format(self.radii_unit.to_string())) + if (self._temp_prof.annulus_bounds is not None and (rad > self._temp_prof.annulus_bounds[-1]).any()) \ + or (self._dens_prof.annulus_bounds is not None and (rad > self._dens_prof.annulus_bounds[-1]).any()): + warn("Some radii are outside the data range covered by the temperature or density profiles, as such " + "you will be extrapolating based on the model fits.", stacklevel=2) @@ -2939,9 +3630,9 @@ def view_entropy_dist(self, radius: Quantity, conf_level: float = 68.2, figsize= plt.show() @property - def temperature_profile(self) -> GasTemperature3D: + def temperature_profile(self) -> Union[GasTemperature3D, ProjectedGasTemperature1D]: """ - A method to provide access to the 3D temperature profile used to generate this entropy profile. + A method to provide access to the 3D or projected temperature profile used to generate this entropy profile. :return: The input temperature profile. :rtype: GasTemperature3D @@ -2961,7 +3652,7 @@ def density_profile(self) -> GasDensity3D: @property def temperature_model(self) -> BaseModel1D: """ - A method to provide access to the model that was fit to the temperature profile. + A method to provide access to the model that may have been fit to the temperature profile. :return: The fit temperature model. :rtype: BaseModel1D @@ -2971,7 +3662,7 @@ def temperature_model(self) -> BaseModel1D: @property def density_model(self) -> BaseModel1D: """ - A method to provide access to the model that was fit to the density profile. + A method to provide access to the model that may have been fit to the density profile. :return: The fit density profile. :rtype: BaseModel1D From 4da4ed4799ce7a74fafa50870f03002f753af287 Mon Sep 17 00:00:00 2001 From: David Turner Date: Tue, 19 Nov 2024 09:57:02 -0500 Subject: [PATCH 03/85] Swept through the hydro mass profile class and mildly improved some docstrings and type hints. --- xga/products/profile.py | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/xga/products/profile.py b/xga/products/profile.py index 54c6957c..5e55597b 100644 --- a/xga/products/profile.py +++ b/xga/products/profile.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 19/11/2024, 09:43. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 19/11/2024, 09:57. Copyright (c) The Contributors from copy import copy from typing import Tuple, Union, List @@ -2629,8 +2629,8 @@ def annular_mass(self, outer_radius: Quantity, inner_radius: Quantity, conf_leve # This PROBABLY NOT AT ALL valid because they're just posterior distributions of mass return outer_mass_dist - inner_mass_dist - def view_mass_dist(self, radius: Quantity, conf_level: float = 68.2, figsize=(8, 8), bins: Union[str, int] = 'auto', - colour: str = "lightslategrey"): + def view_mass_dist(self, radius: Quantity, conf_level: float = 68.2, figsize: Tuple[float, float] = (8, 8), + bins: Union[str, int] = 'auto', colour: str = "lightslategrey"): """ A method which will generate a histogram of the mass distribution that resulted from the mass calculation at the supplied radius. If the mass for the passed radius has already been measured it, and the mass @@ -2642,7 +2642,7 @@ def view_mass_dist(self, radius: Quantity, conf_level: float = 68.2, figsize=(8, :param int/str bins: The argument to be passed to plt.hist, either a number of bins or a binning algorithm name. :param str colour: The desired colour of the histogram. - :param tuple figsize: The desired size of the histogram figure. + :param Tuple[float, float] figsize: The desired size of the histogram figure. """ if not radius.isscalar: raise ValueError("Unfortunately this method can only display a distribution for one radius, so " @@ -2716,8 +2716,9 @@ def baryon_fraction(self, radius: Quantity, conf_level: float = 68.2) -> Tuple[Q return bar_frac_res, bar_frac_dist - def view_baryon_fraction_dist(self, radius: Quantity, conf_level: float = 68.2, figsize=(8, 8), - bins: Union[str, int] = 'auto', colour: str = "lightslategrey"): + def view_baryon_fraction_dist(self, radius: Quantity, conf_level: float = 68.2, + figsize: Tuple[float, float] = (8, 8), bins: Union[str, int] = 'auto', + colour: str = "lightslategrey"): """ A method which will generate a histogram of the baryon fraction distribution that resulted from the mass calculation at the supplied radius. If the baryon fraction for the passed radius has already been @@ -2729,7 +2730,7 @@ def view_baryon_fraction_dist(self, radius: Quantity, conf_level: float = 68.2, :param float conf_level: The confidence level for the mass uncertainties, the default is 68.2% (~1σ). :param int/str bins: The argument to be passed to plt.hist, either a number of bins or a binning algorithm name. - :param tuple figsize: The desired size of the histogram figure. + :param Tuple[float, float] figsize: The desired size of the histogram figure. :param str colour: The desired colour of the histogram. """ if not radius.isscalar: @@ -3010,7 +3011,7 @@ def _gen_diag_view(self, fig: Figure, src, num_plots: int, rt: RateMap, sb: Surf return ax_arr - def diagnostic_view(self, src=None, figsize: tuple = None): + def diagnostic_view(self, src=None, figsize: Tuple[float, float] = None): """ This method produces a figure with the most important products that went into the creation of this HydrostaticMass profile, for the purposes of quickly checking that everything looks sensible. The @@ -3019,8 +3020,8 @@ def diagnostic_view(self, src=None, figsize: tuple = None): was generated from is passed. :param GalaxyCluster src: The GalaxyCluster source that this HydrostaticMass profile was generated from. - :param tuple figsize: A tuple that sets the size of the diagnostic plot, default is None in which case - it is set automatically. + :param Tuple[float, float] figsize: A tuple that sets the size of the diagnostic plot, default is None in + which case it is set automatically. """ # Run the preparatory method to get the number of plots, RateMap, and SB profile - also performs @@ -3042,7 +3043,7 @@ def diagnostic_view(self, src=None, figsize: tuple = None): plt.close('all') - def save_diagnostic_view(self, save_path: str, src=None, figsize: tuple = None): + def save_diagnostic_view(self, save_path: str, src=None, figsize: Tuple[float, float] = None): """ This method saves a figure (without displaying) with the most important products that went into the creation of this HydrostaticMass profile, for the purposes of quickly checking that everything looks sensible. The @@ -3052,8 +3053,8 @@ def save_diagnostic_view(self, save_path: str, src=None, figsize: tuple = None): :param str save_path: The path and filename where the diagnostic figure should be saved. :param GalaxyCluster src: The GalaxyCluster source that this HydrostaticMass profile was generated from. - :param tuple figsize: A tuple that sets the size of the diagnostic plot, default is None in which case - it is set automatically. + :param Tuple[float, float] figsize: A tuple that sets the size of the diagnostic plot, default is None + in which case it is set automatically. """ # Run the preparatory method to get the number of plots, RateMap, and SB profile - also performs # some common sense checks if a source has been passed. From 2dc01c81a80c12cdec1ed4a48b7424be61f66b2e Mon Sep 17 00:00:00 2001 From: David Turner Date: Tue, 19 Nov 2024 11:28:12 -0500 Subject: [PATCH 04/85] Starting to get the new hydrostatic mass profile to full functionality - the first part of the 'mass' method is essentially the same as the entropy method of SpecificEntropy, handling all the different ways we can get density and temperature information. The next part thing need to is implement derivative calculation, then add the actual hydrostatic mass calculation (making sure to support radius uncertainties like the old one did). For issue #1260 --- xga/products/profile.py | 90 ++++++++++++++++++++++++++++------------- 1 file changed, 63 insertions(+), 27 deletions(-) diff --git a/xga/products/profile.py b/xga/products/profile.py index 5e55597b..ceaf8c1c 100644 --- a/xga/products/profile.py +++ b/xga/products/profile.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 19/11/2024, 09:57. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 19/11/2024, 11:28. Copyright (c) The Contributors from copy import copy from typing import Tuple, Union, List @@ -2436,7 +2436,8 @@ def __init__(self, temperature_profile: Union[GasTemperature3D, ProjectedGasTemp # Setting up a dictionary to store mass results in. self._masses = {} - def mass(self, radius: Quantity, conf_level: float = 68.2, radius_err: Quantity = None) -> Union[Quantity, Quantity]: + def mass(self, radius: Quantity, conf_level: float = 68.2, + radius_err: Quantity = None) -> Union[Quantity, Quantity]: """ A method which will measure a hydrostatic mass and hydrostatic mass uncertainty within the given radius/radii. No corrections are applied to the values calculated by this method, it is just the vanilla @@ -2455,7 +2456,10 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, radius_err: Quantity the mass realization distribution. :rtype: Union[Quantity, Quantity] """ - raise NotImplementedError + # TODO THE MAIN THING HERE THAT WILL BE DIFFERENT FROM THE REVAMPED ENTROPY CALCULATIONS IS THAT WE NEED TO + # CALCULATE DERIVATIVES - SHOULDN'T BE TOO MUCH OF A CHALLENGE AND WE SHOULD BE ABLE TO PRESERVE ALL THE + # OPTIONS THAT WE INCLUDED FOR ENTROPY (INTERPOLATION, DATA DRIVEN, ETC.) + # Setting the upper and lower confidence limits upper = 50 + (conf_level / 2) lower = 50 - (conf_level / 2) @@ -2463,36 +2467,65 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, radius_err: Quantity # Prints a warning if the radius at which to calculate the entropy is outside the range of the data self.rad_check(radius) - # If a particular radius already has a result in the profiles storage structure then we'll just grab that - # rather than redoing a calculation unnecessarily. - if radius.isscalar and radius in self._entropies: + # We need check that, if the user has passed uncertainty information on radii, it is how we expect it to be. + # First off, are there the right number of entries? + if not radius.isscalar and radius_err is not None and (radius_err.isscalar or len(radius) != len(radius_err)): + raise ValueError("If a set of radii are passed, and radius uncertainty information is provided, the " + "'radius_err' argument must contain the same number of entries as the 'radius' argument.") + # Same deal here, if only one radius is passed, only one error may be passed + elif radius.isscalar and radius_err is not None and not radius_err.isscalar: + raise ValueError("When a radius uncertainty ('radius_err') is passed for a single radius value, " + "'radius_err' must be scalar.") + # Now we check that the units of the radius and radius error quantities are compatible + elif radius_err is not None and not radius_err.unit.is_equivalent(radius.unit): + raise UnitConversionError("The radius_err quantity must be in units that are equivalent to units " + "of {}.".format(radius.unit.to_string())) + + # Now we make absolutely sure that the radius error(s) are in the correct units + if radius_err is not None: + radius_err = radius_err.to(self.radii_unit) + + # Here we construct the storage key for the radius passed, and the uncertainty if there is one + if radius.isscalar and radius_err is None: + stor_key = str(radius.value) + " " + str(radius.unit) + elif radius.isscalar and radius_err is not None: + stor_key = str(radius.value) + '_' + str(radius_err.value) + " " + str(radius.unit) + # In this case, as the radius is not scalar, the masses won't be stored so we don't need a storage key + else: + stor_key = None + + # If a particular radius+radius error (if passed) already has a result in the profiles storage structure + # then we'll just grab that rather than redoing a calculation unnecessarily. + + # Check to see whether the calculation has to be run again + if radius.isscalar and stor_key in self._masses: already_run = True - ent_dist = self._entropies[radius] + mass_dist = self._masses[stor_key] else: already_run = False - # Here, if we haven't already identified a previously calculated entropy for the radius, we start to + # Here, if we haven't already identified a previously calculated hydrostatic mass for the radius, we start to # prepare the data we need (i.e. temperature and density). This is complicated slightly by the different - # ways of calculating entropy we support (using smooth models, using data points, using interpolated data - # points). First of all we deal with the case of there being a density model to draw from + # ways of calculating the profile that we now support (using smooth models, using data points, using + # interpolated data points). First of all we deal with the case of there being a density model to draw from if not already_run and self.density_model is not None: # If the density model fit didn't work then we give up and throw an error if not self.density_model.success: - raise XGAFitError("The density model fit was not successful, as such we cannot calculate entropy " - "using a smooth density model.") - # Getting a bunch of realisations (with the number set by the 'num_samples' argument that was passed on + raise XGAFitError("The density model fit was not successful, as such we cannot calculate " + "hydrostatic mass using a smooth density model.") + # Getting a bunch of realizations (with the number set by the 'num_samples' argument that was passed on # the definition of this source of the model. dens = self._dens_model.get_realisations(radius) - # In this rare case (inspired by how ACCEPT packaged their profiles, see issue #1176) the radii for the - # temperature and density profiles are identical, and so we just get some realisations + # In this rare case the radii for the temperature and density profiles are identical, and so we just get + # some realizations elif (not already_run and (len(self.density_profile) == len(self.temperature_profile)) and (self.density_profile.radii == self.temperature_profile.radii).all()): dens = self.density_profile.generate_data_realisations(self._num_samples).T elif not already_run and self._interp_data: - # This uses the density profile y-axis values (and their uncertainties) to draw N realisations of the - # data points - we'll use this to create N realisations of the interpolations as well + # This uses the density profile y-axis values (and their uncertainties) to draw N realizations of the + # data points - we'll use this to create N realizations of the interpolations as well dens_data_real = self.density_profile.generate_data_realisations(self._num_samples) # TODO This unfortunately may be removed from scipy soon, but the np.interp linear interpolation method # doesn't currently support interpolating along a particular axis. Also considering more sophisticated @@ -2501,12 +2534,12 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, radius_err: Quantity # We make sure to turn on extrapolation, and make sure this is no out-of-bounds error issued dens_interp = interp1d(self.density_profile.radii, dens_data_real, axis=1, assume_sorted=True, fill_value='extrapolate', bounds_error=False) - # Restore the interpolated density profile realisations to an astropy quantity array + # Restore the interpolated density profile realizations to an astropy quantity array dens = Quantity(dens_interp(self.radii).T, self.density_profile.values_unit) # This particular combination means that we are doing a data-point based profile, but without interpolation, # and that the density profile has more bins than the temperature (going to be true in most cases). So we - # just read out the density data points (and make N realisations of them) with no funny business required + # just read out the density data points (and make N realizations of them) with no funny business required elif not already_run and not self._interp_data and len(self.density_profile) == len(self.radii): dens = self.density_profile.generate_data_realisations(self._num_samples).T else: @@ -2523,25 +2556,24 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, radius_err: Quantity dens = dens / (MEAN_MOL_WEIGHT * m_p) # We now essentially repeat the process we just did with the density profiles, constructing the temperature - # values that we are going to use in our entropy measurements; from models, data points, or interpolating - # from data points + # values that we are going to use in our hydrostatic mass measurements; from models, data points, or + # interpolating from data points if not already_run and self.temperature_model is not None: if not self.temperature_model.success: raise XGAFitError("The temperature model fit was not successful, as such we cannot calculate entropy " "using a smooth temperature model.") - # Getting a bunch of realisations (with the number set by the 'num_samples' argument that was passed on + # Getting a bunch of realizations (with the number set by the 'num_samples' argument that was passed on # the definition of this source of the model. temp = self._temp_model.get_realisations(radius) - # In this rare case (inspired by how ACCEPT packaged their profiles, see issue #1176) the radii for the - # temperature and density profiles are identical, and so we just get some realisations + # In this rare case temperature and density profiles are identical, and so we just get some realizations elif (not already_run and (len(self.density_profile) == len(self.temperature_profile)) and (self.density_profile.radii == self.temperature_profile.radii).all()): temp = self.temperature_profile.generate_data_realisations(self._num_samples).T elif not already_run and self._interp_data: - # This uses the temperature profile y-axis values (and their uncertainties) to draw N realisations of the - # data points - we'll use this to create N realisations of the interpolations as well + # This uses the temperature profile y-axis values (and their uncertainties) to draw N realizations of the + # data points - we'll use this to create N realizations of the interpolations as well temp_data_real = self.temperature_profile.generate_data_realisations(self._num_samples) temp_interp = interp1d(self.temperature_profile.radii, temp_data_real, axis=1, assume_sorted=True, fill_value='extrapolate', bounds_error=False) @@ -2554,7 +2586,7 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, radius_err: Quantity # And here, the final option, we're doing a data-point based profile without interpolation, and we need # to make sure that the density values (here N_denspoints > N_temppoints) each have a corresponding # temperature value - in practise this means that each density will be paired with the temperature - # realisations whose radial coverage they fall within. + # realizations whose radial coverage they fall within. else: t_bnds = np.vstack([self.temperature_profile.annulus_bounds[0:-1], self.temperature_profile.annulus_bounds[1:]]).T @@ -2568,6 +2600,10 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, radius_err: Quantity if not already_run and not temp.unit.is_equivalent('keV'): temp = (temp * k_B).to('keV') + + raise NotImplementedError("The method is not complete beyond this point") + + # And now we do the actual entropy calculation if not already_run: ent_dist = (temp / dens**(2/3)).T From fe06a33e9af0d5ba9e744be8a5444816299ccff8 Mon Sep 17 00:00:00 2001 From: David Turner Date: Tue, 19 Nov 2024 12:39:22 -0500 Subject: [PATCH 05/85] Hopefully set up the radius distribution creation in the mass method of the revamped hydrostatic mass profile. For issue #1260 --- xga/products/profile.py | 81 +++++++++++++++++++++++++++++++++-------- 1 file changed, 66 insertions(+), 15 deletions(-) diff --git a/xga/products/profile.py b/xga/products/profile.py index ceaf8c1c..fd519514 100644 --- a/xga/products/profile.py +++ b/xga/products/profile.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 19/11/2024, 11:28. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 19/11/2024, 12:39. Copyright (c) The Contributors from copy import copy from typing import Tuple, Union, List @@ -2496,14 +2496,43 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, # If a particular radius+radius error (if passed) already has a result in the profiles storage structure # then we'll just grab that rather than redoing a calculation unnecessarily. - - # Check to see whether the calculation has to be run again if radius.isscalar and stor_key in self._masses: already_run = True mass_dist = self._masses[stor_key] else: already_run = False + # Here we prepare the radius uncertainties for use (if they've been passed) - the goal here is to end up + # with a set of radius samples (either just the one, or M if there are M radii passed) that can be used for + # the extraction of the temperature, density, temperature gradient, and density gradient values that we need + # We make sure to have the number of samples that was set for this profile + if not already_run: + # Declaring this allows us to randomly draw from Gaussian dists, if the user has given us radius error + # information + rng = np.random.default_rng() + # In this case a single radius value, and a radius uncertainty has been passed + if radius.isscalar and radius_err is not None: + # We just want a single distribution of radius here (as one radius value was passed), but make + # sure that it is in a (1, N) shaped array as some downstream tasks in model classes, such as + # get_realisations and derivative, want radius DISTRIBUTIONS to be 2dim arrays, and multiple radius + # VALUES (e.g. [1, 2, 3, 4]) to be 1dim arrays + calc_rad = Quantity(rng.normal(radius.value, radius_err.value, (1, self._num_samples)), + radius_err.unit) + # In this case multiple radius values have been passed, each with an uncertainty + elif not radius.isscalar and radius_err is not None: + # So here we're setting up M radius distributions, where M is the number of input radii. So this radius + # array ends up being shape (M, N), where M is the number of radii, and M is the number of samples in + # the model posterior distributions + calc_rad = Quantity(rng.normal(radius.value, radius_err.value, (self._num_samples, len(radius))), + radius_err.unit).T + # This is the simplest case, just a radius (or a set of radii) with no uncertainty information + # has been passed + else: + calc_rad = radius + + print(calc_rad) + stop + # Here, if we haven't already identified a previously calculated hydrostatic mass for the radius, we start to # prepare the data we need (i.e. temperature and density). This is complicated slightly by the different # ways of calculating the profile that we now support (using smooth models, using data points, using @@ -2603,27 +2632,49 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, raise NotImplementedError("The method is not complete beyond this point") + # TODO DON'T KNOW IF THIS IS REALLY THE PLACE FOR THIS OR HOW I'M GOING TO HANDLE THIS AT ALL + # If the models don't have analytical solutions to their derivative then the derivative method will need + # a dx to assume, so I will set one equal to radius/1e+6 (or the max radius if non-scalar), should be + # small enough. + if radius.isscalar: + dx = radius/1e+6 + else: + dx = radius.max()/1e+6 - # And now we do the actual entropy calculation + # And now we do the actual mass calculation if not already_run: - ent_dist = (temp / dens**(2/3)).T + + # Please note that this is just the vanilla hydrostatic mass equation, but not written in the "standard + # form". Here there are no logs in the derivatives, because it's easier to take advantage of astropy's + # quantities that way. + mass_dist = (((-1 * k_B * np.power(radius[..., None], 2)) / (dens * (MEAN_MOL_WEIGHT * m_p) * G)) + * ((dens * temp_der) + (temp * dens_der))) + + # Just converts the mass/masses to the unit we normally use for them + # TODO DID HAVE A .T HERE TO TRANSPOSE THE RESULT - NEED TO CHECK IF THAT WILL BE NECESSARY FOR THIS + # NEW SET UP + mass_dist = mass_dist.to('Msun') + # Storing the result if it is for a single radius if radius.isscalar: - self._entropies[radius] = ent_dist + self._masses[stor_key] = mass_dist - # Whether we just calculated the entropy, or we fetched it from storage at the beginning of this method - # call, we use the distribution to calculate median and confidence limit values - ent_med = np.nanpercentile(ent_dist, 50, axis=0) - ent_lower = ent_med - np.nanpercentile(ent_dist, lower, axis=0) - ent_upper = np.nanpercentile(ent_dist, upper, axis=0) - ent_med + # Whether we just calculated the hydrostatic mass, or we fetched it from storage at the beginning of this + # method call, we use the distribution to calculate median and confidence limit values + mass_med = np.nanpercentile(mass_dist, 50, axis=0) + mass_lower = mass_med - np.nanpercentile(mass_dist, lower, axis=0) + mass_upper = np.nanpercentile(mass_dist, upper, axis=0) - mass_med # Set up the result to return as an astropy quantity. - ent_res = Quantity(np.array([ent_med.value, ent_lower.value, ent_upper.value]), ent_dist.unit) + mass_res = Quantity(np.array([mass_med.value, mass_lower.value, mass_upper.value]), mass_dist.unit) - if np.any(ent_res[0] < 0): - raise ValueError("A specific entropy of less than zero has been measured, which is not physical.") + # We check to see if any of the upper limits (i.e. measured value plus +ve error) are below zero, and if so + # then we throw an exception up + if np.any((mass_res[0] + mass_res[1]) < 0): + raise ValueError("A mass upper limit (i.e. measured value plus +ve error) of less than zero has been " + "measured, which is not physical.") - return ent_res, ent_dist + return mass_res, mass_dist def annular_mass(self, outer_radius: Quantity, inner_radius: Quantity, conf_level: float = 68.2): """ From 9b60704c5f429a0956373a94b50ba359a7bc26c0 Mon Sep 17 00:00:00 2001 From: David Turner Date: Tue, 19 Nov 2024 12:44:51 -0500 Subject: [PATCH 06/85] Missed some bits to convert to mass (rather than the original entropy setup) in the new hydro mass profile init. Also improved over the original, as apparently for the mass profile values the radius uncertainties were not being passed. For issue #1260 --- xga/products/profile.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/xga/products/profile.py b/xga/products/profile.py index fd519514..244d48ed 100644 --- a/xga/products/profile.py +++ b/xga/products/profile.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 19/11/2024, 12:39. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 19/11/2024, 12:44. Copyright (c) The Contributors from copy import copy from typing import Tuple, Union, List @@ -2408,12 +2408,12 @@ def __init__(self, temperature_profile: Union[GasTemperature3D, ProjectedGasTemp # hydrostatic mass profile rather than model derived. self._num_samples = num_samples - ent, ent_dist = self.entropy(radii, conf_level=68) - ent_vals = ent[0, :] - ent_errs = np.mean(ent[1:, :], axis=0) + mass, mass_dist = self.mass(radii, conf_level=68, radius_err=radii_err) + mass_vals = mass[0, :] + mass_errs = np.mean(mass[1:, :], axis=0) - super().__init__(radii, ent_vals, self._temp_prof.centre, self._temp_prof.src_name, self._temp_prof.obs_id, - self._temp_prof.instrument, radii_err, ent_errs, set_id, set_store, deg_radii, + super().__init__(radii, mass_vals, self._temp_prof.centre, self._temp_prof.src_name, self._temp_prof.obs_id, + self._temp_prof.instrument, radii_err, mass_errs, set_id, set_store, deg_radii, auto_save=auto_save) # Need a custom storage key for this entropy profile, incorporating all the information we have about what From 7d400e3487bf9b12da0f040ab6e5dcb3ef27aafa Mon Sep 17 00:00:00 2001 From: David Turner Date: Tue, 19 Nov 2024 13:18:41 -0500 Subject: [PATCH 07/85] Working through the mass method the new hydro mass class, slowly including radii errors - have added it to the density model realisation approach (as it was in the original hydrostatic mass profile calculation). For issue #1260 --- xga/products/profile.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/xga/products/profile.py b/xga/products/profile.py index 244d48ed..bc495ae5 100644 --- a/xga/products/profile.py +++ b/xga/products/profile.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 19/11/2024, 12:44. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 19/11/2024, 13:18. Copyright (c) The Contributors from copy import copy from typing import Tuple, Union, List @@ -2530,8 +2530,7 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, else: calc_rad = radius - print(calc_rad) - stop + print(calc_rad.shape) # Here, if we haven't already identified a previously calculated hydrostatic mass for the radius, we start to # prepare the data we need (i.e. temperature and density). This is complicated slightly by the different @@ -2543,8 +2542,10 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, raise XGAFitError("The density model fit was not successful, as such we cannot calculate " "hydrostatic mass using a smooth density model.") # Getting a bunch of realizations (with the number set by the 'num_samples' argument that was passed on - # the definition of this source of the model. - dens = self._dens_model.get_realisations(radius) + # the definition of this source of the model) - the radii errors are included if supplied. + dens = self._dens_model.get_realisations(calc_rad) + print(dens.shape) + print(dens) # In this rare case the radii for the temperature and density profiles are identical, and so we just get # some realizations From 91c144bd7197bab0f0d73d35c24d1aed4062eed5 Mon Sep 17 00:00:00 2001 From: David Turner Date: Tue, 19 Nov 2024 13:32:05 -0500 Subject: [PATCH 08/85] The density interpolating option in the new hydro mass class 'mass()' method should now use the radius distribution rather than a single value. For issue #1260 --- xga/products/profile.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/xga/products/profile.py b/xga/products/profile.py index bc495ae5..1d09c03c 100644 --- a/xga/products/profile.py +++ b/xga/products/profile.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 19/11/2024, 13:18. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 19/11/2024, 13:32. Copyright (c) The Contributors from copy import copy from typing import Tuple, Union, List @@ -2564,8 +2564,12 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, # We make sure to turn on extrapolation, and make sure this is no out-of-bounds error issued dens_interp = interp1d(self.density_profile.radii, dens_data_real, axis=1, assume_sorted=True, fill_value='extrapolate', bounds_error=False) - # Restore the interpolated density profile realizations to an astropy quantity array - dens = Quantity(dens_interp(self.radii).T, self.density_profile.values_unit) + print(dens_interp) + # Restore the interpolated density profile realizations to an astropy quantity array - this should also + # take into account radius errors (if they have been passed), as we're using the 'calc_rad' variable. + dens = Quantity(dens_interp(calc_rad).T, self.density_profile.values_unit) + print(dens.shape) + print(dens) # This particular combination means that we are doing a data-point based profile, but without interpolation, # and that the density profile has more bins than the temperature (going to be true in most cases). So we From 5e9b800c2597b79210d822a64b1aa60312283ce2 Mon Sep 17 00:00:00 2001 From: David Turner Date: Tue, 19 Nov 2024 13:43:57 -0500 Subject: [PATCH 09/85] Interpolating the density with radius errors is going awry - attempting to fix it --- xga/products/profile.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/xga/products/profile.py b/xga/products/profile.py index 1d09c03c..6dfe9b32 100644 --- a/xga/products/profile.py +++ b/xga/products/profile.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 19/11/2024, 13:32. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 19/11/2024, 13:43. Copyright (c) The Contributors from copy import copy from typing import Tuple, Union, List @@ -2567,9 +2567,14 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, print(dens_interp) # Restore the interpolated density profile realizations to an astropy quantity array - this should also # take into account radius errors (if they have been passed), as we're using the 'calc_rad' variable. - dens = Quantity(dens_interp(calc_rad).T, self.density_profile.values_unit) + dens = Quantity(dens_interp(self.radii).T, self.density_profile.values_unit) + print(dens.shape) + print(dens) + + dens = Quantity(dens_interp(self.radii).T, self.density_profile.values_unit) print(dens.shape) print(dens) + stop # This particular combination means that we are doing a data-point based profile, but without interpolation, # and that the density profile has more bins than the temperature (going to be true in most cases). So we From 9cddd3e650669c15346dc91471f1c817912699bf Mon Sep 17 00:00:00 2001 From: David Turner Date: Tue, 19 Nov 2024 14:02:34 -0500 Subject: [PATCH 10/85] Added density derivative calculation back into the new hydro mass profile calculations - only for the smooth model mode currently. For issue #1260 --- xga/products/profile.py | 39 ++++++++++++--------------------------- 1 file changed, 12 insertions(+), 27 deletions(-) diff --git a/xga/products/profile.py b/xga/products/profile.py index 6dfe9b32..7de6b681 100644 --- a/xga/products/profile.py +++ b/xga/products/profile.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 19/11/2024, 13:43. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 19/11/2024, 14:02. Copyright (c) The Contributors from copy import copy from typing import Tuple, Union, List @@ -2502,6 +2502,11 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, else: already_run = False + # If we have to do any numerical differentiation, which we will if we're not using smooth models that have + # analytical solutions to their first order derivative, then we need a 'dx' value. We'll choose a very + # small one, dividing the outermost radius of this profile be 1e+6 + dx = self.radii.max()/1e+6 + # Here we prepare the radius uncertainties for use (if they've been passed) - the goal here is to end up # with a set of radius samples (either just the one, or M if there are M radii passed) that can be used for # the extraction of the temperature, density, temperature gradient, and density gradient values that we need @@ -2530,8 +2535,6 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, else: calc_rad = radius - print(calc_rad.shape) - # Here, if we haven't already identified a previously calculated hydrostatic mass for the radius, we start to # prepare the data we need (i.e. temperature and density). This is complicated slightly by the different # ways of calculating the profile that we now support (using smooth models, using data points, using @@ -2544,8 +2547,9 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, # Getting a bunch of realizations (with the number set by the 'num_samples' argument that was passed on # the definition of this source of the model) - the radii errors are included if supplied. dens = self._dens_model.get_realisations(calc_rad) - print(dens.shape) - print(dens) + dens_der = self._dens_model.derivative(calc_rad, dx, True) + print(dens_der.shape) + print(dens_der) # In this rare case the radii for the temperature and density profiles are identical, and so we just get # some realizations @@ -2564,17 +2568,9 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, # We make sure to turn on extrapolation, and make sure this is no out-of-bounds error issued dens_interp = interp1d(self.density_profile.radii, dens_data_real, axis=1, assume_sorted=True, fill_value='extrapolate', bounds_error=False) - print(dens_interp) - # Restore the interpolated density profile realizations to an astropy quantity array - this should also - # take into account radius errors (if they have been passed), as we're using the 'calc_rad' variable. - dens = Quantity(dens_interp(self.radii).T, self.density_profile.values_unit) - print(dens.shape) - print(dens) - + # TODO I don't know if I can include the radius distribution here, but if I can then I should + # Restore the interpolated density profile realizations to an astropy quantity array dens = Quantity(dens_interp(self.radii).T, self.density_profile.values_unit) - print(dens.shape) - print(dens) - stop # This particular combination means that we are doing a data-point based profile, but without interpolation, # and that the density profile has more bins than the temperature (going to be true in most cases). So we @@ -2603,7 +2599,7 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, "using a smooth temperature model.") # Getting a bunch of realizations (with the number set by the 'num_samples' argument that was passed on # the definition of this source of the model. - temp = self._temp_model.get_realisations(radius) + temp = self._temp_model.get_realisations(calc_rad) # In this rare case temperature and density profiles are identical, and so we just get some realizations elif (not already_run and (len(self.density_profile) == len(self.temperature_profile)) and @@ -2640,17 +2636,6 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, temp = (temp * k_B).to('keV') - raise NotImplementedError("The method is not complete beyond this point") - - # TODO DON'T KNOW IF THIS IS REALLY THE PLACE FOR THIS OR HOW I'M GOING TO HANDLE THIS AT ALL - # If the models don't have analytical solutions to their derivative then the derivative method will need - # a dx to assume, so I will set one equal to radius/1e+6 (or the max radius if non-scalar), should be - # small enough. - if radius.isscalar: - dx = radius/1e+6 - else: - dx = radius.max()/1e+6 - # And now we do the actual mass calculation if not already_run: From d68c7a5b9c322b0c1adf5a7c423752f6343ecde6 Mon Sep 17 00:00:00 2001 From: David Turner Date: Tue, 19 Nov 2024 20:49:42 -0500 Subject: [PATCH 11/85] Trying to use the numerical gradient measurement from numpy to measure the density gradient from non-interpolated data points in the new hydro mass setup. For issue #1260 --- xga/products/profile.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/xga/products/profile.py b/xga/products/profile.py index 7de6b681..5f84eca4 100644 --- a/xga/products/profile.py +++ b/xga/products/profile.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 19/11/2024, 14:02. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 19/11/2024, 20:49. Copyright (c) The Contributors from copy import copy from typing import Tuple, Union, List @@ -2556,6 +2556,8 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, elif (not already_run and (len(self.density_profile) == len(self.temperature_profile)) and (self.density_profile.radii == self.temperature_profile.radii).all()): dens = self.density_profile.generate_data_realisations(self._num_samples).T + dens_der = np.gradient(dens, self.radii, axis=1) + print(dens_der) elif not already_run and self._interp_data: # This uses the density profile y-axis values (and their uncertainties) to draw N realizations of the @@ -2586,9 +2588,11 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, dens_data_real = self.density_profile.generate_data_realisations(self._num_samples) dens = dens_data_real[:, d_inds].T - # Finally, whatever way we got the densities, we make sure they are in the right unit + # Finally, whatever way we got the densities, we make sure they are in the right unit (also their 1st + # derivatives). if not already_run and not dens.unit.is_equivalent('1/cm^3'): dens = dens / (MEAN_MOL_WEIGHT * m_p) + dens_der = dens_der / (MEAN_MOL_WEIGHT * m_p) # We now essentially repeat the process we just did with the density profiles, constructing the temperature # values that we are going to use in our hydrostatic mass measurements; from models, data points, or From a5314143c0fd420349f66c9c6648616cc1327891 Mon Sep 17 00:00:00 2001 From: David Turner Date: Tue, 19 Nov 2024 20:53:28 -0500 Subject: [PATCH 12/85] Now trying to use the interpolated density profile data to numerically determine the density gradient. For issue #1260 --- xga/products/profile.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/xga/products/profile.py b/xga/products/profile.py index 5f84eca4..d3a932a6 100644 --- a/xga/products/profile.py +++ b/xga/products/profile.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 19/11/2024, 20:49. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 19/11/2024, 20:53. Copyright (c) The Contributors from copy import copy from typing import Tuple, Union, List @@ -2573,6 +2573,8 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, # TODO I don't know if I can include the radius distribution here, but if I can then I should # Restore the interpolated density profile realizations to an astropy quantity array dens = Quantity(dens_interp(self.radii).T, self.density_profile.values_unit) + dens_der = np.gradient(dens, self.radii, axis=1) + print(dens_der) # This particular combination means that we are doing a data-point based profile, but without interpolation, # and that the density profile has more bins than the temperature (going to be true in most cases). So we From 21508c215785d90e5464e66bc951b78b7ccd00d5 Mon Sep 17 00:00:00 2001 From: David Turner Date: Wed, 20 Nov 2024 09:01:42 -0500 Subject: [PATCH 13/85] Still trying to make numerical differentiation of data points work for the new mass profile calculation --- xga/products/profile.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/xga/products/profile.py b/xga/products/profile.py index d3a932a6..b07f5fad 100644 --- a/xga/products/profile.py +++ b/xga/products/profile.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 19/11/2024, 20:53. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 20/11/2024, 09:01. Copyright (c) The Contributors from copy import copy from typing import Tuple, Union, List @@ -2556,7 +2556,7 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, elif (not already_run and (len(self.density_profile) == len(self.temperature_profile)) and (self.density_profile.radii == self.temperature_profile.radii).all()): dens = self.density_profile.generate_data_realisations(self._num_samples).T - dens_der = np.gradient(dens, self.radii, axis=1) + dens_der = np.gradient(dens, calc_rad, axis=0) print(dens_der) elif not already_run and self._interp_data: @@ -2573,7 +2573,7 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, # TODO I don't know if I can include the radius distribution here, but if I can then I should # Restore the interpolated density profile realizations to an astropy quantity array dens = Quantity(dens_interp(self.radii).T, self.density_profile.values_unit) - dens_der = np.gradient(dens, self.radii, axis=1) + dens_der = np.gradient(dens, calc_rad, axis=0) print(dens_der) # This particular combination means that we are doing a data-point based profile, but without interpolation, From eafcd5f0810f1d7aaae2c38f40c796780cb0ad9c Mon Sep 17 00:00:00 2001 From: David Turner Date: Wed, 20 Nov 2024 09:28:57 -0500 Subject: [PATCH 14/85] Bodging in the temperature derivatives now (apart from the no-interpolation but mismatched bins scenario) for the new hydrostatic mass calculation. For issue #1260 --- xga/products/profile.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/xga/products/profile.py b/xga/products/profile.py index b07f5fad..2c8fe5d5 100644 --- a/xga/products/profile.py +++ b/xga/products/profile.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 20/11/2024, 09:01. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 20/11/2024, 09:28. Copyright (c) The Contributors from copy import copy from typing import Tuple, Union, List @@ -2556,7 +2556,7 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, elif (not already_run and (len(self.density_profile) == len(self.temperature_profile)) and (self.density_profile.radii == self.temperature_profile.radii).all()): dens = self.density_profile.generate_data_realisations(self._num_samples).T - dens_der = np.gradient(dens, calc_rad, axis=0) + dens_der = np.gradient(dens, self.radii, axis=0) print(dens_der) elif not already_run and self._interp_data: @@ -2573,7 +2573,7 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, # TODO I don't know if I can include the radius distribution here, but if I can then I should # Restore the interpolated density profile realizations to an astropy quantity array dens = Quantity(dens_interp(self.radii).T, self.density_profile.values_unit) - dens_der = np.gradient(dens, calc_rad, axis=0) + dens_der = np.gradient(dens, self.radii, axis=0) print(dens_der) # This particular combination means that we are doing a data-point based profile, but without interpolation, @@ -2581,7 +2581,10 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, # just read out the density data points (and make N realizations of them) with no funny business required elif not already_run and not self._interp_data and len(self.density_profile) == len(self.radii): dens = self.density_profile.generate_data_realisations(self._num_samples).T + dens_der = np.gradient(dens, self.radii, axis=0) + else: + # TODO NO DERIVATIVE HERE YET!!! d_bnds = np.vstack([self.density_profile.annulus_bounds[0:-1], self.density_profile.annulus_bounds[1:]]).T @@ -2596,6 +2599,8 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, dens = dens / (MEAN_MOL_WEIGHT * m_p) dens_der = dens_der / (MEAN_MOL_WEIGHT * m_p) + # --------------------------- DEALING WITH THE TEMPERATURE INFO --------------------------- + # We now essentially repeat the process we just did with the density profiles, constructing the temperature # values that we are going to use in our hydrostatic mass measurements; from models, data points, or # interpolating from data points @@ -2606,11 +2611,13 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, # Getting a bunch of realizations (with the number set by the 'num_samples' argument that was passed on # the definition of this source of the model. temp = self._temp_model.get_realisations(calc_rad) + temp_der = self._temp_model.derivative(calc_rad, dx, True) # In this rare case temperature and density profiles are identical, and so we just get some realizations elif (not already_run and (len(self.density_profile) == len(self.temperature_profile)) and (self.density_profile.radii == self.temperature_profile.radii).all()): temp = self.temperature_profile.generate_data_realisations(self._num_samples).T + temp_der = np.gradient(temp, self.radii, axis=0) elif not already_run and self._interp_data: # This uses the temperature profile y-axis values (and their uncertainties) to draw N realizations of the @@ -2619,16 +2626,19 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, temp_interp = interp1d(self.temperature_profile.radii, temp_data_real, axis=1, assume_sorted=True, fill_value='extrapolate', bounds_error=False) temp = Quantity(temp_interp(self.radii).T, self.temperature_profile.values_unit) + temp_der = np.gradient(temp, self.radii, axis=0) # This particular combination means that we are doing a data-point based profile, but without interpolation, # and that the temperature profile has more bins than the density (not going to happen often) elif not already_run and not self._interp_data and len(self.temperature_profile) == len(self.radii): temp = self.temperature_profile.generate_data_realisations(self._num_samples).T + temp_der = np.gradient(temp, self.radii, axis=0) # And here, the final option, we're doing a data-point based profile without interpolation, and we need # to make sure that the density values (here N_denspoints > N_temppoints) each have a corresponding # temperature value - in practise this means that each density will be paired with the temperature # realizations whose radial coverage they fall within. else: + # TODO NO TEMP DERIVATIVE HERE YET!!! t_bnds = np.vstack([self.temperature_profile.annulus_bounds[0:-1], self.temperature_profile.annulus_bounds[1:]]).T @@ -2641,10 +2651,8 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, if not already_run and not temp.unit.is_equivalent('keV'): temp = (temp * k_B).to('keV') - # And now we do the actual mass calculation if not already_run: - # Please note that this is just the vanilla hydrostatic mass equation, but not written in the "standard # form". Here there are no logs in the derivatives, because it's easier to take advantage of astropy's # quantities that way. From edbbc42d755ed01fe4e2477f2e7dcd39b5fd5e41 Mon Sep 17 00:00:00 2001 From: David Turner Date: Wed, 20 Nov 2024 09:32:44 -0500 Subject: [PATCH 15/85] Wasn't converting the temperature derivative to correct units! For issue #1260 --- xga/products/profile.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/xga/products/profile.py b/xga/products/profile.py index 2c8fe5d5..9b6fe7a2 100644 --- a/xga/products/profile.py +++ b/xga/products/profile.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 20/11/2024, 09:28. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 20/11/2024, 09:32. Copyright (c) The Contributors from copy import copy from typing import Tuple, Union, List @@ -2650,6 +2650,7 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, # We ensure the temperatures are in the right unit if not already_run and not temp.unit.is_equivalent('keV'): temp = (temp * k_B).to('keV') + temp_der = (temp_der * k_B).to('keV') # And now we do the actual mass calculation if not already_run: From 6d2f35c07814f15219e4a4ee75f003170ece3911 Mon Sep 17 00:00:00 2001 From: David Turner Date: Wed, 20 Nov 2024 09:41:05 -0500 Subject: [PATCH 16/85] Need to make sure that temperature is in Kelvin rather than keV - was the opposite to the entropy calculation as we tend to use keV for that, so the conversion I copied over was going the wrong way around. For issue #1260 --- xga/products/profile.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/xga/products/profile.py b/xga/products/profile.py index 9b6fe7a2..0e524eac 100644 --- a/xga/products/profile.py +++ b/xga/products/profile.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 20/11/2024, 09:32. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 20/11/2024, 09:41. Copyright (c) The Contributors from copy import copy from typing import Tuple, Union, List @@ -2647,10 +2647,11 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, temp_data_real = self.temperature_profile.generate_data_realisations(self._num_samples) temp = temp_data_real[:, t_inds].T - # We ensure the temperatures are in the right unit - if not already_run and not temp.unit.is_equivalent('keV'): - temp = (temp * k_B).to('keV') - temp_der = (temp_der * k_B).to('keV') + # We ensure the temperatures are in the right unit - we want Kelvin for this, as compared to the entropy + # profile where the 'custom' is to do it in keV + if not already_run and temp.unit.is_equivalent('keV'): + temp = (temp / k_B).to('K') + temp_der = (temp_der / k_B).to('K') # And now we do the actual mass calculation if not already_run: From 3defee44001394596ffbc5694409c3d77c0ca816 Mon Sep 17 00:00:00 2001 From: David Turner Date: Wed, 20 Nov 2024 09:44:06 -0500 Subject: [PATCH 17/85] Was trying to convert the temperature gradient to Kelvin rather than K/{insert distance unit}... think I might be tired. For issue #1260 --- xga/products/profile.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/xga/products/profile.py b/xga/products/profile.py index 0e524eac..9717c551 100644 --- a/xga/products/profile.py +++ b/xga/products/profile.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 20/11/2024, 09:41. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 20/11/2024, 09:44. Copyright (c) The Contributors from copy import copy from typing import Tuple, Union, List @@ -2651,7 +2651,7 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, # profile where the 'custom' is to do it in keV if not already_run and temp.unit.is_equivalent('keV'): temp = (temp / k_B).to('K') - temp_der = (temp_der / k_B).to('K') + temp_der = (temp_der / k_B).to(Unit('K')/self._temp_prof.radii_unit) # And now we do the actual mass calculation if not already_run: From 05c7e93dcfc49f9d8094b69b4ee9a4c67529b651 Mon Sep 17 00:00:00 2001 From: David Turner Date: Wed, 20 Nov 2024 09:48:16 -0500 Subject: [PATCH 18/85] Figuring out why there is a shape mismatch between the mass distribution array and the radii array (could well be a matrix transpose that I removed earlier - added it back in to test). For issue #1260 --- xga/products/profile.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/xga/products/profile.py b/xga/products/profile.py index 9717c551..7b087306 100644 --- a/xga/products/profile.py +++ b/xga/products/profile.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 20/11/2024, 09:44. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 20/11/2024, 09:48. Copyright (c) The Contributors from copy import copy from typing import Tuple, Union, List @@ -2655,6 +2655,12 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, # And now we do the actual mass calculation if not already_run: + + print(dens.shape) + print(dens_der.shape) + print(temp.shape) + print(temp_der.shape) + # Please note that this is just the vanilla hydrostatic mass equation, but not written in the "standard # form". Here there are no logs in the derivatives, because it's easier to take advantage of astropy's # quantities that way. @@ -2664,7 +2670,7 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, # Just converts the mass/masses to the unit we normally use for them # TODO DID HAVE A .T HERE TO TRANSPOSE THE RESULT - NEED TO CHECK IF THAT WILL BE NECESSARY FOR THIS # NEW SET UP - mass_dist = mass_dist.to('Msun') + mass_dist = mass_dist.to('Msun').T # Storing the result if it is for a single radius if radius.isscalar: From ae110a4d20ccbe52633ecdf8845aa7b1853dd84d Mon Sep 17 00:00:00 2001 From: David Turner Date: Wed, 20 Nov 2024 10:00:06 -0500 Subject: [PATCH 19/85] Added an 'allow_unphysical' flag to the declaration of the new mass profile class - means no error will be raised if negative mass is measured. Indirectly for issue #1260 (will now do the same for entropy whilst I think about it). --- xga/products/profile.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/xga/products/profile.py b/xga/products/profile.py index 7b087306..6abd806a 100644 --- a/xga/products/profile.py +++ b/xga/products/profile.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 20/11/2024, 09:48. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 20/11/2024, 10:00. Copyright (c) The Contributors from copy import copy from typing import Tuple, Union, List @@ -2184,6 +2184,8 @@ class NewHydrostaticMass(BaseProfile1D): models, this controls whether the data profile with the coarser bins is interpolated, or whether the other profile's data points are matched with the value that was measured for the radial region they are in (the default). + :param bool allow_unphysical: This controls whether unphysical mass results are 'allowed' without an + exception being raised (e.g. if a calculated mass value is negative). Default is False. :param bool auto_save: Whether the profile should automatically save itself to disk at any point. The default is False, but all profiles generated through XGA processes acting on XGA sources will auto-save. """ @@ -2193,7 +2195,8 @@ def __init__(self, temperature_profile: Union[GasTemperature3D, ProjectedGasTemp density_model: Union[str, BaseModel1D] = None, radii: Quantity = None, radii_err: Quantity = None, deg_radii: Quantity = None, fit_method: str = "mcmc", num_walkers: int = 20, num_steps: [int, List[int]] = 20000, num_samples: int = 1000, show_warn: bool = True, - progress: bool = True, interp_data: bool = False, auto_save: bool = False): + progress: bool = True, interp_data: bool = False, allow_unphysical: bool = False, + auto_save: bool = False): """ A profile product which uses input temperature and density profiles to calculate a cumulative hydrostatic mass profile - used in galaxy cluster analyses (https://ui.adsabs.harvard.edu/abs/2024arXiv240307982T/abstract @@ -2248,6 +2251,8 @@ def __init__(self, temperature_profile: Union[GasTemperature3D, ProjectedGasTemp fitted models, this controls whether the data profile with the coarser bins is interpolated, or whether the other profile's data points are matched with the value that was measured for the radial region they are in (the default). + :param bool allow_unphysical: This controls whether unphysical mass results are 'allowed' without an + exception being raised (e.g. if a calculated mass value is negative). Default is False. :param bool auto_save: Whether the profile should automatically save itself to disk at any point. The default is False, but all profiles generated through XGA processes acting on XGA sources will auto-save. """ @@ -2408,6 +2413,10 @@ def __init__(self, temperature_profile: Union[GasTemperature3D, ProjectedGasTemp # hydrostatic mass profile rather than model derived. self._num_samples = num_samples + # A simple flag that controls whether the 'mass()' method will raise an exception if an unphysical mass is + # calculated, or if it will let it go through without an exception + self._allow_unphysical = allow_unphysical + mass, mass_dist = self.mass(radii, conf_level=68, radius_err=radii_err) mass_vals = mass[0, :] mass_errs = np.mean(mass[1:, :], axis=0) @@ -2686,8 +2695,8 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, mass_res = Quantity(np.array([mass_med.value, mass_lower.value, mass_upper.value]), mass_dist.unit) # We check to see if any of the upper limits (i.e. measured value plus +ve error) are below zero, and if so - # then we throw an exception up - if np.any((mass_res[0] + mass_res[1]) < 0): + # then we throw an exception up (if the profile is set to do that - it is the default behaviour). + if not self._allow_unphysical and np.any((mass_res[0] + mass_res[1]) < 0): raise ValueError("A mass upper limit (i.e. measured value plus +ve error) of less than zero has been " "measured, which is not physical.") From 2593f2e057eca8e7198b278d1f1dd823e1399a43 Mon Sep 17 00:00:00 2001 From: David Turner Date: Wed, 20 Nov 2024 10:09:49 -0500 Subject: [PATCH 20/85] Added allow_unphysical flag to SpecificEntropy class --- xga/products/profile.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/xga/products/profile.py b/xga/products/profile.py index 6abd806a..e2f08440 100644 --- a/xga/products/profile.py +++ b/xga/products/profile.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 20/11/2024, 10:00. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 20/11/2024, 10:09. Copyright (c) The Contributors from copy import copy from typing import Tuple, Union, List @@ -3300,6 +3300,8 @@ class SpecificEntropy(BaseProfile1D): this controls whether the data profile with the coarser bins is interpolated, or whether the other profile's data points are matched with the value that was measured for the radial region they are in (the default). + :param bool allow_unphysical: This controls whether unphysical entropy results are 'allowed' without an + exception being raised (e.g. if a calculated entropy value is negative). Default is False. :param bool auto_save: Whether the profile should automatically save itself to disk at any point. The default is False, but all profiles generated through XGA processes acting on XGA sources will auto-save. """ @@ -3309,7 +3311,8 @@ def __init__(self, temperature_profile: Union[GasTemperature3D, ProjectedGasTemp density_model: Union[str, BaseModel1D] = None, radii: Quantity = None, radii_err: Quantity = None, deg_radii: Quantity = None, fit_method: str = "mcmc", num_walkers: int = 20, num_steps: [int, List[int]] = 20000, num_samples: int = 1000, show_warn: bool = True, - progress: bool = True, interp_data: bool = False, auto_save: bool = False): + progress: bool = True, interp_data: bool = False, allow_unphysical: bool = False, + auto_save: bool = False): """ A profile product which uses input temperature and density profiles to calculate a specific entropy profile of the kind often uses in galaxy cluster analyses (https://ui.adsabs.harvard.edu/abs/2009ApJS..182...12C/abstract @@ -3361,6 +3364,8 @@ def __init__(self, temperature_profile: Union[GasTemperature3D, ProjectedGasTemp this controls whether the data profile with the coarser bins is interpolated, or whether the other profile's data points are matched with the value that was measured for the radial region they are in (the default). + :param bool allow_unphysical: This controls whether unphysical entropy results are 'allowed' without an + exception being raised (e.g. if a calculated entropy value is negative). Default is False. :param bool auto_save: Whether the profile should automatically save itself to disk at any point. The default is False, but all profiles generated through XGA processes acting on XGA sources will auto-save. """ @@ -3520,6 +3525,10 @@ def __init__(self, temperature_profile: Union[GasTemperature3D, ProjectedGasTemp # entropy profile rather than model derived. self._num_samples = num_samples + # A simple flag that controls whether the 'mass()' method will raise an exception if an unphysical mass is + # calculated, or if it will let it go through without an exception + self._allow_unphysical = allow_unphysical + ent, ent_dist = self.entropy(radii, conf_level=68) ent_vals = ent[0, :] ent_errs = np.mean(ent[1:, :], axis=0) @@ -3692,7 +3701,7 @@ def entropy(self, radius: Quantity, conf_level: float = 68.2) -> Union[Quantity, # Set up the result to return as an astropy quantity. ent_res = Quantity(np.array([ent_med.value, ent_lower.value, ent_upper.value]), ent_dist.unit) - if np.any(ent_res[0] < 0): + if not self._allow_unphysical and np.any(ent_res[0] < 0): raise ValueError("A specific entropy of less than zero has been measured, which is not physical.") return ent_res, ent_dist From cf8e060fa465523c0d8024823b2e5165f0480f54 Mon Sep 17 00:00:00 2001 From: David Turner Date: Wed, 20 Nov 2024 10:11:35 -0500 Subject: [PATCH 21/85] Changed the BaseAggregateProfile init to just check that the x and y units are the same, rather than explicitly look at the Python instance type. For issue #1260 --- xga/products/base.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/xga/products/base.py b/xga/products/base.py index 27104f0c..93a6ec89 100644 --- a/xga/products/base.py +++ b/xga/products/base.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 01/08/2024, 10:01. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 20/11/2024, 10:11. Copyright (c) The Contributors import inspect import os @@ -2448,18 +2448,14 @@ def __init__(self, profiles: List[BaseProfile1D]): """ The init for the BaseAggregateProfile1D class. """ - # This checks that all types of profiles in the profiles list are the same - types = [type(p) for p in profiles] - if len(set(types)) != 1: - raise TypeError("All component profiles must be of the same type") - # This checks that all profiles have the same x units + # This checks that all profiles have the same x units - we used to explicitly check for Python instance + # type, but actually we do want profiles to be plottable on the same axis if they have the same units x_units = [p.radii_unit.to_string() for p in profiles] if len(set(x_units)) != 1: raise TypeError("All component profiles must have the same radii units.") - # THis checks that they all have the same y units. This is likely to be true if they are the same - # type, but you never know + # THis checks that they all have the same y units. y_units = [p.values_unit.to_string() for p in profiles] if len(set(y_units)) != 1: raise TypeError("All component profiles must have the same value units.") From 9a13f6eed7a139a603a4cfb1457a20bf31d75409 Mon Sep 17 00:00:00 2001 From: David Turner Date: Wed, 20 Nov 2024 11:58:07 -0500 Subject: [PATCH 22/85] Added a basic XGA-model implementation of the NFW profile (integrated for cumulative mass rather than mass density) - this will be useful for fitting hydrostatic mass profiles. For issue #1260 (sort of) --- xga/models/mass.py | 108 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 xga/models/mass.py diff --git a/xga/models/mass.py b/xga/models/mass.py new file mode 100644 index 00000000..03b5f9a5 --- /dev/null +++ b/xga/models/mass.py @@ -0,0 +1,108 @@ +# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). +# Last modified by David J Turner (turne540@msu.edu) 20/11/2024, 11:58. Copyright (c) The Contributors + +from typing import Union, List + +import numpy as np +from astropy.units import Quantity, Unit, UnitConversionError, kpc, deg + +from .base import BaseModel1D +from ..utils import r500, r200, r2500 + + +class NFW(BaseModel1D): + """ + A simple model to fit galaxy cluster mass profiles (https://ui.adsabs.harvard.edu/abs/1997ApJ...490..493N/abstract) + - a cumulative mass version of the Navarro-Frenk-White profile. Typically, the NFW is formulated in terms of mass + density, but one can derive a mass profile from it (https://ui.adsabs.harvard.edu/abs/2006MNRAS.368..518V/abstract). + + The NFW is extremely widely used, though generally for dark matter mass profiles, but will act as a handy + functional form to fit to data-driven mass profiles derived from X-ray observations of clusters. + + :param Unit/str x_unit: The unit of the x-axis of this model, kpc for instance. May be passed as a string + representation or an astropy unit object. + :param Unit/str y_unit: The unit of the output of this model, keV for instance. May be passed as a string + representation or an astropy unit object. + :param List[Quantity] cust_start_pars: The start values of the model parameters for any fitting function that + used start values. The units are checked against default start values. + """ + def __init__(self, x_unit: Union[str, Unit] = 'kpc', y_unit: Union[str, Unit] = Unit('Msun'), + cust_start_pars: List[Quantity] = None): + """ + The init of a subclass of the XGA BaseModel1D class, describing the shape of cumulative mass profiles for + a galaxy cluster based on the NFW mass density profile. + """ + + # If a string representation of a unit was passed then we make it an astropy unit + if isinstance(x_unit, str): + x_unit = Unit(x_unit) + if isinstance(y_unit, str): + y_unit = Unit(y_unit) + + poss_y_units = [Unit('Msun')] + y_convertible = [u.is_equivalent(y_unit) for u in poss_y_units] + if not any(y_convertible): + allowed = ", ".join([u.to_string() for u in poss_y_units]) + raise UnitConversionError("{p} is not convertible to any of the allowed units; " + "{a}".format(p=y_unit.to_string(), a=allowed)) + else: + yu_ind = y_convertible.index(True) + + poss_x_units = [kpc, deg, r200, r500, r2500] + x_convertible = [u.is_equivalent(x_unit) for u in poss_x_units] + if not any(x_convertible): + allowed = ", ".join([u.to_string() for u in poss_x_units]) + raise UnitConversionError("{p} is not convertible to any of the allowed units; " + "{a}".format(p=x_unit.to_string(), a=allowed)) + else: + xu_ind = x_convertible.index(True) + + r_scale_starts = [Quantity(100, 'kpc'), Quantity(0.2, 'deg'), Quantity(0.05, r200), Quantity(0.1, r500), + Quantity(0.5, r2500)] + # We will implement the NFW mass profile with a rho_0 normalization parameter, a density - and leave in the + # volume integration terms - rather than fitting for some mass normalization + norm_starts = [Quantity(1e+13, 'Msun/Mpc^3')] + start_pars = [r_scale_starts[xu_ind], norm_starts[yu_ind]] + if cust_start_pars is not None: + # If the custom start parameters can run this gauntlet without tripping an error then we're all good + # This method also returns the custom start pars converted to exactly the same units as the default + start_pars = self.compare_units(cust_start_pars, start_pars) + + r_core_priors = [{'prior': Quantity([0, 2000], 'kpc'), 'type': 'uniform'}, + {'prior': Quantity([0, 1], 'deg'), 'type': 'uniform'}, + {'prior': Quantity([0, 1], r200), 'type': 'uniform'}, + {'prior': Quantity([0, 1], r500), 'type': 'uniform'}, + {'prior': Quantity([0, 1], r2500), 'type': 'uniform'}] + norm_priors = [{'prior': Quantity([1e+12, 1e+16], 'Msun/Mpc^3'), 'type': 'uniform'}] + + priors = [{'prior': Quantity([0, 3]), 'type': 'uniform'}, r_core_priors[xu_ind], norm_priors[yu_ind]] + + nice_pars = [r"R$_{\rm{s}}$", r"\rho$_{0}$"] + info_dict = {'author': 'Navarro J, Frenk C, White S', 'year': '1997', + 'reference': 'https://ui.adsabs.harvard.edu/abs/1997ApJ...490..493N/abstract', + 'general': 'The cumulative mass version of the NFW mass-density profile for galaxy \n' + 'clusters - normally used to describe dark matter profiles.'} + + super().__init__(x_unit, y_unit, start_pars, priors, 'nfw', 'NFW Profile', nice_pars, 'Mass', + info_dict) + + @staticmethod + def model(x: Quantity, r_scale: Quantity, rho_zero: Quantity) -> Quantity: + """ + The model function for the constant-core and power-law entropy model. + + :param Quantity x: The radii to calculate y values for. + :param Quantity r_scale: The scale radius parameter. + :param Quantity rho_zero: A density normalization parameter. + :return: The y values corresponding to the input x values. + :rtype: Quantity + """ + + norm_rad = x / r_scale + return 4*np.pi*rho_zero*np.power(r_scale, 3)*(np.log(1 + norm_rad) - (norm_rad / (1 + norm_rad))) + + +# So that things like fitting functions can be written generally to support different models +MASS_MODELS = {"nfw": NFW} +MASS_MODELS_PUB_NAMES = {n: m().publication_name for n, m in MASS_MODELS.items()} +MASS_MODELS_PAR_NAMES = {n: m().par_publication_names for n, m in MASS_MODELS.items()} \ No newline at end of file From 786b0ca4b5365256a5b886c2d1d3a18ab43c61a6 Mon Sep 17 00:00:00 2001 From: David Turner Date: Wed, 20 Nov 2024 12:00:04 -0500 Subject: [PATCH 23/85] Made sure to add the new mass model section of XGA models to the dictionaries which let profiles look up what models they are allowed to be fit with. For issue #1260 (sort of) --- xga/models/__init__.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/xga/models/__init__.py b/xga/models/__init__.py index b82dc184..19162c76 100644 --- a/xga/models/__init__.py +++ b/xga/models/__init__.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 29/07/2024, 21:58. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 20/11/2024, 12:00. Copyright (c) The Contributors import inspect from types import FunctionType @@ -8,6 +8,7 @@ # it becomes a big inefficiency from .density import * from .entropy import * +from .mass import * from .misc import * from .sb import * from .temperature import * @@ -15,12 +16,13 @@ # This dictionary is meant to provide pretty versions of model/function names to go in plots # This method of merging dictionaries only works in Python 3.5+, but that should be fine MODEL_PUBLICATION_NAMES = {**DENS_MODELS_PUB_NAMES, **MISC_MODELS_PUB_NAMES, **SB_MODELS_PUB_NAMES, - **TEMP_MODELS_PUB_NAMES, **ENTROPY_MODELS_PUB_NAMES} + **TEMP_MODELS_PUB_NAMES, **ENTROPY_MODELS_PUB_NAMES, **MASS_MODELS_PUB_NAMES} MODEL_PUBLICATION_PAR_NAMES = {**DENS_MODELS_PAR_NAMES, **MISC_MODELS_PAR_NAMES, **SB_MODELS_PAR_NAMES, - **TEMP_MODELS_PAR_NAMES, **ENTROPY_MODELS_PAR_NAMES} + **TEMP_MODELS_PAR_NAMES, **ENTROPY_MODELS_PAR_NAMES, **MASS_MODELS_PAR_NAMES} # These dictionaries tell the profile fitting function what models, start pars, and priors are allowed PROF_TYPE_MODELS = {"brightness": SB_MODELS, "gas_density": DENS_MODELS, "gas_temperature": TEMP_MODELS, - '1d_proj_temperature': TEMP_MODELS, 'specific_entropy': ENTROPY_MODELS} + '1d_proj_temperature': TEMP_MODELS, 'specific_entropy': ENTROPY_MODELS, + 'hydrostatic_mass': MASS_MODELS} def convert_to_odr_compatible(model_func: FunctionType, new_par_name: str = 'β', new_data_name: str = 'x_values') \ From 5664985ef5db65ca5f30967393199c4b58a9d181 Mon Sep 17 00:00:00 2001 From: David Turner Date: Wed, 20 Nov 2024 12:04:44 -0500 Subject: [PATCH 24/85] Corrected a small mistake in the NFW mass model - had a dimensionless prior in there when there should not have been. For issue #1260 (sort of) --- xga/models/mass.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/xga/models/mass.py b/xga/models/mass.py index 03b5f9a5..64fbdc0d 100644 --- a/xga/models/mass.py +++ b/xga/models/mass.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 20/11/2024, 11:58. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 20/11/2024, 12:04. Copyright (c) The Contributors from typing import Union, List @@ -75,7 +75,7 @@ def __init__(self, x_unit: Union[str, Unit] = 'kpc', y_unit: Union[str, Unit] = {'prior': Quantity([0, 1], r2500), 'type': 'uniform'}] norm_priors = [{'prior': Quantity([1e+12, 1e+16], 'Msun/Mpc^3'), 'type': 'uniform'}] - priors = [{'prior': Quantity([0, 3]), 'type': 'uniform'}, r_core_priors[xu_ind], norm_priors[yu_ind]] + priors = [r_core_priors[xu_ind], norm_priors[yu_ind]] nice_pars = [r"R$_{\rm{s}}$", r"\rho$_{0}$"] info_dict = {'author': 'Navarro J, Frenk C, White S', 'year': '1997', From 15c94b44107a21dcb4d0e2eecb93015559caf2c1 Mon Sep 17 00:00:00 2001 From: David Turner Date: Wed, 20 Nov 2024 15:44:55 -0500 Subject: [PATCH 25/85] The 'nice' parameter name for the NFW density parameter had the \rho latex code outside of a math environment so it didn't render properly. --- xga/models/mass.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/xga/models/mass.py b/xga/models/mass.py index 64fbdc0d..7338ddf0 100644 --- a/xga/models/mass.py +++ b/xga/models/mass.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 20/11/2024, 12:04. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 20/11/2024, 15:44. Copyright (c) The Contributors from typing import Union, List @@ -77,7 +77,7 @@ def __init__(self, x_unit: Union[str, Unit] = 'kpc', y_unit: Union[str, Unit] = priors = [r_core_priors[xu_ind], norm_priors[yu_ind]] - nice_pars = [r"R$_{\rm{s}}$", r"\rho$_{0}$"] + nice_pars = [r"R$_{\rm{s}}$", r"$\rho_{0}$"] info_dict = {'author': 'Navarro J, Frenk C, White S', 'year': '1997', 'reference': 'https://ui.adsabs.harvard.edu/abs/1997ApJ...490..493N/abstract', 'general': 'The cumulative mass version of the NFW mass-density profile for galaxy \n' From 76a2e7f1b5151a45107896437c56833a211cac82 Mon Sep 17 00:00:00 2001 From: David Turner Date: Wed, 20 Nov 2024 17:32:33 -0500 Subject: [PATCH 26/85] Don't know that the NFW model units are working like I thought they would. Putting in a stupid test --- xga/models/mass.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/xga/models/mass.py b/xga/models/mass.py index 7338ddf0..936ba7fd 100644 --- a/xga/models/mass.py +++ b/xga/models/mass.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 20/11/2024, 15:44. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 20/11/2024, 17:32. Copyright (c) The Contributors from typing import Union, List @@ -99,6 +99,8 @@ def model(x: Quantity, r_scale: Quantity, rho_zero: Quantity) -> Quantity: """ norm_rad = x / r_scale + print((4*np.pi*rho_zero*np.power(r_scale, 3)*(np.log(1 + norm_rad) - (norm_rad / (1 + norm_rad)))).unit) + stop return 4*np.pi*rho_zero*np.power(r_scale, 3)*(np.log(1 + norm_rad) - (norm_rad / (1 + norm_rad))) From d572f3ac6a958ec921e4f4bd90a2c0651a115575 Mon Sep 17 00:00:00 2001 From: David Turner Date: Wed, 20 Nov 2024 17:34:36 -0500 Subject: [PATCH 27/85] Don't know that the NFW model units are working like I thought they would. Putting in a stupid test --- xga/models/mass.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/xga/models/mass.py b/xga/models/mass.py index 936ba7fd..04bff766 100644 --- a/xga/models/mass.py +++ b/xga/models/mass.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 20/11/2024, 17:32. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 20/11/2024, 17:34. Copyright (c) The Contributors from typing import Union, List @@ -99,7 +99,7 @@ def model(x: Quantity, r_scale: Quantity, rho_zero: Quantity) -> Quantity: """ norm_rad = x / r_scale - print((4*np.pi*rho_zero*np.power(r_scale, 3)*(np.log(1 + norm_rad) - (norm_rad / (1 + norm_rad)))).unit) + print((4*np.pi*rho_zero*np.power(r_scale, 3)*(np.log(1 + norm_rad) - (norm_rad / (1 + norm_rad))))[0].unit) stop return 4*np.pi*rho_zero*np.power(r_scale, 3)*(np.log(1 + norm_rad) - (norm_rad / (1 + norm_rad))) From 960751ba847c8089cabf95c1e40a13041f2ca17b Mon Sep 17 00:00:00 2001 From: David Turner Date: Wed, 20 Nov 2024 17:36:46 -0500 Subject: [PATCH 28/85] Don't know that the NFW model units are working like I thought they would. Putting in a stupid test --- xga/models/mass.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/xga/models/mass.py b/xga/models/mass.py index 04bff766..b91709b8 100644 --- a/xga/models/mass.py +++ b/xga/models/mass.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 20/11/2024, 17:34. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 20/11/2024, 17:36. Copyright (c) The Contributors from typing import Union, List @@ -99,7 +99,7 @@ def model(x: Quantity, r_scale: Quantity, rho_zero: Quantity) -> Quantity: """ norm_rad = x / r_scale - print((4*np.pi*rho_zero*np.power(r_scale, 3)*(np.log(1 + norm_rad) - (norm_rad / (1 + norm_rad))))[0].unit) + print((4*np.pi*rho_zero*np.power(r_scale, 3)*(np.log(1 + norm_rad) - (norm_rad / (1 + norm_rad))))) stop return 4*np.pi*rho_zero*np.power(r_scale, 3)*(np.log(1 + norm_rad) - (norm_rad / (1 + norm_rad))) From 176e9230ebf9c38ecca80e73bb28a0d4e6db6ccc Mon Sep 17 00:00:00 2001 From: David Turner Date: Wed, 20 Nov 2024 17:49:29 -0500 Subject: [PATCH 29/85] Restoring the NFW model to something that will run (if not necessarily be correct). --- xga/models/mass.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/xga/models/mass.py b/xga/models/mass.py index b91709b8..645910e0 100644 --- a/xga/models/mass.py +++ b/xga/models/mass.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 20/11/2024, 17:36. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 20/11/2024, 17:49. Copyright (c) The Contributors from typing import Union, List @@ -99,9 +99,9 @@ def model(x: Quantity, r_scale: Quantity, rho_zero: Quantity) -> Quantity: """ norm_rad = x / r_scale - print((4*np.pi*rho_zero*np.power(r_scale, 3)*(np.log(1 + norm_rad) - (norm_rad / (1 + norm_rad))))) - stop - return 4*np.pi*rho_zero*np.power(r_scale, 3)*(np.log(1 + norm_rad) - (norm_rad / (1 + norm_rad))) + result = 4*np.pi*rho_zero*np.power(r_scale, 3)*(np.log(1 + norm_rad) - (norm_rad / (1 + norm_rad))) + print(type(result)) + return result # So that things like fitting functions can be written generally to support different models From d7cb341b9117ddc33c0e944f2710dead0ea76d48 Mon Sep 17 00:00:00 2001 From: David Turner Date: Wed, 20 Nov 2024 17:50:21 -0500 Subject: [PATCH 30/85] Ooops didn't remove one thing from NFW --- xga/models/mass.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/xga/models/mass.py b/xga/models/mass.py index 645910e0..b34baf63 100644 --- a/xga/models/mass.py +++ b/xga/models/mass.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 20/11/2024, 17:49. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 20/11/2024, 17:50. Copyright (c) The Contributors from typing import Union, List @@ -100,7 +100,6 @@ def model(x: Quantity, r_scale: Quantity, rho_zero: Quantity) -> Quantity: norm_rad = x / r_scale result = 4*np.pi*rho_zero*np.power(r_scale, 3)*(np.log(1 + norm_rad) - (norm_rad / (1 + norm_rad))) - print(type(result)) return result From 1721844b05d83f152b1329fff2f09ca31e1a5095 Mon Sep 17 00:00:00 2001 From: David Turner Date: Wed, 20 Nov 2024 19:51:52 -0500 Subject: [PATCH 31/85] Changing the start parameters and priors for NFW to the msun/kpc^3 unit - this really shouldn't matter and clearly I need to fix how models deal with units. Really just want a fit working though, for issue #1260 --- xga/models/mass.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/xga/models/mass.py b/xga/models/mass.py index b34baf63..26ee687d 100644 --- a/xga/models/mass.py +++ b/xga/models/mass.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 20/11/2024, 17:50. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 20/11/2024, 19:51. Copyright (c) The Contributors from typing import Union, List @@ -61,7 +61,8 @@ def __init__(self, x_unit: Union[str, Unit] = 'kpc', y_unit: Union[str, Unit] = Quantity(0.5, r2500)] # We will implement the NFW mass profile with a rho_0 normalization parameter, a density - and leave in the # volume integration terms - rather than fitting for some mass normalization - norm_starts = [Quantity(1e+13, 'Msun/Mpc^3')] + # norm_starts = [Quantity(1e+13, 'Msun/Mpc^3')] + norm_starts = [Quantity(1e+6, 'Msun/kpc^3')] start_pars = [r_scale_starts[xu_ind], norm_starts[yu_ind]] if cust_start_pars is not None: # If the custom start parameters can run this gauntlet without tripping an error then we're all good @@ -73,7 +74,8 @@ def __init__(self, x_unit: Union[str, Unit] = 'kpc', y_unit: Union[str, Unit] = {'prior': Quantity([0, 1], r200), 'type': 'uniform'}, {'prior': Quantity([0, 1], r500), 'type': 'uniform'}, {'prior': Quantity([0, 1], r2500), 'type': 'uniform'}] - norm_priors = [{'prior': Quantity([1e+12, 1e+16], 'Msun/Mpc^3'), 'type': 'uniform'}] + # norm_priors = [{'prior': Quantity([1e+12, 1e+16], 'Msun/Mpc^3'), 'type': 'uniform'}] + norm_priors = [{"prior": Quantity([1000, 1e+9], 'Msun/kpc^3'), "type": 'uniform'}] priors = [r_core_priors[xu_ind], norm_priors[yu_ind]] From 6337fa4ea781dc9f5665fb856f3560d0ee198417 Mon Sep 17 00:00:00 2001 From: David Turner Date: Wed, 20 Nov 2024 21:09:16 -0500 Subject: [PATCH 32/85] Trying something for the new hydrostatic mass profile measurements - setting any value in the mass distribution less than zero to be NaN. For issue #1260 --- xga/products/profile.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/xga/products/profile.py b/xga/products/profile.py index e2f08440..94e0c228 100644 --- a/xga/products/profile.py +++ b/xga/products/profile.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 20/11/2024, 10:09. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 20/11/2024, 21:09. Copyright (c) The Contributors from copy import copy from typing import Tuple, Union, List @@ -2676,9 +2676,10 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, mass_dist = (((-1 * k_B * np.power(radius[..., None], 2)) / (dens * (MEAN_MOL_WEIGHT * m_p) * G)) * ((dens * temp_der) + (temp * dens_der))) + # TODO THIS IS A TEST OF A THOUGHT I HAD - MAYBE REMOVE? + mass_dist[mass_dist < 0] = np.nan # Just converts the mass/masses to the unit we normally use for them - # TODO DID HAVE A .T HERE TO TRANSPOSE THE RESULT - NEED TO CHECK IF THAT WILL BE NECESSARY FOR THIS - # NEW SET UP + mass_dist = mass_dist.to('Msun').T # Storing the result if it is for a single radius From 3ac7a9f9147bd243fe532b0caa3aea3f8ffc2d2d Mon Sep 17 00:00:00 2001 From: David Turner Date: Wed, 20 Nov 2024 21:15:36 -0500 Subject: [PATCH 33/85] That went fairly badly - #1260 --- xga/products/profile.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/xga/products/profile.py b/xga/products/profile.py index 94e0c228..2ca780a4 100644 --- a/xga/products/profile.py +++ b/xga/products/profile.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 20/11/2024, 21:09. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 20/11/2024, 21:15. Copyright (c) The Contributors from copy import copy from typing import Tuple, Union, List @@ -2677,7 +2677,7 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, * ((dens * temp_der) + (temp * dens_der))) # TODO THIS IS A TEST OF A THOUGHT I HAD - MAYBE REMOVE? - mass_dist[mass_dist < 0] = np.nan + # mass_dist[mass_dist < 0] = np.nan # Just converts the mass/masses to the unit we normally use for them mass_dist = mass_dist.to('Msun').T From 590e9945905f363ebc3d23f1bb3d409fe4bd524f Mon Sep 17 00:00:00 2001 From: David Turner Date: Wed, 20 Nov 2024 21:32:16 -0500 Subject: [PATCH 34/85] Fixed a docstring typo --- xga/models/base.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/xga/models/base.py b/xga/models/base.py index e56d2f68..55fc3c48 100644 --- a/xga/models/base.py +++ b/xga/models/base.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 07/08/2024, 10:14. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 20/11/2024, 21:32. Copyright (c) The Contributors import inspect from abc import ABCMeta, abstractmethod @@ -564,7 +564,7 @@ def compare_units(check_pars: List[Quantity], good_pars: List[Quantity]) -> List :param List[Quantity] good_pars: The second list of parameters, these are taken as having 'correct' units. :return: Only if the check pars pass the tests. We return the check pars list but with all elements - converted to EXACTLY the same units as good_pars, not just equivelant. + converted to EXACTLY the same units as good_pars, not just equivalent. :rtype: List[Quantity] """ if len(check_pars) != len(good_pars): From a01796291be3e964e97c2df218005843fcf6d27b Mon Sep 17 00:00:00 2001 From: David Turner Date: Wed, 20 Nov 2024 22:03:13 -0500 Subject: [PATCH 35/85] Attempting to understand what is going on with profile fitting and parameter units. There are dimensionless parameters used in the fitting process - as I assume using quantities was causing problems when I developed it, but how then am I gonna solve the problem of the model prediction being in the wrong unit to compare to the data? --- xga/products/base.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/xga/products/base.py b/xga/products/base.py index 93a6ec89..71f59a1d 100644 --- a/xga/products/base.py +++ b/xga/products/base.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 20/11/2024, 10:11. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 20/11/2024, 22:03. Copyright (c) The Contributors import inspect import os @@ -833,10 +833,11 @@ def find_to_replace(start_pos: np.ndarray, par_lims: np.ndarray) -> np.ndarray: for prior in model.par_priors: if prior['type'] != 'uniform': - raise NotImplementedError("Sorry but I don't yet support non-uniform priors for profile fitting!") + raise NotImplementedError("Sorry but we don't yet support non-uniform priors for profile fitting!") prior_list = [p['prior'].to(model.par_units[p_ind]).value for p_ind, p in enumerate(model.par_priors)] prior_arr = np.array(prior_list) + print(prior_arr) # We can run a curve_fit fit to try and get start values for the model parameters, and if that fails # we try maximum likelihood, and if that fails then we fall back on the default start parameters in the @@ -855,13 +856,15 @@ def find_to_replace(start_pos: np.ndarray, par_lims: np.ndarray) -> np.ndarray: # I'm now adding this checking step, which will revert to the default start parameters of the model if the # maximum likelihood estimate produced insane results. base_start_pars = max_like_res.x + print(base_start_pars) # So if any of the max likelihood pars are outside their prior, we just revert back to the original # start parameters of the model. This step may make the checks performed later for instances where all # start positions for a parameter are outside the prior a bit pointless, but I'm leaving them in for safety. if find_to_replace(base_start_pars, prior_arr).any(): warn("Maximum likelihood estimator has produced at least one start parameter that is outside" - " the allowed values defined by the prior, reverting to default start parameters for this model.") + " the allowed values defined by the prior, reverting to default start parameters for this model.", + stacklevel=2) base_start_pars = model.unitless_start_pars # This basically finds the order of magnitude of each parameter, so we know the scale on which we should @@ -889,7 +892,7 @@ def find_to_replace(start_pos: np.ndarray, par_lims: np.ndarray) -> np.ndarray: if any(all_bad): warn("All walker starting parameters for one or more of the model parameters are outside the priors, which" "probably indicates a bad initial fit (which is used to get initial start parameters). Values will be" - " drawn from the priors directly.") + " drawn from the priors directly.", stacklevel=2) # This replacement only affects those parameters for which ALL start positions are outside the # prior range all_bad_inds = np.argwhere(all_bad).T[0] @@ -912,6 +915,8 @@ def find_to_replace(start_pos: np.ndarray, par_lims: np.ndarray) -> np.ndarray: # from the prior pos[to_replace] = rand_uniform_pos[to_replace] + print(pos) + # This instantiates an Ensemble sampler with the number of walkers specified by the user, # with the log probability as defined in the functions above sampler = em.EnsembleSampler(num_walkers, model.num_pars, log_prob, args=(rads, y_data, y_errs, model.model, @@ -1196,11 +1201,11 @@ def fit(self, model: Union[str, BaseModel1D], method: str = "mcmc", num_samples: # XGA model objects generate from their name and their start parameters if model.name in self._good_model_fits[method]: warn("{m} already has a successful fit result for this profile using {me}, with those start " - "parameters".format(m=model.name, me=method)) + "parameters".format(m=model.name, me=method), stacklevel=2) already_done = True elif model.name in self._bad_model_fits[method]: warn("{m} already has a failed fit result for this profile using {me} with those start " - "parameters".format(m=model.name, me=method)) + "parameters".format(m=model.name, me=method), stacklevel=2) already_done = False else: already_done = False From 5955d164bd9bb9bf8d91c83fcd0c8f270bbab0b0 Mon Sep 17 00:00:00 2001 From: David Turner Date: Wed, 20 Nov 2024 22:20:14 -0500 Subject: [PATCH 36/85] I am attempting to address this problem completely by ensuring that the data of a profile are converted to the current output unit of the model. For issue #1267 --- xga/products/base.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/xga/products/base.py b/xga/products/base.py index 71f59a1d..c15487f0 100644 --- a/xga/products/base.py +++ b/xga/products/base.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 20/11/2024, 22:03. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 20/11/2024, 22:20. Copyright (c) The Contributors import inspect import os @@ -825,7 +825,7 @@ def find_to_replace(start_pos: np.ndarray, par_lims: np.ndarray) -> np.ndarray: self._model_allegiance(model) # I'm just defining these here so that the lines don't get too long for PEP standards - y_data = (self.values.copy() - self._background).value + y_data = (self.values.copy() - self._background).to(model(self.radii[0]).unit).value y_errs = self.values_err.copy().value rads = self.fit_radii.copy().value success = True @@ -837,7 +837,6 @@ def find_to_replace(start_pos: np.ndarray, par_lims: np.ndarray) -> np.ndarray: prior_list = [p['prior'].to(model.par_units[p_ind]).value for p_ind, p in enumerate(model.par_priors)] prior_arr = np.array(prior_list) - print(prior_arr) # We can run a curve_fit fit to try and get start values for the model parameters, and if that fails # we try maximum likelihood, and if that fails then we fall back on the default start parameters in the @@ -856,7 +855,6 @@ def find_to_replace(start_pos: np.ndarray, par_lims: np.ndarray) -> np.ndarray: # I'm now adding this checking step, which will revert to the default start parameters of the model if the # maximum likelihood estimate produced insane results. base_start_pars = max_like_res.x - print(base_start_pars) # So if any of the max likelihood pars are outside their prior, we just revert back to the original # start parameters of the model. This step may make the checks performed later for instances where all @@ -915,8 +913,6 @@ def find_to_replace(start_pos: np.ndarray, par_lims: np.ndarray) -> np.ndarray: # from the prior pos[to_replace] = rand_uniform_pos[to_replace] - print(pos) - # This instantiates an Ensemble sampler with the number of walkers specified by the user, # with the log probability as defined in the functions above sampler = em.EnsembleSampler(num_walkers, model.num_pars, log_prob, args=(rads, y_data, y_errs, model.model, From 80b116d2ff4999d47c7c8ce92fc3d4ccf9bf1d4c Mon Sep 17 00:00:00 2001 From: David Turner Date: Wed, 20 Nov 2024 22:21:57 -0500 Subject: [PATCH 37/85] Returning the NFW profile to the original start parameters and priors that were causing problems, to see if the fix works. For issue #1267 (and indirectly for issue #1260) --- xga/models/mass.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/xga/models/mass.py b/xga/models/mass.py index 26ee687d..d74d93e7 100644 --- a/xga/models/mass.py +++ b/xga/models/mass.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 20/11/2024, 19:51. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 20/11/2024, 22:21. Copyright (c) The Contributors from typing import Union, List @@ -61,8 +61,8 @@ def __init__(self, x_unit: Union[str, Unit] = 'kpc', y_unit: Union[str, Unit] = Quantity(0.5, r2500)] # We will implement the NFW mass profile with a rho_0 normalization parameter, a density - and leave in the # volume integration terms - rather than fitting for some mass normalization - # norm_starts = [Quantity(1e+13, 'Msun/Mpc^3')] - norm_starts = [Quantity(1e+6, 'Msun/kpc^3')] + norm_starts = [Quantity(1e+13, 'Msun/Mpc^3')] + start_pars = [r_scale_starts[xu_ind], norm_starts[yu_ind]] if cust_start_pars is not None: # If the custom start parameters can run this gauntlet without tripping an error then we're all good @@ -74,8 +74,7 @@ def __init__(self, x_unit: Union[str, Unit] = 'kpc', y_unit: Union[str, Unit] = {'prior': Quantity([0, 1], r200), 'type': 'uniform'}, {'prior': Quantity([0, 1], r500), 'type': 'uniform'}, {'prior': Quantity([0, 1], r2500), 'type': 'uniform'}] - # norm_priors = [{'prior': Quantity([1e+12, 1e+16], 'Msun/Mpc^3'), 'type': 'uniform'}] - norm_priors = [{"prior": Quantity([1000, 1e+9], 'Msun/kpc^3'), "type": 'uniform'}] + norm_priors = [{'prior': Quantity([1e+12, 1e+16], 'Msun/Mpc^3'), 'type': 'uniform'}] priors = [r_core_priors[xu_ind], norm_priors[yu_ind]] From 8e4b13007feed733bc1ab0cc5c2da4efc7963ae9 Mon Sep 17 00:00:00 2001 From: David Turner Date: Wed, 20 Nov 2024 22:32:01 -0500 Subject: [PATCH 38/85] That did not work, making sure to convert the value errors as well - for issue #1267 --- xga/models/mass.py | 4 ++-- xga/products/base.py | 7 +++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/xga/models/mass.py b/xga/models/mass.py index d74d93e7..2b8cb686 100644 --- a/xga/models/mass.py +++ b/xga/models/mass.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 20/11/2024, 22:21. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 20/11/2024, 22:32. Copyright (c) The Contributors from typing import Union, List @@ -21,7 +21,7 @@ class NFW(BaseModel1D): :param Unit/str x_unit: The unit of the x-axis of this model, kpc for instance. May be passed as a string representation or an astropy unit object. - :param Unit/str y_unit: The unit of the output of this model, keV for instance. May be passed as a string + :param Unit/str y_unit: The unit of the output of this model, Msun for instance. May be passed as a string representation or an astropy unit object. :param List[Quantity] cust_start_pars: The start values of the model parameters for any fitting function that used start values. The units are checked against default start values. diff --git a/xga/products/base.py b/xga/products/base.py index c15487f0..397052a2 100644 --- a/xga/products/base.py +++ b/xga/products/base.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 20/11/2024, 22:20. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 20/11/2024, 22:32. Copyright (c) The Contributors import inspect import os @@ -826,11 +826,14 @@ def find_to_replace(start_pos: np.ndarray, par_lims: np.ndarray) -> np.ndarray: # I'm just defining these here so that the lines don't get too long for PEP standards y_data = (self.values.copy() - self._background).to(model(self.radii[0]).unit).value - y_errs = self.values_err.copy().value + y_errs = self.values_err.copy().to(model(self.radii[0]).unit).value rads = self.fit_radii.copy().value success = True warning_str = "" + print(y_data) + print(y_errs) + for prior in model.par_priors: if prior['type'] != 'uniform': raise NotImplementedError("Sorry but we don't yet support non-uniform priors for profile fitting!") From 1c5a0a17039e884f4f6cd56047e9b1085f6c80fc Mon Sep 17 00:00:00 2001 From: David Turner Date: Wed, 20 Nov 2024 22:38:49 -0500 Subject: [PATCH 39/85] still nope, think perhaps because the NLLS fit is doing the same sort of thing as emcee. For issue #1267 --- xga/products/base.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/xga/products/base.py b/xga/products/base.py index 397052a2..323e24b1 100644 --- a/xga/products/base.py +++ b/xga/products/base.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 20/11/2024, 22:32. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 20/11/2024, 22:38. Copyright (c) The Contributors import inspect import os @@ -831,8 +831,9 @@ def find_to_replace(start_pos: np.ndarray, par_lims: np.ndarray) -> np.ndarray: success = True warning_str = "" - print(y_data) - print(y_errs) + print(model(self.radii[0])) + print((self.values.copy() - self._background).to(model(self.radii[0]).unit)) + print(self.values_err.copy().to(model(self.radii[0]).unit)) for prior in model.par_priors: if prior['type'] != 'uniform': @@ -851,6 +852,7 @@ def find_to_replace(start_pos: np.ndarray, par_lims: np.ndarray) -> np.ndarray: curve_fit_model, success = self.nlls_fit(curve_fit_model, 10, show_warn=False) if success or curve_fit_model.fit_warning == "Very large parameter uncertainties": base_start_pars = np.array([p.value for p in curve_fit_model.model_pars]) + print(base_start_pars) else: # This finds maximum likelihood parameter values for the model+data max_like_res = minimize(lambda *args: -log_likelihood(*args, model.model), model.unitless_start_pars, From dd422ef8b525ade34e1ec46417726f0bf0d3bbd1 Mon Sep 17 00:00:00 2001 From: David Turner Date: Wed, 20 Nov 2024 22:53:24 -0500 Subject: [PATCH 40/85] Converting the data for regression in the emcee and nlls fit methods of BaseProfile1D to what I FINALLY HOPE is the right unit. For issue #1267 --- xga/products/base.py | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/xga/products/base.py b/xga/products/base.py index 323e24b1..d7fc2512 100644 --- a/xga/products/base.py +++ b/xga/products/base.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 20/11/2024, 22:38. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 20/11/2024, 22:53. Copyright (c) The Contributors import inspect import os @@ -824,17 +824,17 @@ def find_to_replace(start_pos: np.ndarray, par_lims: np.ndarray) -> np.ndarray: else: self._model_allegiance(model) + # Trying to read out the raw output unit of the model with current start parameters, rather than the + # final unit set by each model - this is to make sure we're doing regression on data of the right unit + raw_mod_unit = model.model(self.radii[0], model.start_pars).unit + # I'm just defining these here so that the lines don't get too long for PEP standards - y_data = (self.values.copy() - self._background).to(model(self.radii[0]).unit).value - y_errs = self.values_err.copy().to(model(self.radii[0]).unit).value + y_data = (self.values.copy() - self._background).to(raw_mod_unit).value + y_errs = self.values_err.copy().to(raw_mod_unit).value rads = self.fit_radii.copy().value success = True warning_str = "" - print(model(self.radii[0])) - print((self.values.copy() - self._background).to(model(self.radii[0]).unit)) - print(self.values_err.copy().to(model(self.radii[0]).unit)) - for prior in model.par_priors: if prior['type'] != 'uniform': raise NotImplementedError("Sorry but we don't yet support non-uniform priors for profile fitting!") @@ -852,7 +852,6 @@ def find_to_replace(start_pos: np.ndarray, par_lims: np.ndarray) -> np.ndarray: curve_fit_model, success = self.nlls_fit(curve_fit_model, 10, show_warn=False) if success or curve_fit_model.fit_warning == "Very large parameter uncertainties": base_start_pars = np.array([p.value for p in curve_fit_model.model_pars]) - print(base_start_pars) else: # This finds maximum likelihood parameter values for the model+data max_like_res = minimize(lambda *args: -log_likelihood(*args, model.model), model.unitless_start_pars, @@ -1031,8 +1030,12 @@ def nlls_fit(self, model: BaseModel1D, num_samples: int, show_warn: bool) -> Tup else: self._model_allegiance(model) - y_data = (self.values.copy() - self._background).value - y_errs = self.values_err.copy().value + # Trying to read out the raw output unit of the model with current start parameters, rather than the + # final unit set by each model - this is to make sure we're doing regression on data of the right unit + raw_mod_unit = model.model(self.radii[0], model.start_pars).unit + + y_data = (self.values.copy() - self._background).to(raw_mod_unit).value + y_errs = self.values_err.copy().to(raw_mod_unit).value rads = self.fit_radii.copy().value success = True warning_str = "" From 4b63a263daf2091a16a47c92362f6e1acf93bf7a Mon Sep 17 00:00:00 2001 From: David Turner Date: Wed, 20 Nov 2024 22:56:07 -0500 Subject: [PATCH 41/85] I swear I'm not doing this on purpose... I was passing the start parameters incorrectly to the model to get the raw output unit. (by the way, beforehand I was actually using the model self call method, which actually auto-converts the output to the set unit of the model, so wasn't helping us here). For issue #1267 --- xga/products/base.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/xga/products/base.py b/xga/products/base.py index d7fc2512..6a5c9a07 100644 --- a/xga/products/base.py +++ b/xga/products/base.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 20/11/2024, 22:53. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 20/11/2024, 22:56. Copyright (c) The Contributors import inspect import os @@ -826,7 +826,7 @@ def find_to_replace(start_pos: np.ndarray, par_lims: np.ndarray) -> np.ndarray: # Trying to read out the raw output unit of the model with current start parameters, rather than the # final unit set by each model - this is to make sure we're doing regression on data of the right unit - raw_mod_unit = model.model(self.radii[0], model.start_pars).unit + raw_mod_unit = model.model(self.radii[0], *model.start_pars).unit # I'm just defining these here so that the lines don't get too long for PEP standards y_data = (self.values.copy() - self._background).to(raw_mod_unit).value @@ -1032,7 +1032,7 @@ def nlls_fit(self, model: BaseModel1D, num_samples: int, show_warn: bool) -> Tup # Trying to read out the raw output unit of the model with current start parameters, rather than the # final unit set by each model - this is to make sure we're doing regression on data of the right unit - raw_mod_unit = model.model(self.radii[0], model.start_pars).unit + raw_mod_unit = model.model(self.radii[0], *model.start_pars).unit y_data = (self.values.copy() - self._background).to(raw_mod_unit).value y_errs = self.values_err.copy().to(raw_mod_unit).value From 5c8ee21986e432748da7f0c6e96beb0cbc68d918 Mon Sep 17 00:00:00 2001 From: David Turner Date: Wed, 20 Nov 2024 23:35:57 -0500 Subject: [PATCH 42/85] Make sure to convert the output from a model's get_realisations method to the specified y unit of the model. For issue #1267 --- xga/models/base.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/xga/models/base.py b/xga/models/base.py index 55fc3c48..34b20c53 100644 --- a/xga/models/base.py +++ b/xga/models/base.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 20/11/2024, 21:32. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 20/11/2024, 23:35. Copyright (c) The Contributors import inspect from abc import ABCMeta, abstractmethod @@ -222,7 +222,8 @@ def get_realisations(self, x: Quantity) -> Quantity: x = x.to(self._x_unit) if self._x_lims is not None and (np.any(x < self._x_lims[0]) or np.any(x > self._x_lims[1])): - warn("Some x values are outside of the x-axis limits for this model, results may not be trustworthy.") + warn("Some x values are outside of the x-axis limits for this model, results may not be trustworthy.", + stacklevel=2) if x.isscalar or (not x.isscalar and x.ndim == 1): realisations = self.model(x[..., None], *self._par_dists) @@ -234,7 +235,7 @@ def get_realisations(self, x: Quantity) -> Quantity: # statement realisations = self.model(x, *self._par_dists) - return realisations + return realisations.to(self._y_unit) @staticmethod @abstractmethod From 8b0cb40e30038163bd592d7704dc174c7efbf844 Mon Sep 17 00:00:00 2001 From: David Turner Date: Wed, 20 Nov 2024 23:59:15 -0500 Subject: [PATCH 43/85] Trying out setting negatives to NaN again --- xga/products/profile.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/xga/products/profile.py b/xga/products/profile.py index 2ca780a4..f52c7c86 100644 --- a/xga/products/profile.py +++ b/xga/products/profile.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 20/11/2024, 21:15. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 20/11/2024, 23:59. Copyright (c) The Contributors from copy import copy from typing import Tuple, Union, List @@ -2665,11 +2665,6 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, # And now we do the actual mass calculation if not already_run: - print(dens.shape) - print(dens_der.shape) - print(temp.shape) - print(temp_der.shape) - # Please note that this is just the vanilla hydrostatic mass equation, but not written in the "standard # form". Here there are no logs in the derivatives, because it's easier to take advantage of astropy's # quantities that way. @@ -2677,7 +2672,7 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, * ((dens * temp_der) + (temp * dens_der))) # TODO THIS IS A TEST OF A THOUGHT I HAD - MAYBE REMOVE? - # mass_dist[mass_dist < 0] = np.nan + mass_dist[mass_dist < 0] = np.nan # Just converts the mass/masses to the unit we normally use for them mass_dist = mass_dist.to('Msun').T From ae931963cf0dc2e184bbc29c9bb5c18746cb58de Mon Sep 17 00:00:00 2001 From: David Turner Date: Thu, 21 Nov 2024 00:04:55 -0500 Subject: [PATCH 44/85] nevermind - anyway just need to add the last derivatives of temperature and density. These are for the cases where there are more radii points for the mass profile than there are radii or density, and no interpolation. For issue #1260 --- xga/products/profile.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/xga/products/profile.py b/xga/products/profile.py index f52c7c86..f8caf84a 100644 --- a/xga/products/profile.py +++ b/xga/products/profile.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 20/11/2024, 23:59. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 21/11/2024, 00:04. Copyright (c) The Contributors from copy import copy from typing import Tuple, Union, List @@ -2671,10 +2671,7 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, mass_dist = (((-1 * k_B * np.power(radius[..., None], 2)) / (dens * (MEAN_MOL_WEIGHT * m_p) * G)) * ((dens * temp_der) + (temp * dens_der))) - # TODO THIS IS A TEST OF A THOUGHT I HAD - MAYBE REMOVE? - mass_dist[mass_dist < 0] = np.nan # Just converts the mass/masses to the unit we normally use for them - mass_dist = mass_dist.to('Msun').T # Storing the result if it is for a single radius From b9a2294a898617e5db5e37d096ba35a5d7512131 Mon Sep 17 00:00:00 2001 From: David Turner Date: Thu, 21 Nov 2024 09:48:19 -0500 Subject: [PATCH 45/85] Added first attempt at temp/dens gradient for temp/dens profiles with mismatched radii and interpolation turned off (in the new hydrostatic mass profile implementation). For issue #1260 --- xga/products/profile.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/xga/products/profile.py b/xga/products/profile.py index f8caf84a..450835cd 100644 --- a/xga/products/profile.py +++ b/xga/products/profile.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 21/11/2024, 00:04. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 21/11/2024, 09:48. Copyright (c) The Contributors from copy import copy from typing import Tuple, Union, List @@ -2601,6 +2601,8 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, dens_data_real = self.density_profile.generate_data_realisations(self._num_samples) dens = dens_data_real[:, d_inds].T + # Calculating density gradient # TODO DON'T KNOW IF THIS WILL WORK + dens_der = np.gradient(dens_data_real, self.radii, axis=0)[:, d_inds].T # Finally, whatever way we got the densities, we make sure they are in the right unit (also their 1st # derivatives). @@ -2655,6 +2657,9 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, temp_data_real = self.temperature_profile.generate_data_realisations(self._num_samples) temp = temp_data_real[:, t_inds].T + # Calculating temperature gradient # TODO DON'T KNOW IF THIS WILL WORK + temp_der = np.gradient(temp_data_real, self.radii, axis=0)[:, t_inds].T + # We ensure the temperatures are in the right unit - we want Kelvin for this, as compared to the entropy # profile where the 'custom' is to do it in keV From 49241bffe71fa273e53febd55c2384b901ecd5e5 Mon Sep 17 00:00:00 2001 From: David Turner Date: Thu, 21 Nov 2024 09:50:07 -0500 Subject: [PATCH 46/85] Removed leftover print statements --- xga/products/profile.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/xga/products/profile.py b/xga/products/profile.py index 450835cd..c69c3907 100644 --- a/xga/products/profile.py +++ b/xga/products/profile.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 21/11/2024, 09:48. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 21/11/2024, 09:50. Copyright (c) The Contributors from copy import copy from typing import Tuple, Union, List @@ -2557,8 +2557,6 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, # the definition of this source of the model) - the radii errors are included if supplied. dens = self._dens_model.get_realisations(calc_rad) dens_der = self._dens_model.derivative(calc_rad, dx, True) - print(dens_der.shape) - print(dens_der) # In this rare case the radii for the temperature and density profiles are identical, and so we just get # some realizations @@ -2566,7 +2564,6 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, (self.density_profile.radii == self.temperature_profile.radii).all()): dens = self.density_profile.generate_data_realisations(self._num_samples).T dens_der = np.gradient(dens, self.radii, axis=0) - print(dens_der) elif not already_run and self._interp_data: # This uses the density profile y-axis values (and their uncertainties) to draw N realizations of the @@ -2583,7 +2580,6 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, # Restore the interpolated density profile realizations to an astropy quantity array dens = Quantity(dens_interp(self.radii).T, self.density_profile.values_unit) dens_der = np.gradient(dens, self.radii, axis=0) - print(dens_der) # This particular combination means that we are doing a data-point based profile, but without interpolation, # and that the density profile has more bins than the temperature (going to be true in most cases). So we From 4d05376425208a85f224060e11dedd5575887f2c Mon Sep 17 00:00:00 2001 From: David Turner Date: Thu, 21 Nov 2024 09:57:52 -0500 Subject: [PATCH 47/85] Ridiculous amount of matrix transposing going on to make numerical derivates work on the non-interpolation case. For issue #1260 --- xga/products/profile.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/xga/products/profile.py b/xga/products/profile.py index c69c3907..83ba16ab 100644 --- a/xga/products/profile.py +++ b/xga/products/profile.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 21/11/2024, 09:50. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 21/11/2024, 09:57. Copyright (c) The Contributors from copy import copy from typing import Tuple, Union, List @@ -2598,7 +2598,7 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, dens_data_real = self.density_profile.generate_data_realisations(self._num_samples) dens = dens_data_real[:, d_inds].T # Calculating density gradient # TODO DON'T KNOW IF THIS WILL WORK - dens_der = np.gradient(dens_data_real, self.radii, axis=0)[:, d_inds].T + dens_der = np.gradient(dens_data_real.T, self.radii, axis=0).T[:, d_inds].T # Finally, whatever way we got the densities, we make sure they are in the right unit (also their 1st # derivatives). @@ -2654,7 +2654,7 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, temp_data_real = self.temperature_profile.generate_data_realisations(self._num_samples) temp = temp_data_real[:, t_inds].T # Calculating temperature gradient # TODO DON'T KNOW IF THIS WILL WORK - temp_der = np.gradient(temp_data_real, self.radii, axis=0)[:, t_inds].T + temp_der = np.gradient(temp_data_real.T, self.radii, axis=0).T[:, t_inds].T # We ensure the temperatures are in the right unit - we want Kelvin for this, as compared to the entropy From 10b1e5c06941864456e8d5e8afd59c8f68002fec Mon Sep 17 00:00:00 2001 From: David Turner Date: Thu, 21 Nov 2024 10:03:35 -0500 Subject: [PATCH 48/85] May have been using the wrong radii values for numerical derivative of temp and dens non-interpolation mode in the new hydro mass implementation. For issue #1260 --- xga/products/profile.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/xga/products/profile.py b/xga/products/profile.py index 83ba16ab..46126a9d 100644 --- a/xga/products/profile.py +++ b/xga/products/profile.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 21/11/2024, 09:57. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 21/11/2024, 10:03. Copyright (c) The Contributors from copy import copy from typing import Tuple, Union, List @@ -2598,7 +2598,7 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, dens_data_real = self.density_profile.generate_data_realisations(self._num_samples) dens = dens_data_real[:, d_inds].T # Calculating density gradient # TODO DON'T KNOW IF THIS WILL WORK - dens_der = np.gradient(dens_data_real.T, self.radii, axis=0).T[:, d_inds].T + dens_der = np.gradient(dens_data_real, self.density_profile.radii, axis=0)[:, d_inds].T # Finally, whatever way we got the densities, we make sure they are in the right unit (also their 1st # derivatives). @@ -2654,7 +2654,7 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, temp_data_real = self.temperature_profile.generate_data_realisations(self._num_samples) temp = temp_data_real[:, t_inds].T # Calculating temperature gradient # TODO DON'T KNOW IF THIS WILL WORK - temp_der = np.gradient(temp_data_real.T, self.radii, axis=0).T[:, t_inds].T + temp_der = np.gradient(temp_data_real, self.temperature_profile.radii, axis=0)[:, t_inds].T # We ensure the temperatures are in the right unit - we want Kelvin for this, as compared to the entropy From e6a7464a15e3bee9efa082ca551a55857fc4ae2f Mon Sep 17 00:00:00 2001 From: David Turner Date: Thu, 21 Nov 2024 10:05:53 -0500 Subject: [PATCH 49/85] Arrrrgh restoring the ridiculous transposing chain --- xga/products/profile.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/xga/products/profile.py b/xga/products/profile.py index 46126a9d..32251326 100644 --- a/xga/products/profile.py +++ b/xga/products/profile.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 21/11/2024, 10:03. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 21/11/2024, 10:05. Copyright (c) The Contributors from copy import copy from typing import Tuple, Union, List @@ -2598,7 +2598,7 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, dens_data_real = self.density_profile.generate_data_realisations(self._num_samples) dens = dens_data_real[:, d_inds].T # Calculating density gradient # TODO DON'T KNOW IF THIS WILL WORK - dens_der = np.gradient(dens_data_real, self.density_profile.radii, axis=0)[:, d_inds].T + dens_der = np.gradient(dens_data_real.T, self.density_profile.radii, axis=0).T[:, d_inds].T # Finally, whatever way we got the densities, we make sure they are in the right unit (also their 1st # derivatives). @@ -2654,7 +2654,7 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, temp_data_real = self.temperature_profile.generate_data_realisations(self._num_samples) temp = temp_data_real[:, t_inds].T # Calculating temperature gradient # TODO DON'T KNOW IF THIS WILL WORK - temp_der = np.gradient(temp_data_real, self.temperature_profile.radii, axis=0)[:, t_inds].T + temp_der = np.gradient(temp_data_real.T, self.temperature_profile.radii, axis=0).T[:, t_inds].T # We ensure the temperatures are in the right unit - we want Kelvin for this, as compared to the entropy From a78e3fefca7bc6a56efa8581a51a4a34c1605672 Mon Sep 17 00:00:00 2001 From: David Turner Date: Thu, 21 Nov 2024 10:25:07 -0500 Subject: [PATCH 50/85] I think the new hydrostatic mass profile 'mass()' method is now feature complete. For issue #1260 --- xga/products/profile.py | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/xga/products/profile.py b/xga/products/profile.py index 32251326..1e30ca32 100644 --- a/xga/products/profile.py +++ b/xga/products/profile.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 21/11/2024, 10:05. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 21/11/2024, 10:25. Copyright (c) The Contributors from copy import copy from typing import Tuple, Union, List @@ -2465,10 +2465,6 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, the mass realization distribution. :rtype: Union[Quantity, Quantity] """ - # TODO THE MAIN THING HERE THAT WILL BE DIFFERENT FROM THE REVAMPED ENTROPY CALCULATIONS IS THAT WE NEED TO - # CALCULATE DERIVATIVES - SHOULDN'T BE TOO MUCH OF A CHALLENGE AND WE SHOULD BE ABLE TO PRESERVE ALL THE - # OPTIONS THAT WE INCLUDED FOR ENTROPY (INTERPOLATION, DATA DRIVEN, ETC.) - # Setting the upper and lower confidence limits upper = 50 + (conf_level / 2) lower = 50 - (conf_level / 2) @@ -2576,7 +2572,6 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, # We make sure to turn on extrapolation, and make sure this is no out-of-bounds error issued dens_interp = interp1d(self.density_profile.radii, dens_data_real, axis=1, assume_sorted=True, fill_value='extrapolate', bounds_error=False) - # TODO I don't know if I can include the radius distribution here, but if I can then I should # Restore the interpolated density profile realizations to an astropy quantity array dens = Quantity(dens_interp(self.radii).T, self.density_profile.values_unit) dens_der = np.gradient(dens, self.radii, axis=0) @@ -2589,7 +2584,6 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, dens_der = np.gradient(dens, self.radii, axis=0) else: - # TODO NO DERIVATIVE HERE YET!!! d_bnds = np.vstack([self.density_profile.annulus_bounds[0:-1], self.density_profile.annulus_bounds[1:]]).T @@ -2597,7 +2591,7 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, dens_data_real = self.density_profile.generate_data_realisations(self._num_samples) dens = dens_data_real[:, d_inds].T - # Calculating density gradient # TODO DON'T KNOW IF THIS WILL WORK + # Calculating density gradient - there are a ridiculous number of transposes here I know, but oh well dens_der = np.gradient(dens_data_real.T, self.density_profile.radii, axis=0).T[:, d_inds].T # Finally, whatever way we got the densities, we make sure they are in the right unit (also their 1st @@ -2645,7 +2639,6 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, # temperature value - in practise this means that each density will be paired with the temperature # realizations whose radial coverage they fall within. else: - # TODO NO TEMP DERIVATIVE HERE YET!!! t_bnds = np.vstack([self.temperature_profile.annulus_bounds[0:-1], self.temperature_profile.annulus_bounds[1:]]).T @@ -2653,10 +2646,9 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, temp_data_real = self.temperature_profile.generate_data_realisations(self._num_samples) temp = temp_data_real[:, t_inds].T - # Calculating temperature gradient # TODO DON'T KNOW IF THIS WILL WORK + # Calculating temperature gradient - there are a ridiculous number of transposes here I know, but oh well temp_der = np.gradient(temp_data_real.T, self.temperature_profile.radii, axis=0).T[:, t_inds].T - # We ensure the temperatures are in the right unit - we want Kelvin for this, as compared to the entropy # profile where the 'custom' is to do it in keV if not already_run and temp.unit.is_equivalent('keV'): From 89394c72dba359536958437a5c79fce68af713da Mon Sep 17 00:00:00 2001 From: David Turner Date: Thu, 21 Nov 2024 10:31:59 -0500 Subject: [PATCH 51/85] Hopefully made the getdist corner plots in the BaseProfile method include the unit in the labels. For issue #1260 --- xga/products/base.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/xga/products/base.py b/xga/products/base.py index 6a5c9a07..c66be3e6 100644 --- a/xga/products/base.py +++ b/xga/products/base.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 20/11/2024, 22:56. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 21/11/2024, 10:31. Copyright (c) The Contributors import inspect import os @@ -1467,8 +1467,13 @@ def view_getdist_corner(self, model: str, settings: dict = {}, figsize: tuple = flat_chains = self.get_chains(model, flatten=True) model_obj = self.get_model_fit(model, 'mcmc') + # Setting up parameter label name and unit pairs - will strip them of '$' in the next line - didn't do it + # here to make it a little easier to read + labels = [[par_name, model_obj.par_units[par_ind].to_string('latex')] for par_ind, par_name + in enumerate(model_obj.par_publication_names)] + # Need to remove $ from the labels because getdist adds them itself - stripped_labels = [n.replace('$', '') for n in model_obj.par_publication_names] + stripped_labels = [(lab_pair[0] + " " + lab_pair[1]).replace('$', '') for lab_pair in labels] # Setup the getdist sample object gd_samp = MCSamples(samples=flat_chains, names=model_obj.par_names, labels=stripped_labels, From 477eb715bd3a8bbbac01706fed33afb6b7430d04 Mon Sep 17 00:00:00 2001 From: David Turner Date: Thu, 21 Nov 2024 10:34:39 -0500 Subject: [PATCH 52/85] Making getdist axis labels in BaseProfile include the nicely rendered unit in a square bracket. For issue #1260 indirectly. --- xga/products/base.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/xga/products/base.py b/xga/products/base.py index c66be3e6..5d665985 100644 --- a/xga/products/base.py +++ b/xga/products/base.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 21/11/2024, 10:31. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 21/11/2024, 10:34. Copyright (c) The Contributors import inspect import os @@ -1473,7 +1473,7 @@ def view_getdist_corner(self, model: str, settings: dict = {}, figsize: tuple = in enumerate(model_obj.par_publication_names)] # Need to remove $ from the labels because getdist adds them itself - stripped_labels = [(lab_pair[0] + " " + lab_pair[1]).replace('$', '') for lab_pair in labels] + stripped_labels = [(lab_pair[0] + "\: [" + lab_pair[1] + ']').replace('$', '') for lab_pair in labels] # Setup the getdist sample object gd_samp = MCSamples(samples=flat_chains, names=model_obj.par_names, labels=stripped_labels, From 818edd4ea96fc61a9d3b4061101d94682aa6d78d Mon Sep 17 00:00:00 2001 From: David Turner Date: Thu, 21 Nov 2024 10:37:30 -0500 Subject: [PATCH 53/85] Now making the getdist unit brackets be adaptable height. --- xga/products/base.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/xga/products/base.py b/xga/products/base.py index 5d665985..3692324a 100644 --- a/xga/products/base.py +++ b/xga/products/base.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 21/11/2024, 10:34. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 21/11/2024, 10:37. Copyright (c) The Contributors import inspect import os @@ -1473,7 +1473,8 @@ def view_getdist_corner(self, model: str, settings: dict = {}, figsize: tuple = in enumerate(model_obj.par_publication_names)] # Need to remove $ from the labels because getdist adds them itself - stripped_labels = [(lab_pair[0] + "\: [" + lab_pair[1] + ']').replace('$', '') for lab_pair in labels] + stripped_labels = [(lab_pair[0] + r"\: \left[" + lab_pair[1] + r'\right]').replace('$', '') + for lab_pair in labels] # Setup the getdist sample object gd_samp = MCSamples(samples=flat_chains, names=model_obj.par_names, labels=stripped_labels, From 160dab2d1f0eb6c49768a128fbfc33e4828378ce Mon Sep 17 00:00:00 2001 From: David Turner Date: Thu, 21 Nov 2024 10:57:25 -0500 Subject: [PATCH 54/85] Updated the look of the quick 'view_BLAH_distribution' methods of the profile classes. --- xga/products/profile.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/xga/products/profile.py b/xga/products/profile.py index 1e30ca32..7a60b1f5 100644 --- a/xga/products/profile.py +++ b/xga/products/profile.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 21/11/2024, 10:25. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 21/11/2024, 10:57. Copyright (c) The Contributors from copy import copy from typing import Tuple, Union, List @@ -700,7 +700,7 @@ def generation_profile(self) -> BaseProfile1D: return self._gen_prof def view_gas_mass_dist(self, model: str, outer_rad: Quantity, conf_level: float = 68.2, figsize=(8, 8), - bins: Union[str, int] = 'auto', colour: str = "lightslategrey", fit_method: str = 'mcmc'): + bins: Union[str, int] = 'auto', colour: str = "lightseagreen", fit_method: str = 'mcmc'): """ A method which will generate a histogram of the gas mass distribution that resulted from the gas mass calculation at the supplied radius. If the mass for the passed radius has already been measured it, and the @@ -728,7 +728,7 @@ def view_gas_mass_dist(self, model: str, outer_rad: Quantity, conf_level: float ax.yaxis.set_ticklabels([]) plt.hist(gas_mass_dist.value, bins=bins, color=colour, alpha=0.7, density=False) - plt.xlabel(r"Gas Mass [M$_{\odot}$]") + plt.xlabel(r"Gas Mass \left[M$_{\odot}\right]$", fontsize=14) plt.title("Gas Mass Distribution at {}".format(outer_rad.to_string())) mass_label = gas_mass.to("10^13Msun") @@ -2729,7 +2729,7 @@ def annular_mass(self, outer_radius: Quantity, inner_radius: Quantity, conf_leve return outer_mass_dist - inner_mass_dist def view_mass_dist(self, radius: Quantity, conf_level: float = 68.2, figsize: Tuple[float, float] = (8, 8), - bins: Union[str, int] = 'auto', colour: str = "lightslategrey"): + bins: Union[str, int] = 'auto', colour: str = "lightseagreen"): """ A method which will generate a histogram of the mass distribution that resulted from the mass calculation at the supplied radius. If the mass for the passed radius has already been measured it, and the mass @@ -2758,8 +2758,8 @@ def view_mass_dist(self, radius: Quantity, conf_level: float = 68.2, figsize: Tu ax.yaxis.set_ticklabels([]) # Plot the histogram and set up labels - plt.hist(hy_dist.value, bins=bins, color=colour, alpha=0.7, density=False) - plt.xlabel(self._y_axis_name + r" M$_{\odot}$") + plt.hist(hy_dist.value, bins=bins, color=colour, alpha=0.7, density=False, histtype='step') + plt.xlabel(self._y_axis_name + r" \left[M$_{\odot}\right]$", fontsize=14) plt.title("Mass Distribution at {}".format(radius.to_string())) lab_hy_mass = hy_mass.to("10^14Msun") @@ -2817,7 +2817,7 @@ def baryon_fraction(self, radius: Quantity, conf_level: float = 68.2) -> Tuple[Q def view_baryon_fraction_dist(self, radius: Quantity, conf_level: float = 68.2, figsize: Tuple[float, float] = (8, 8), bins: Union[str, int] = 'auto', - colour: str = "lightslategrey"): + colour: str = "lightseagreen"): """ A method which will generate a histogram of the baryon fraction distribution that resulted from the mass calculation at the supplied radius. If the baryon fraction for the passed radius has already been @@ -2843,7 +2843,7 @@ def view_baryon_fraction_dist(self, radius: Quantity, conf_level: float = 68.2, ax.yaxis.set_ticklabels([]) plt.hist(bar_frac_dist.value, bins=bins, color=colour, alpha=0.7) - plt.xlabel("Baryon Fraction") + plt.xlabel("Baryon Fraction", fontsize=14) plt.title("Baryon Fraction Distribution at {}".format(radius.to_string())) vals_label = str(bar_frac[0].round(2).value) + "^{+" + str(bar_frac[2].round(2).value) + "}" + \ From 189b2ddfb79315259c60eb79247d3d04f93f5f39 Mon Sep 17 00:00:00 2001 From: David Turner Date: Thu, 21 Nov 2024 10:57:54 -0500 Subject: [PATCH 55/85] Updated the look of the quick 'view_BLAH_distribution' methods of the profile classes. --- xga/products/profile.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/xga/products/profile.py b/xga/products/profile.py index 7a60b1f5..bf5c27e5 100644 --- a/xga/products/profile.py +++ b/xga/products/profile.py @@ -2758,7 +2758,7 @@ def view_mass_dist(self, radius: Quantity, conf_level: float = 68.2, figsize: Tu ax.yaxis.set_ticklabels([]) # Plot the histogram and set up labels - plt.hist(hy_dist.value, bins=bins, color=colour, alpha=0.7, density=False, histtype='step') + plt.hist(hy_dist.value, bins=bins, color=colour, alpha=0.7, density=False) plt.xlabel(self._y_axis_name + r" \left[M$_{\odot}\right]$", fontsize=14) plt.title("Mass Distribution at {}".format(radius.to_string())) From de4c655128b6f4634cb51e37d193c8a3f32c4201 Mon Sep 17 00:00:00 2001 From: David Turner Date: Thu, 21 Nov 2024 10:58:52 -0500 Subject: [PATCH 56/85] Updated the look of the 'par_dist_view' method of BaseModel. --- xga/models/base.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/xga/models/base.py b/xga/models/base.py index 34b20c53..57075b51 100644 --- a/xga/models/base.py +++ b/xga/models/base.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 20/11/2024, 23:35. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 21/11/2024, 10:58. Copyright (c) The Contributors import inspect from abc import ABCMeta, abstractmethod @@ -660,13 +660,13 @@ def predicted_dist_view(self, radius: Quantity, bins: Union[str, int] = 'auto', else: warn("You have not added parameter distributions to this model") - def par_dist_view(self, bins: Union[str, int] = 'auto', colour: str = "lightslategrey"): + def par_dist_view(self, bins: Union[str, int] = 'auto', colour: str = "lightseagreen"): """ Very simple method that allows you to view the parameter distributions that have been added to this model. The model parameter and uncertainties are indicated with red lines, highlighting the value and enclosing the 1sigma confidence region. - :param Union[str, int] bins: Equivelant to the plt.hist bins argument, set either the number of bins + :param Union[str, int] bins: Equivalent to the plt.hist bins argument, set either the number of bins or the algorithm to decide on the number of bins. :param str colour: Set the colour of the histogram. """ @@ -700,7 +700,7 @@ def par_dist_view(self, bins: Union[str, int] = 'auto', colour: str = "lightslat else: par_unit_name = r" $\left[" + cur_unit.to_string("latex").strip("$") + r"\right]$" - ax.set_xlabel(self.par_publication_names[ax_ind] + par_unit_name) + ax.set_xlabel(self.par_publication_names[ax_ind] + par_unit_name, fontsize=14) # And show the plot plt.tight_layout() From a409f659aae744a2cd68598ae0bdc56462bc027e Mon Sep 17 00:00:00 2001 From: David Turner Date: Thu, 21 Nov 2024 10:59:12 -0500 Subject: [PATCH 57/85] Another stacklevel 2 for a warning --- xga/models/base.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/xga/models/base.py b/xga/models/base.py index 57075b51..4838635e 100644 --- a/xga/models/base.py +++ b/xga/models/base.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 21/11/2024, 10:58. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 21/11/2024, 10:59. Copyright (c) The Contributors import inspect from abc import ABCMeta, abstractmethod @@ -706,7 +706,7 @@ def par_dist_view(self, bins: Union[str, int] = 'auto', colour: str = "lightseag plt.tight_layout() plt.show() else: - warn("You have not added parameter distributions to this model") + warn("You have not added parameter distributions to this model", stacklevel=2) def view(self, radii: Quantity = None, xscale: str = 'log', yscale: str = 'log', figsize: tuple = (8, 8), colour: str = "black"): From 00912d92ebf33f73da03989c4761c5267ee363de Mon Sep 17 00:00:00 2001 From: David Turner Date: Thu, 21 Nov 2024 11:11:42 -0500 Subject: [PATCH 58/85] Attempting an improvement to the par_dist_view method of BaseModel so that it provides a label of the value/error on each parameter. --- xga/models/base.py | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/xga/models/base.py b/xga/models/base.py index 4838635e..8bf84e24 100644 --- a/xga/models/base.py +++ b/xga/models/base.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 21/11/2024, 10:59. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 21/11/2024, 11:11. Copyright (c) The Contributors import inspect from abc import ABCMeta, abstractmethod @@ -680,27 +680,45 @@ def par_dist_view(self, bins: Union[str, int] = 'auto', colour: str = "lightseag for ax_ind, ax in enumerate(ax_arr): # Add histogram ax.hist(self.par_dists[ax_ind].value, bins=bins, color=colour) - # Add parameter value as a solid red line - ax.axvline(self.model_pars[ax_ind].value, color='red') + # Read out the errors err = self.model_par_errs[ax_ind] - # Depending how many entries there are per parameter in the error quantity depends how we plot them + + # Define the unit of this parameter + cur_unit = err.unit + + # Change how we plot depending on how many entries there are per parameter in the error quantity if err.isscalar: + # Set up the label that will accompany the vertical lines to indicate parameter value and error + vals_label = str(self.model_pars[ax_ind].round(2).value) + r"\pm" + str(err.round(2).value) + ax.axvline(self.model_pars[ax_ind].value - err.value, color='red', linestyle='dashed') ax.axvline(self.model_pars[ax_ind].value + err.value, color='red', linestyle='dashed') elif not err.isscalar and len(err) == 2: + # Set up the label that will accompany the vertical lines to indicate parameter value and error + vals_label = (str(self.model_pars[ax_ind].round(2).value) + "^{+" + str(err[2].round(2).value) + + "}_{-" + str(err[1].round(2).value) + "}") + ax.axvline(self.model_pars[ax_ind].value - err[0].value, color='red', linestyle='dashed') ax.axvline(self.model_pars[ax_ind].value + err[1].value, color='red', linestyle='dashed') else: raise ValueError("Parameter error has three elements in it!") - cur_unit = err.unit + # The full label for the vertical line that indicates the parameter value + res_label = (r"$" + self.par_publication_names[ax_ind].replace('$', '') + "= " + + vals_label + cur_unit.to_string("latex").strip("$") + '$') + + # Add parameter value as a solid red line + ax.axvline(self.model_pars[ax_ind].value, color='red', label=res_label) + if cur_unit == Unit(''): par_unit_name = "" else: par_unit_name = r" $\left[" + cur_unit.to_string("latex").strip("$") + r"\right]$" ax.set_xlabel(self.par_publication_names[ax_ind] + par_unit_name, fontsize=14) + ax.legend(loc='best') + # And show the plot plt.tight_layout() From c483a7a6e4e43226e9eff1d56ff48d139dbd37b4 Mon Sep 17 00:00:00 2001 From: David Turner Date: Thu, 21 Nov 2024 11:20:25 -0500 Subject: [PATCH 59/85] Messed up the new labels in par_dist_view for BaseModel. --- xga/models/base.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/xga/models/base.py b/xga/models/base.py index 8bf84e24..fb6aa25f 100644 --- a/xga/models/base.py +++ b/xga/models/base.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 21/11/2024, 11:11. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 21/11/2024, 11:20. Copyright (c) The Contributors import inspect from abc import ABCMeta, abstractmethod @@ -696,8 +696,8 @@ def par_dist_view(self, bins: Union[str, int] = 'auto', colour: str = "lightseag ax.axvline(self.model_pars[ax_ind].value + err.value, color='red', linestyle='dashed') elif not err.isscalar and len(err) == 2: # Set up the label that will accompany the vertical lines to indicate parameter value and error - vals_label = (str(self.model_pars[ax_ind].round(2).value) + "^{+" + str(err[2].round(2).value) + - "}_{-" + str(err[1].round(2).value) + "}") + vals_label = (str(self.model_pars[ax_ind].round(2).value) + "^{+" + str(err[1].round(2).value) + + "}_{-" + str(err[0].round(2).value) + "}") ax.axvline(self.model_pars[ax_ind].value - err[0].value, color='red', linestyle='dashed') ax.axvline(self.model_pars[ax_ind].value + err[1].value, color='red', linestyle='dashed') From 4d482315e67b06dff59681842742e9c6d8c01426 Mon Sep 17 00:00:00 2001 From: David Turner Date: Thu, 21 Nov 2024 11:47:41 -0500 Subject: [PATCH 60/85] Hopefully make big long numbers in the labels of the par_dist_view method be formatted more nicely. --- xga/models/base.py | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/xga/models/base.py b/xga/models/base.py index fb6aa25f..05f68b28 100644 --- a/xga/models/base.py +++ b/xga/models/base.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 21/11/2024, 11:20. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 21/11/2024, 11:47. Copyright (c) The Contributors import inspect from abc import ABCMeta, abstractmethod @@ -689,15 +689,33 @@ def par_dist_view(self, bins: Union[str, int] = 'auto', colour: str = "lightseag # Change how we plot depending on how many entries there are per parameter in the error quantity if err.isscalar: + # Change how we format the value label depending on how big the number is effectively + if (self.model_pars[ax_ind].value / 1000) > 1: + cur_v_str = "{:.2e}".format(self.model_pars[ax_ind].value) + cur_e_str = "{:.2e}".format(err.value) + else: + cur_v_str = str(self.model_pars[ax_ind].round(2).value) + cur_e_str = str(err.round(2).value) + # Set up the label that will accompany the vertical lines to indicate parameter value and error - vals_label = str(self.model_pars[ax_ind].round(2).value) + r"\pm" + str(err.round(2).value) + vals_label = cur_v_str + r"\pm" + cur_e_str ax.axvline(self.model_pars[ax_ind].value - err.value, color='red', linestyle='dashed') ax.axvline(self.model_pars[ax_ind].value + err.value, color='red', linestyle='dashed') elif not err.isscalar and len(err) == 2: + + # Change how we format the value label depending on how big the number is effectively + if (self.model_pars[ax_ind].value / 1000) > 1: + cur_v_str = "{:.2e}".format(self.model_pars[ax_ind].value) + cur_em_str = "{:.2e}".format(err[0].value) + cur_ep_str = "{:.2e}".format(err[1].value) + else: + cur_v_str = str(self.model_pars[ax_ind].round(2).value) + cur_em_str = str(err[0].round(2).value) + cur_ep_str = str(err[1].round(2).value) + # Set up the label that will accompany the vertical lines to indicate parameter value and error - vals_label = (str(self.model_pars[ax_ind].round(2).value) + "^{+" + str(err[1].round(2).value) + - "}_{-" + str(err[0].round(2).value) + "}") + vals_label = (cur_v_str + "^{+" + cur_ep_str + "}_{-" + cur_em_str + "}") ax.axvline(self.model_pars[ax_ind].value - err[0].value, color='red', linestyle='dashed') ax.axvline(self.model_pars[ax_ind].value + err[1].value, color='red', linestyle='dashed') From 8494c78bf003ac36ee651af98a1f595c475833d8 Mon Sep 17 00:00:00 2001 From: David Turner Date: Thu, 21 Nov 2024 11:58:10 -0500 Subject: [PATCH 61/85] Hopefully make big long numbers in the labels of the par_dist_view method be formatted EVEN more nicely. --- xga/models/base.py | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/xga/models/base.py b/xga/models/base.py index 05f68b28..75430819 100644 --- a/xga/models/base.py +++ b/xga/models/base.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 21/11/2024, 11:47. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 21/11/2024, 11:58. Copyright (c) The Contributors import inspect from abc import ABCMeta, abstractmethod @@ -691,8 +691,9 @@ def par_dist_view(self, bins: Union[str, int] = 'auto', colour: str = "lightseag if err.isscalar: # Change how we format the value label depending on how big the number is effectively if (self.model_pars[ax_ind].value / 1000) > 1: - cur_v_str = "{:.2e}".format(self.model_pars[ax_ind].value) - cur_e_str = "{:.2e}".format(err.value) + v_ord = len(str(self.model_pars[ax_ind].value).split('.')[0]) - 1 + cur_v_str = str((self.model_pars[ax_ind].value / (10**v_ord)).round(2)) + cur_e_str = str((err.value / (10**v_ord)).round(2)) + r"\times 10^{" + str(v_ord) + "}" else: cur_v_str = str(self.model_pars[ax_ind].round(2).value) cur_e_str = str(err.round(2).value) @@ -706,16 +707,20 @@ def par_dist_view(self, bins: Union[str, int] = 'auto', colour: str = "lightseag # Change how we format the value label depending on how big the number is effectively if (self.model_pars[ax_ind].value / 1000) > 1: - cur_v_str = "{:.2e}".format(self.model_pars[ax_ind].value) - cur_em_str = "{:.2e}".format(err[0].value) - cur_ep_str = "{:.2e}".format(err[1].value) + v_ord = len(str(self.model_pars[ax_ind].value).split('.')[0]) - 1 + + cur_v_str = str((self.model_pars[ax_ind].value / (10 ** v_ord)).round(2)) + cur_em_str = str((err[0].value / (10 ** v_ord)).round(2)) + cur_ep_str = str((err[1].value / (10 ** v_ord)).round(2)) + # Set up the label that will accompany the vertical lines to indicate parameter value and error + vals_label = (cur_v_str + "^{+" + cur_ep_str + "}_{-" + cur_em_str + "} " + + r"\times 10^{" + str(v_ord) + "}") else: cur_v_str = str(self.model_pars[ax_ind].round(2).value) cur_em_str = str(err[0].round(2).value) cur_ep_str = str(err[1].round(2).value) - - # Set up the label that will accompany the vertical lines to indicate parameter value and error - vals_label = (cur_v_str + "^{+" + cur_ep_str + "}_{-" + cur_em_str + "}") + # Set up the label that will accompany the vertical lines to indicate parameter value and error + vals_label = (cur_v_str + "^{+" + cur_ep_str + "}_{-" + cur_em_str + "}") ax.axvline(self.model_pars[ax_ind].value - err[0].value, color='red', linestyle='dashed') ax.axvline(self.model_pars[ax_ind].value + err[1].value, color='red', linestyle='dashed') From 34a56f9ea719c1fa702b3b64953e9c73bb778fa9 Mon Sep 17 00:00:00 2001 From: David Turner Date: Thu, 21 Nov 2024 12:10:27 -0500 Subject: [PATCH 62/85] Corrected a label latex error I added in the view_mass_dist method of the new hydro mass profile class. For issue #1260 --- xga/products/profile.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/xga/products/profile.py b/xga/products/profile.py index bf5c27e5..35915df9 100644 --- a/xga/products/profile.py +++ b/xga/products/profile.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 21/11/2024, 10:57. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 21/11/2024, 12:10. Copyright (c) The Contributors from copy import copy from typing import Tuple, Union, List @@ -2759,7 +2759,7 @@ def view_mass_dist(self, radius: Quantity, conf_level: float = 68.2, figsize: Tu # Plot the histogram and set up labels plt.hist(hy_dist.value, bins=bins, color=colour, alpha=0.7, density=False) - plt.xlabel(self._y_axis_name + r" \left[M$_{\odot}\right]$", fontsize=14) + plt.xlabel(self._y_axis_name + r" $\left[\rm{M}_{\odot}\right]$", fontsize=14) plt.title("Mass Distribution at {}".format(radius.to_string())) lab_hy_mass = hy_mass.to("10^14Msun") From 268b031bd23a49779fba35972fedb7fdcb003134 Mon Sep 17 00:00:00 2001 From: David Turner Date: Thu, 21 Nov 2024 12:31:02 -0500 Subject: [PATCH 63/85] Working to make the new hydro mass profile class 'mass' method actually measure masses at custom radii properly. For issue #1260 --- xga/products/profile.py | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/xga/products/profile.py b/xga/products/profile.py index 35915df9..2abb7ad5 100644 --- a/xga/products/profile.py +++ b/xga/products/profile.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 21/11/2024, 12:10. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 21/11/2024, 12:31. Copyright (c) The Contributors from copy import copy from typing import Tuple, Union, List @@ -2469,9 +2469,21 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, upper = 50 + (conf_level / 2) lower = 50 - (conf_level / 2) - # Prints a warning if the radius at which to calculate the entropy is outside the range of the data + # Prints a warning if the radius at which to calculate the mass is outside the range of the data self.rad_check(radius) + # This is quite a specific check - different ways of calculating mass points have been now been + # included (other than using the smooth temperature and density models), and we will have to stop + # the profile making mass predictions (in this method) for single (user input most likely) radius + # values for one of the modes. + # If we're using data points, and interpolation is TURNED OFF, then we can't in good conscience try + # to predict a mass for a generic radius that most likely will not match any of the data points we have. In + # that case we'll encourage them to fit a model and use that to predict the mass + if (radius.isscalar or len(radius) == 1) and self._temp_model is None and not self._interp_data: + raise ValueError("Cannot measure a mass distribution for a custom radius when the hydrostatic mass " + "profile is set to use non-interpolated temperature and density data points - instead " + "please fit a mass model and use that to predict a mass.") + # We need check that, if the user has passed uncertainty information on radii, it is how we expect it to be. # First off, are there the right number of entries? if not radius.isscalar and radius_err is not None and (radius_err.isscalar or len(radius) != len(radius_err)): @@ -2540,6 +2552,8 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, else: calc_rad = radius + print(calc_rad) + # Here, if we haven't already identified a previously calculated hydrostatic mass for the radius, we start to # prepare the data we need (i.e. temperature and density). This is complicated slightly by the different # ways of calculating the profile that we now support (using smooth models, using data points, using @@ -2765,7 +2779,7 @@ def view_mass_dist(self, radius: Quantity, conf_level: float = 68.2, figsize: Tu lab_hy_mass = hy_mass.to("10^14Msun") vals_label = str(lab_hy_mass[0].round(2).value) + "^{+" + str(lab_hy_mass[2].round(2).value) + "}" + \ "_{-" + str(lab_hy_mass[1].round(2).value) + "}" - res_label = r"$\rm{M_{hydro}} = " + vals_label + r"10^{14}M_{\odot}$" + res_label = r"$\rm{M_{hydro}} = " + vals_label + r"\times 10^{14}M_{\odot}$" # And this just plots the 'result' on the distribution as a series of vertical lines plt.axvline(hy_mass[0].value, color='red', label=res_label) From 59d171df91cf710189bcfe3f52318262eb1c18d2 Mon Sep 17 00:00:00 2001 From: David Turner Date: Thu, 21 Nov 2024 13:19:02 -0500 Subject: [PATCH 64/85] Maybe made sure that the mass() method of the new hydrostatic mass profile class can calculate distributions for a single radius input. For issue #1260 --- xga/products/profile.py | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/xga/products/profile.py b/xga/products/profile.py index 2abb7ad5..c8fd26b1 100644 --- a/xga/products/profile.py +++ b/xga/products/profile.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 21/11/2024, 12:31. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 21/11/2024, 13:19. Copyright (c) The Contributors from copy import copy from typing import Tuple, Union, List @@ -2483,6 +2483,12 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, raise ValueError("Cannot measure a mass distribution for a custom radius when the hydrostatic mass " "profile is set to use non-interpolated temperature and density data points - instead " "please fit a mass model and use that to predict a mass.") + # These will be useful further down, to help properly setup the if-elif-else statements that decide how + # exactly the temp/dens profile data are treated + elif radius.isscalar or len(radius) == 1: + one_rad = True + else: + one_rad = False # We need check that, if the user has passed uncertainty information on radii, it is how we expect it to be. # First off, are there the right number of entries? @@ -2552,8 +2558,6 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, else: calc_rad = radius - print(calc_rad) - # Here, if we haven't already identified a previously calculated hydrostatic mass for the radius, we start to # prepare the data we need (i.e. temperature and density). This is complicated slightly by the different # ways of calculating the profile that we now support (using smooth models, using data points, using @@ -2570,7 +2574,7 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, # In this rare case the radii for the temperature and density profiles are identical, and so we just get # some realizations - elif (not already_run and (len(self.density_profile) == len(self.temperature_profile)) and + elif (not already_run and not one_rad and (len(self.density_profile) == len(self.temperature_profile)) and (self.density_profile.radii == self.temperature_profile.radii).all()): dens = self.density_profile.generate_data_realisations(self._num_samples).T dens_der = np.gradient(dens, self.radii, axis=0) @@ -2587,8 +2591,8 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, dens_interp = interp1d(self.density_profile.radii, dens_data_real, axis=1, assume_sorted=True, fill_value='extrapolate', bounds_error=False) # Restore the interpolated density profile realizations to an astropy quantity array - dens = Quantity(dens_interp(self.radii).T, self.density_profile.values_unit) - dens_der = np.gradient(dens, self.radii, axis=0) + dens = Quantity(dens_interp(radius[..., None]).T, self.density_profile.values_unit) + dens_der = np.gradient(dens, radius[..., None], axis=0) # This particular combination means that we are doing a data-point based profile, but without interpolation, # and that the density profile has more bins than the temperature (going to be true in most cases). So we @@ -2629,7 +2633,7 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, temp_der = self._temp_model.derivative(calc_rad, dx, True) # In this rare case temperature and density profiles are identical, and so we just get some realizations - elif (not already_run and (len(self.density_profile) == len(self.temperature_profile)) and + elif (not already_run and not one_rad and (len(self.density_profile) == len(self.temperature_profile)) and (self.density_profile.radii == self.temperature_profile.radii).all()): temp = self.temperature_profile.generate_data_realisations(self._num_samples).T temp_der = np.gradient(temp, self.radii, axis=0) @@ -2640,8 +2644,8 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, temp_data_real = self.temperature_profile.generate_data_realisations(self._num_samples) temp_interp = interp1d(self.temperature_profile.radii, temp_data_real, axis=1, assume_sorted=True, fill_value='extrapolate', bounds_error=False) - temp = Quantity(temp_interp(self.radii).T, self.temperature_profile.values_unit) - temp_der = np.gradient(temp, self.radii, axis=0) + temp = Quantity(temp_interp(radius[..., None]).T, self.temperature_profile.values_unit) + temp_der = np.gradient(temp, radius[..., None], axis=0) # This particular combination means that we are doing a data-point based profile, but without interpolation, # and that the temperature profile has more bins than the density (not going to happen often) From 8d9f92e8f9e4315585fc6edcb8214317e2e8234c Mon Sep 17 00:00:00 2001 From: David Turner Date: Thu, 21 Nov 2024 13:27:46 -0500 Subject: [PATCH 65/85] Maybe made sure that the mass() method of the new hydrostatic mass profile class can calculate distributions for a single radius input. SECOND ATTEMPT. For issue #1260 --- xga/products/profile.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/xga/products/profile.py b/xga/products/profile.py index c8fd26b1..221a8eef 100644 --- a/xga/products/profile.py +++ b/xga/products/profile.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 21/11/2024, 13:19. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 21/11/2024, 13:27. Copyright (c) The Contributors from copy import copy from typing import Tuple, Union, List @@ -2558,6 +2558,11 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, else: calc_rad = radius + # This is ugly and inelegant, but want to make sure that the passed radius is an array (even just with + # length one) + if one_rad: + radius = radius.reshape(1,) + # Here, if we haven't already identified a previously calculated hydrostatic mass for the radius, we start to # prepare the data we need (i.e. temperature and density). This is complicated slightly by the different # ways of calculating the profile that we now support (using smooth models, using data points, using @@ -2591,8 +2596,8 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, dens_interp = interp1d(self.density_profile.radii, dens_data_real, axis=1, assume_sorted=True, fill_value='extrapolate', bounds_error=False) # Restore the interpolated density profile realizations to an astropy quantity array - dens = Quantity(dens_interp(radius[..., None]).T, self.density_profile.values_unit) - dens_der = np.gradient(dens, radius[..., None], axis=0) + dens = Quantity(dens_interp(radius).T, self.density_profile.values_unit) + dens_der = np.gradient(dens, radius, axis=0) # This particular combination means that we are doing a data-point based profile, but without interpolation, # and that the density profile has more bins than the temperature (going to be true in most cases). So we @@ -2644,8 +2649,8 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, temp_data_real = self.temperature_profile.generate_data_realisations(self._num_samples) temp_interp = interp1d(self.temperature_profile.radii, temp_data_real, axis=1, assume_sorted=True, fill_value='extrapolate', bounds_error=False) - temp = Quantity(temp_interp(radius[..., None]).T, self.temperature_profile.values_unit) - temp_der = np.gradient(temp, radius[..., None], axis=0) + temp = Quantity(temp_interp(radius).T, self.temperature_profile.values_unit) + temp_der = np.gradient(temp, radius, axis=0) # This particular combination means that we are doing a data-point based profile, but without interpolation, # and that the temperature profile has more bins than the density (not going to happen often) From 7562aeeadc2db6503cc6ba2f3bae1db66a9ec18f Mon Sep 17 00:00:00 2001 From: David Turner Date: Thu, 21 Nov 2024 13:52:41 -0500 Subject: [PATCH 66/85] Still messing around getting the custom radius interpolated-data-point-method mass measurement working. For issue #1260 --- xga/products/profile.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/xga/products/profile.py b/xga/products/profile.py index 221a8eef..d2644f4f 100644 --- a/xga/products/profile.py +++ b/xga/products/profile.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 21/11/2024, 13:27. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 21/11/2024, 13:52. Copyright (c) The Contributors from copy import copy from typing import Tuple, Union, List @@ -2597,7 +2597,11 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, fill_value='extrapolate', bounds_error=False) # Restore the interpolated density profile realizations to an astropy quantity array dens = Quantity(dens_interp(radius).T, self.density_profile.values_unit) - dens_der = np.gradient(dens, radius, axis=0) + dens_der_interp = interp1d(self.density_profile.radii, + np.gradient(dens_data_real, self.density_profile.radii, axis=0), axis=1, + assume_sorted=True, fill_value='extrapolate', bounds_error=False) + dens_der = Quantity(dens_der_interp(radius).T, + self.density_profile.values_unit/self.density_profile.radii_unit) # This particular combination means that we are doing a data-point based profile, but without interpolation, # and that the density profile has more bins than the temperature (going to be true in most cases). So we @@ -2650,7 +2654,12 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, temp_interp = interp1d(self.temperature_profile.radii, temp_data_real, axis=1, assume_sorted=True, fill_value='extrapolate', bounds_error=False) temp = Quantity(temp_interp(radius).T, self.temperature_profile.values_unit) - temp_der = np.gradient(temp, radius, axis=0) + + temp_der_interp = interp1d(self.temperature_profile.radii, + np.gradient(temp_data_real, self.temperature_profile.radii, axis=0), axis=1, + assume_sorted=True, fill_value='extrapolate', bounds_error=False) + temp_der = Quantity(temp_der_interp(radius).T, + self.temperature_profile.values_unit / self.temperature_profile.radii_unit) # This particular combination means that we are doing a data-point based profile, but without interpolation, # and that the temperature profile has more bins than the density (not going to happen often) @@ -2687,6 +2696,10 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, mass_dist = (((-1 * k_B * np.power(radius[..., None], 2)) / (dens * (MEAN_MOL_WEIGHT * m_p) * G)) * ((dens * temp_der) + (temp * dens_der))) + # Returning to the expected shape of array for single radii passed in + if one_rad: + mass_dist = mass_dist[0] + # Just converts the mass/masses to the unit we normally use for them mass_dist = mass_dist.to('Msun').T From 140371a40b459924c66aa37dbe0484948d09e8c8 Mon Sep 17 00:00:00 2001 From: David Turner Date: Thu, 21 Nov 2024 13:55:22 -0500 Subject: [PATCH 67/85] PLEASE FREE ME FROM THIS HELL - #1260 --- xga/products/profile.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/xga/products/profile.py b/xga/products/profile.py index d2644f4f..c30dcc2a 100644 --- a/xga/products/profile.py +++ b/xga/products/profile.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 21/11/2024, 13:52. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 21/11/2024, 13:55. Copyright (c) The Contributors from copy import copy from typing import Tuple, Union, List @@ -2598,7 +2598,7 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, # Restore the interpolated density profile realizations to an astropy quantity array dens = Quantity(dens_interp(radius).T, self.density_profile.values_unit) dens_der_interp = interp1d(self.density_profile.radii, - np.gradient(dens_data_real, self.density_profile.radii, axis=0), axis=1, + np.gradient(dens_data_real.T, self.density_profile.radii, axis=0), axis=1, assume_sorted=True, fill_value='extrapolate', bounds_error=False) dens_der = Quantity(dens_der_interp(radius).T, self.density_profile.values_unit/self.density_profile.radii_unit) @@ -2656,7 +2656,7 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, temp = Quantity(temp_interp(radius).T, self.temperature_profile.values_unit) temp_der_interp = interp1d(self.temperature_profile.radii, - np.gradient(temp_data_real, self.temperature_profile.radii, axis=0), axis=1, + np.gradient(temp_data_real.T, self.temperature_profile.radii, axis=0), axis=1, assume_sorted=True, fill_value='extrapolate', bounds_error=False) temp_der = Quantity(temp_der_interp(radius).T, self.temperature_profile.values_unit / self.temperature_profile.radii_unit) From e0920614b207510b62a13b127e2e63d0a8bc5af0 Mon Sep 17 00:00:00 2001 From: David Turner Date: Thu, 21 Nov 2024 14:02:32 -0500 Subject: [PATCH 68/85] DANTE IS POINTING AND LAUGHING - #1260 --- xga/products/profile.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/xga/products/profile.py b/xga/products/profile.py index c30dcc2a..ca794efc 100644 --- a/xga/products/profile.py +++ b/xga/products/profile.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 21/11/2024, 13:55. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 21/11/2024, 14:02. Copyright (c) The Contributors from copy import copy from typing import Tuple, Union, List @@ -2655,8 +2655,10 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, fill_value='extrapolate', bounds_error=False) temp = Quantity(temp_interp(radius).T, self.temperature_profile.values_unit) + print(np.gradient(temp_data_real.T, self.temperature_profile.radii, axis=1).shape) + temp_der_interp = interp1d(self.temperature_profile.radii, - np.gradient(temp_data_real.T, self.temperature_profile.radii, axis=0), axis=1, + np.gradient(temp_data_real.T, self.temperature_profile.radii, axis=1), axis=1, assume_sorted=True, fill_value='extrapolate', bounds_error=False) temp_der = Quantity(temp_der_interp(radius).T, self.temperature_profile.values_unit / self.temperature_profile.radii_unit) From eabd87ced35c10a6a83e25780b6f9a5cb241eff1 Mon Sep 17 00:00:00 2001 From: David Turner Date: Thu, 21 Nov 2024 14:04:17 -0500 Subject: [PATCH 69/85] Transposing will save me --- xga/products/profile.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/xga/products/profile.py b/xga/products/profile.py index ca794efc..afed7975 100644 --- a/xga/products/profile.py +++ b/xga/products/profile.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 21/11/2024, 14:02. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 21/11/2024, 14:04. Copyright (c) The Contributors from copy import copy from typing import Tuple, Union, List @@ -2598,7 +2598,7 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, # Restore the interpolated density profile realizations to an astropy quantity array dens = Quantity(dens_interp(radius).T, self.density_profile.values_unit) dens_der_interp = interp1d(self.density_profile.radii, - np.gradient(dens_data_real.T, self.density_profile.radii, axis=0), axis=1, + np.gradient(dens_data_real.T, self.density_profile.radii, axis=1), axis=1, assume_sorted=True, fill_value='extrapolate', bounds_error=False) dens_der = Quantity(dens_der_interp(radius).T, self.density_profile.values_unit/self.density_profile.radii_unit) From 99b171e9a0926b46fdf86af12cd8221c7ae07ab9 Mon Sep 17 00:00:00 2001 From: David Turner Date: Thu, 21 Nov 2024 14:14:43 -0500 Subject: [PATCH 70/85] Transposing has not saved me --- xga/products/profile.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/xga/products/profile.py b/xga/products/profile.py index afed7975..f7b37e7e 100644 --- a/xga/products/profile.py +++ b/xga/products/profile.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 21/11/2024, 14:04. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 21/11/2024, 14:14. Copyright (c) The Contributors from copy import copy from typing import Tuple, Union, List @@ -2597,6 +2597,7 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, fill_value='extrapolate', bounds_error=False) # Restore the interpolated density profile realizations to an astropy quantity array dens = Quantity(dens_interp(radius).T, self.density_profile.values_unit) + print(np.gradient(dens_data_real.T, self.density_profile.radii, axis=1)) dens_der_interp = interp1d(self.density_profile.radii, np.gradient(dens_data_real.T, self.density_profile.radii, axis=1), axis=1, assume_sorted=True, fill_value='extrapolate', bounds_error=False) From cb1344284ab9bee68f5d8c476d8024ae1ad6b2c7 Mon Sep 17 00:00:00 2001 From: David Turner Date: Thu, 21 Nov 2024 14:16:07 -0500 Subject: [PATCH 71/85] Un-transposing might save me?! --- xga/products/profile.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/xga/products/profile.py b/xga/products/profile.py index f7b37e7e..20e77a6a 100644 --- a/xga/products/profile.py +++ b/xga/products/profile.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 21/11/2024, 14:14. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 21/11/2024, 14:16. Copyright (c) The Contributors from copy import copy from typing import Tuple, Union, List @@ -2597,7 +2597,8 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, fill_value='extrapolate', bounds_error=False) # Restore the interpolated density profile realizations to an astropy quantity array dens = Quantity(dens_interp(radius).T, self.density_profile.values_unit) - print(np.gradient(dens_data_real.T, self.density_profile.radii, axis=1)) + print(np.gradient(dens_data_real, self.density_profile.radii, axis=1)) + print(np.gradient(dens_data_real, self.density_profile.radii, axis=1).shape) dens_der_interp = interp1d(self.density_profile.radii, np.gradient(dens_data_real.T, self.density_profile.radii, axis=1), axis=1, assume_sorted=True, fill_value='extrapolate', bounds_error=False) From fea3855dd2c9203b43ed6b8f839477745cc5676c Mon Sep 17 00:00:00 2001 From: David Turner Date: Thu, 21 Nov 2024 14:21:43 -0500 Subject: [PATCH 72/85] Un-transposing might save me?! --- xga/products/profile.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/xga/products/profile.py b/xga/products/profile.py index 20e77a6a..02dadaa8 100644 --- a/xga/products/profile.py +++ b/xga/products/profile.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 21/11/2024, 14:16. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 21/11/2024, 14:21. Copyright (c) The Contributors from copy import copy from typing import Tuple, Union, List @@ -2597,10 +2597,8 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, fill_value='extrapolate', bounds_error=False) # Restore the interpolated density profile realizations to an astropy quantity array dens = Quantity(dens_interp(radius).T, self.density_profile.values_unit) - print(np.gradient(dens_data_real, self.density_profile.radii, axis=1)) - print(np.gradient(dens_data_real, self.density_profile.radii, axis=1).shape) dens_der_interp = interp1d(self.density_profile.radii, - np.gradient(dens_data_real.T, self.density_profile.radii, axis=1), axis=1, + np.gradient(dens_data_real, self.density_profile.radii, axis=1).T, axis=1, assume_sorted=True, fill_value='extrapolate', bounds_error=False) dens_der = Quantity(dens_der_interp(radius).T, self.density_profile.values_unit/self.density_profile.radii_unit) @@ -2657,10 +2655,8 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, fill_value='extrapolate', bounds_error=False) temp = Quantity(temp_interp(radius).T, self.temperature_profile.values_unit) - print(np.gradient(temp_data_real.T, self.temperature_profile.radii, axis=1).shape) - temp_der_interp = interp1d(self.temperature_profile.radii, - np.gradient(temp_data_real.T, self.temperature_profile.radii, axis=1), axis=1, + np.gradient(temp_data_real, self.temperature_profile.radii, axis=1).T, axis=1, assume_sorted=True, fill_value='extrapolate', bounds_error=False) temp_der = Quantity(temp_der_interp(radius).T, self.temperature_profile.values_unit / self.temperature_profile.radii_unit) From 630db4daf8a09e60eed3c978a5706cfe0ab1a510 Mon Sep 17 00:00:00 2001 From: David Turner Date: Thu, 21 Nov 2024 15:01:12 -0500 Subject: [PATCH 73/85] Un-transposing might save me?! --- xga/products/profile.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/xga/products/profile.py b/xga/products/profile.py index 02dadaa8..7c3f8cbe 100644 --- a/xga/products/profile.py +++ b/xga/products/profile.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 21/11/2024, 14:21. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 21/11/2024, 15:01. Copyright (c) The Contributors from copy import copy from typing import Tuple, Union, List @@ -2597,8 +2597,12 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, fill_value='extrapolate', bounds_error=False) # Restore the interpolated density profile realizations to an astropy quantity array dens = Quantity(dens_interp(radius).T, self.density_profile.values_unit) + + print(np.gradient(dens_data_real, self.density_profile.radii, axis=1).T.shape) + print(self.density_profile.radii.shape) + dens_der_interp = interp1d(self.density_profile.radii, - np.gradient(dens_data_real, self.density_profile.radii, axis=1).T, axis=1, + np.gradient(dens_data_real, self.density_profile.radii, axis=1).T, axis=0, assume_sorted=True, fill_value='extrapolate', bounds_error=False) dens_der = Quantity(dens_der_interp(radius).T, self.density_profile.values_unit/self.density_profile.radii_unit) From 34db680738e2ed47c8060d56d4934888fe24464d Mon Sep 17 00:00:00 2001 From: David Turner Date: Thu, 21 Nov 2024 15:03:55 -0500 Subject: [PATCH 74/85] Un-transposing might save me?! --- xga/products/profile.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/xga/products/profile.py b/xga/products/profile.py index 7c3f8cbe..ee58496d 100644 --- a/xga/products/profile.py +++ b/xga/products/profile.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 21/11/2024, 15:01. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 21/11/2024, 15:03. Copyright (c) The Contributors from copy import copy from typing import Tuple, Union, List @@ -2598,9 +2598,6 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, # Restore the interpolated density profile realizations to an astropy quantity array dens = Quantity(dens_interp(radius).T, self.density_profile.values_unit) - print(np.gradient(dens_data_real, self.density_profile.radii, axis=1).T.shape) - print(self.density_profile.radii.shape) - dens_der_interp = interp1d(self.density_profile.radii, np.gradient(dens_data_real, self.density_profile.radii, axis=1).T, axis=0, assume_sorted=True, fill_value='extrapolate', bounds_error=False) @@ -2660,7 +2657,7 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, temp = Quantity(temp_interp(radius).T, self.temperature_profile.values_unit) temp_der_interp = interp1d(self.temperature_profile.radii, - np.gradient(temp_data_real, self.temperature_profile.radii, axis=1).T, axis=1, + np.gradient(temp_data_real, self.temperature_profile.radii, axis=1).T, axis=0, assume_sorted=True, fill_value='extrapolate', bounds_error=False) temp_der = Quantity(temp_der_interp(radius).T, self.temperature_profile.values_unit / self.temperature_profile.radii_unit) From aa5875cae05a8d0e6b50e4eabbdb3a46f5f23bf5 Mon Sep 17 00:00:00 2001 From: David Turner Date: Thu, 21 Nov 2024 15:05:59 -0500 Subject: [PATCH 75/85] Things seem to be working now - but the temp and dens der are the wrong way around axis wise --- xga/products/profile.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/xga/products/profile.py b/xga/products/profile.py index ee58496d..6c1fbf8f 100644 --- a/xga/products/profile.py +++ b/xga/products/profile.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 21/11/2024, 15:03. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 21/11/2024, 15:05. Copyright (c) The Contributors from copy import copy from typing import Tuple, Union, List @@ -2602,7 +2602,7 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, np.gradient(dens_data_real, self.density_profile.radii, axis=1).T, axis=0, assume_sorted=True, fill_value='extrapolate', bounds_error=False) dens_der = Quantity(dens_der_interp(radius).T, - self.density_profile.values_unit/self.density_profile.radii_unit) + self.density_profile.values_unit/self.density_profile.radii_unit).T # This particular combination means that we are doing a data-point based profile, but without interpolation, # and that the density profile has more bins than the temperature (going to be true in most cases). So we @@ -2660,7 +2660,7 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, np.gradient(temp_data_real, self.temperature_profile.radii, axis=1).T, axis=0, assume_sorted=True, fill_value='extrapolate', bounds_error=False) temp_der = Quantity(temp_der_interp(radius).T, - self.temperature_profile.values_unit / self.temperature_profile.radii_unit) + self.temperature_profile.values_unit / self.temperature_profile.radii_unit).T # This particular combination means that we are doing a data-point based profile, but without interpolation, # and that the temperature profile has more bins than the density (not going to happen often) From 03ca7049282de8219bdbb817d0d584fbd166665f Mon Sep 17 00:00:00 2001 From: David Turner Date: Thu, 21 Nov 2024 16:08:55 -0500 Subject: [PATCH 76/85] Added an error to rad_check so that no-one can try to extrapolate data-point-driven mass profiles. Indirectly for issue #1260 --- xga/products/profile.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/xga/products/profile.py b/xga/products/profile.py index 6c1fbf8f..38cacc0a 100644 --- a/xga/products/profile.py +++ b/xga/products/profile.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 21/11/2024, 15:05. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 21/11/2024, 16:08. Copyright (c) The Contributors from copy import copy from typing import Tuple, Union, List @@ -3255,7 +3255,8 @@ def density_model(self) -> BaseModel1D: def rad_check(self, rad: Quantity): """ Very simple method that prints a warning if the radius is outside the range of data covered by the - density or temperature profiles. + density or temperature profiles - will actually throw an error if the hydrostatic mass profile was set up + in a data-driven mode, because we aren't going to let anyone extrapolate the data points. :param Quantity rad: The radius to check. """ @@ -3265,8 +3266,18 @@ def rad_check(self, rad: Quantity): if (self._temp_prof.annulus_bounds is not None and (rad > self._temp_prof.annulus_bounds[-1]).any()) \ or (self._dens_prof.annulus_bounds is not None and (rad > self._dens_prof.annulus_bounds[-1]).any()): - warn("Some radii are outside the data range covered by the temperature or density profiles, as such " - "you will be extrapolating based on the model fits.", stacklevel=2) + + # If we're using smooth fitted models for temperature and density then this is allowable, but still + # frowned upon - however if we're in a data-driven mode then no way are we going to let anyone + # extrapolate. If they want that then they can fit a model to the mass profile and extrapolate that. + if self._temp_model is None: + raise ValueError("Some radii are outside the radius range covered by the temperature or density " + "profiles, and it is not possible to extrapolate when using a data-point driven " + "mass profile; please fit a mass model and extrapolate that, or set up a mass profile " + "that uses temperature and density model fits.") + else: + warn("Some radii are outside the radius range covered by the temperature or density profiles, as such " + "you will be extrapolating based on the model fits.", stacklevel=2) From 4324c7eba9b0bbc82a8b506ae686d347d32b904f Mon Sep 17 00:00:00 2001 From: David Turner Date: Thu, 21 Nov 2024 16:31:18 -0500 Subject: [PATCH 77/85] Added a not implemented error to the gas density profile mass calculator to say that we can't yet calculate mass without a fitted model (though will add that). For issue #1271 (and indirectly #1260) --- xga/products/profile.py | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/xga/products/profile.py b/xga/products/profile.py index 38cacc0a..95da31e2 100644 --- a/xga/products/profile.py +++ b/xga/products/profile.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 21/11/2024, 16:08. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 21/11/2024, 16:31. Copyright (c) The Contributors from copy import copy from typing import Tuple, Union, List @@ -524,6 +524,10 @@ def gas_mass(self, model: str, outer_rad: Quantity, inner_rad: Quantity = None, the entire mass distribution from the whole realisation. :rtype: Tuple[Quantity, Quantity] """ + if model is None: + raise NotImplementedError("Gas mass calculation without a fitted model is not yet implemented - see " + "issue #1271.") + # First of all we have to find the model that has been fit to this gas density profile. if model not in PROF_TYPE_MODELS[self._prof_type]: raise XGAInvalidModelError("{m} is not a valid model for a gas density profile".format(m=model)) @@ -2833,8 +2837,18 @@ def baryon_fraction(self, radius: Quantity, conf_level: float = 68.2) -> Tuple[Q # Grab out the hydrostatic mass distribution, and the gas mass distribution hy_mass, hy_mass_dist = self.mass(radius, conf_level) - gas_mass, gas_mass_dist = self._dens_prof.gas_mass(self._dens_model.name, radius, conf_level=conf_level, - fit_method=self._dens_model.fit_method) + + # With this new version of the hydrostatic mass profile, we don't have a guarantee that there is a smooth + # model fit to the density profile. In fact as in the data-driven mode we don't use smooth density models + # it wouldn't be fully correct to use a fitted model to calculate the gas mass in that scenario, so we + # have to make a distinction. + if self._dens_model is not None: + # The case where we have used a density profile model + gas_mass, gas_mass_dist = self._dens_prof.gas_mass(self._dens_model.name, radius, conf_level=conf_level, + fit_method=self._dens_model.fit_method) + else: + # The case where we are data-driven + gas_mass, gas_mass_dist = self._dens_prof.gas_mass(model=None, outer_rad=radius, conf_level=conf_level) # If the distributions don't have the same number of entries (though as far I can recall they always should), # then we just make sure we have two equal length distributions to divide From 5180a86585a2387dc82b75f7a1df574c7300d22f Mon Sep 17 00:00:00 2001 From: David Turner Date: Thu, 21 Nov 2024 16:47:25 -0500 Subject: [PATCH 78/85] Made sure the baryon_fraction method of new hydrostatic mass uses nanmedian etc. --- xga/products/profile.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/xga/products/profile.py b/xga/products/profile.py index 95da31e2..2442a4b9 100644 --- a/xga/products/profile.py +++ b/xga/products/profile.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 21/11/2024, 16:31. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 21/11/2024, 16:47. Copyright (c) The Contributors from copy import copy from typing import Tuple, Union, List @@ -2859,9 +2859,9 @@ def baryon_fraction(self, radius: Quantity, conf_level: float = 68.2) -> Tuple[Q else: bar_frac_dist = gas_mass_dist / hy_mass_dist - bfrac_med = np.percentile(bar_frac_dist, 50, axis=0) - bfrac_lower = bfrac_med - np.percentile(bar_frac_dist, lower, axis=0) - bfrac_upper = np.percentile(bar_frac_dist, upper, axis=0) - bfrac_med + bfrac_med = np.nanpercentile(bar_frac_dist, 50, axis=0) + bfrac_lower = bfrac_med - np.nanpercentile(bar_frac_dist, lower, axis=0) + bfrac_upper = np.nanpercentile(bar_frac_dist, upper, axis=0) - bfrac_med bar_frac_res = Quantity([bfrac_med.value, bfrac_lower.value, bfrac_upper.value]) return bar_frac_res, bar_frac_dist From 25f1574a7b833256bd40fa64c6f2cdcead72058a Mon Sep 17 00:00:00 2001 From: David Turner Date: Thu, 21 Nov 2024 17:03:19 -0500 Subject: [PATCH 79/85] Altering the baryon_fraction_profile method of new hydro mass so that it can take custom radii (though radii 'errs' aren't working yet). --- xga/products/profile.py | 32 ++++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/xga/products/profile.py b/xga/products/profile.py index 2442a4b9..4bcaa4fe 100644 --- a/xga/products/profile.py +++ b/xga/products/profile.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 21/11/2024, 16:47. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 21/11/2024, 17:03. Copyright (c) The Contributors from copy import copy from typing import Tuple, Union, List @@ -2909,18 +2909,34 @@ def view_baryon_fraction_dist(self, radius: Quantity, conf_level: float = 68.2, plt.tight_layout() plt.show() - def baryon_fraction_profile(self) -> BaryonFraction: + def baryon_fraction_profile(self, radii: Quantity = None, deg_radii: Quantity = None) -> BaryonFraction: """ - A method which uses the baryon_fraction method to construct a baryon fraction profile at the radii of - this HydrostaticMass profile. The uncertainties on the baryon fraction are calculated at the 1σ level. + A method which uses the baryon_fraction method to construct a baryon fraction profile - either at the radii + of this HydrostaticMass profile or at custom radii. The uncertainties on the baryon fraction are calculated + at the 1σ level. + :param Quantity radii: Custom radii to generate the points of the profile at, default is None in which case + the radii of this hydrostatic mass profile are used. + :param Quantity deg_radii: The equivalent values to 'radii', but in degrees. :return: An XGA BaryonFraction object. :rtype: BaryonFraction """ + # Check the input radii, if they have been passed (and are valid) we'll use them + if radii is None: + radii = self.radii + radii_err = self.radii_err + deg_radii = self.deg_radii + elif radii is not None and deg_radii is None: + raise ValueError("If the 'radii' argument is passed, then the 'deg_radii' argument must be populated " + "with equivalent values.") + else: + self.rad_check(radii) + radii_err = None + frac = [] frac_err = [] # Step through the radii of this profile - for rad in self.radii: + for rad in radii: # Grabs the baryon fraction for the current radius b_frac = self.baryon_fraction(rad)[0] @@ -2933,9 +2949,9 @@ def baryon_fraction_profile(self) -> BaryonFraction: frac = Quantity(frac, '') frac_err = Quantity(frac_err, '') - return BaryonFraction(self.radii, frac, self.centre, self.src_name, self.obs_id, self.instrument, - self.radii_err, frac_err, self.set_ident, self.associated_set_storage_key, - self.deg_radii, auto_save=self.auto_save) + return BaryonFraction(radii, frac, self.centre, self.src_name, self.obs_id, self.instrument, + radii_err, frac_err, self.set_ident, self.associated_set_storage_key, + deg_radii, auto_save=self.auto_save) def overdensity_radius(self, delta: int, redshift: float, cosmo, init_lo_rad: Quantity = Quantity(100, 'kpc'), init_hi_rad: Quantity = Quantity(3500, 'kpc'), init_step: Quantity = Quantity(100, 'kpc'), From 1c2a8c721e82bdbbd9087e2c2657a0648bb23a07 Mon Sep 17 00:00:00 2001 From: David Turner Date: Thu, 21 Nov 2024 21:49:25 -0500 Subject: [PATCH 80/85] Added a remove_model_fit method to BaseProfile1D. Indirectly for issue #1260, to let the new hydro mass profile refit a model if the number of samples doesn't match that specified in the init --- xga/products/base.py | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/xga/products/base.py b/xga/products/base.py index 3692324a..2a28b6fe 100644 --- a/xga/products/base.py +++ b/xga/products/base.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 21/11/2024, 10:37. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 21/11/2024, 21:49. Copyright (c) The Contributors import inspect import os @@ -1348,6 +1348,35 @@ def add_model_fit(self, model: BaseModel1D, method: str): # This method means that a change has happened to the model, so it should be re-saved self.save() + def remove_model_fit(self, model: Union[str, BaseModel1D], method: str): + """ + This will remove an existing model fit for a particular fit method. + + :param str/BaseModel1D model: The model fit to delete. + :param str method: The method used to fit the model. + """ + # Making sure we have a string model name + if isinstance(model, BaseModel1D): + model = model.name + + # Checking the input model is valid for this profile + if model not in PROF_TYPE_MODELS[self._prof_type]: + raise XGAInvalidModelError("{m} is not a valid model for a {p} " + "profile.".format(m=model, p=self._y_axis_name.lower())) + + # Checking that the method passed is valid + if method not in self._fit_methods: + allowed = ", ".join(self._fit_methods) + raise XGAFitError("{me} is not a valid fitting method, the following are allowed; " + "{a}".format(me=method, a=allowed)) + + if model not in self._good_model_fits[method]: + raise XGAInvalidModelError("{m} is valid for this profile, but cannot be removed as it has not been " + "fit.".format(m=model)) + else: + # Finally remove the model + del self._good_model_fits[method][model] + def get_sampler(self, model: str) -> em.EnsembleSampler: """ A get method meant to retrieve the MCMC ensemble sampler used to fit a particular From 7c55de2715381f12468a741512cd8c14e6b2c26a Mon Sep 17 00:00:00 2001 From: David Turner Date: Thu, 21 Nov 2024 21:54:12 -0500 Subject: [PATCH 81/85] Added a force refit option to the fit method of BaseProfile1D. Indirectly for issue #1260 --- xga/products/base.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/xga/products/base.py b/xga/products/base.py index 2a28b6fe..d8522a2a 100644 --- a/xga/products/base.py +++ b/xga/products/base.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 21/11/2024, 21:49. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 21/11/2024, 21:54. Copyright (c) The Contributors import inspect import os @@ -1141,7 +1141,7 @@ def _odr_fit(self, model: BaseModel1D, show_warn: bool): def fit(self, model: Union[str, BaseModel1D], method: str = "mcmc", num_samples: int = 10000, num_steps: int = 30000, num_walkers: int = 20, progress_bar: bool = True, - show_warn: bool = True) -> BaseModel1D: + show_warn: bool = True, force_refit: bool = False) -> BaseModel1D: """ Method to fit a model to this profile's data, then store the resulting model parameter results. Each profile can store one instance of a type of model per fit method. So for instance you could fit both @@ -1162,6 +1162,8 @@ def fit(self, model: Union[str, BaseModel1D], method: str = "mcmc", num_samples: :param bool progress_bar: Only applicable if using MCMC fitting, should a progress bar be shown. :param bool show_warn: Should warnings be printed out, otherwise they are just stored in the model instance (this also happens if show_warn is True). + :param bool force_refit: Controls whether the profile will re-run the fit of a model that already has a good + model fit stored. The default is False. :return: The fitted model object. The fitted model is also stored within the profile object. :rtype: BaseModel1D """ @@ -1203,7 +1205,7 @@ def fit(self, model: Union[str, BaseModel1D], method: str = "mcmc", num_samples: # Check whether a good fit result already exists for this model. We use the storage_key property that # XGA model objects generate from their name and their start parameters - if model.name in self._good_model_fits[method]: + if not force_refit and model.name in self._good_model_fits[method]: warn("{m} already has a successful fit result for this profile using {me}, with those start " "parameters".format(m=model.name, me=method), stacklevel=2) already_done = True From 1b6dcb77b1544a10825b91e8c6596a84495774da Mon Sep 17 00:00:00 2001 From: David Turner Date: Thu, 21 Nov 2024 21:59:47 -0500 Subject: [PATCH 82/85] Think that maybe models will be refit if mismatching number of samples is found when declaring a mass profile now. For issue #1260 --- xga/products/profile.py | 182 ++++++++++++++++++++++------------------ 1 file changed, 101 insertions(+), 81 deletions(-) diff --git a/xga/products/profile.py b/xga/products/profile.py index 4bcaa4fe..bd0aca16 100644 --- a/xga/products/profile.py +++ b/xga/products/profile.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 21/11/2024, 17:03. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 21/11/2024, 21:59. Copyright (c) The Contributors from copy import copy from typing import Tuple, Union, List @@ -47,6 +47,7 @@ class SurfaceBrightness1D(BaseProfile1D): :param bool auto_save: Whether the profile should automatically save itself to disk at any point. The default is False, but all profiles generated through XGA processes acting on XGA sources will auto-save. """ + def __init__(self, rt: RateMap, radii: Quantity, values: Quantity, centre: Quantity, pix_step: int, min_snr: float, outer_rad: Quantity, radii_err: Quantity = None, values_err: Quantity = None, background: Quantity = None, pixel_bins: np.ndarray = None, back_pixel_bin: np.ndarray = None, @@ -318,6 +319,7 @@ class GasMass1D(BaseProfile1D): :param bool auto_save: Whether the profile should automatically save itself to disk at any point. The default is False, but all profiles generated through XGA processes acting on XGA sources will auto-save. """ + def __init__(self, radii: Quantity, values: Quantity, centre: Quantity, source_name: str, obs_id: str, inst: str, dens_method: str, associated_prof, radii_err: Quantity = None, values_err: Quantity = None, deg_radii: Quantity = None, auto_save: bool = False): @@ -414,6 +416,7 @@ class GasDensity3D(BaseProfile1D): :param bool auto_save: Whether the profile should automatically save itself to disk at any point. The default is False, but all profiles generated through XGA processes acting on XGA sources will auto-save. """ + def __init__(self, radii: Quantity, values: Quantity, centre: Quantity, source_name: str, obs_id: str, inst: str, dens_method: str, associated_prof, radii_err: Quantity = None, values_err: Quantity = None, associated_set_id: int = None, set_storage_key: str = None, deg_radii: Quantity = None, @@ -655,7 +658,7 @@ def gas_mass(self, model: str, outer_rad: Quantity, inner_rad: Quantity = None, mass_dist = model_obj.volume_integral(outer_rad, inner_rad, use_par_dist=True) # Converts to an actual mass rather than a total number of particles if self._sub_type == 'num_dens': - mass_dist *= (MEAN_MOL_WEIGHT*m_p) + mass_dist *= (MEAN_MOL_WEIGHT * m_p) # Converts to solar masses and stores inside the current profile for future reference mass_dist = mass_dist.to('Msun') self._gas_masses[str(model_obj)][out_stor_key][inn_stor_key] = mass_dist @@ -671,9 +674,9 @@ def gas_mass(self, model: str, outer_rad: Quantity, inner_rad: Quantity = None, mass_dist = self._gas_masses[str(model_obj)][out_stor_key][inn_stor_key] med_mass = np.percentile(mass_dist, 50).value - upp_mass = np.percentile(mass_dist, 50 + (conf_level/2)).value - low_mass = np.percentile(mass_dist, 50 - (conf_level/2)).value - gas_mass = Quantity([med_mass, med_mass-low_mass, upp_mass-med_mass], mass_dist.unit) + upp_mass = np.percentile(mass_dist, 50 + (conf_level / 2)).value + low_mass = np.percentile(mass_dist, 50 - (conf_level / 2)).value + gas_mass = Quantity([med_mass, med_mass - low_mass, upp_mass - med_mass], mass_dist.unit) if np.any(gas_mass[0] < 0): raise ValueError("A gas mass of less than zero has been measured, which is not physical.") @@ -741,8 +744,8 @@ def view_gas_mass_dist(self, model: str, outer_rad: Quantity, conf_level: float res_label = r"$\rm{M_{gas}} = " + vals_label + r"10^{13}M_{\odot}$" plt.axvline(gas_mass[0].value, color='red', label=res_label) - plt.axvline(gas_mass[0].value-gas_mass[1].value, color='red', linestyle='dashed') - plt.axvline(gas_mass[0].value+gas_mass[2].value, color='red', linestyle='dashed') + plt.axvline(gas_mass[0].value - gas_mass[1].value, color='red', linestyle='dashed') + plt.axvline(gas_mass[0].value + gas_mass[2].value, color='red', linestyle='dashed') plt.legend(loc='best', prop={'size': 12}) plt.tight_layout() plt.show() @@ -814,6 +817,7 @@ class ProjectedGasTemperature1D(BaseProfile1D): :param bool auto_save: Whether the profile should automatically save itself to disk at any point. The default is False, but all profiles generated through XGA processes acting on XGA sources will auto-save. """ + def __init__(self, radii: Quantity, values: Quantity, centre: Quantity, source_name: str, obs_id: str, inst: str, radii_err: Quantity = None, values_err: Quantity = None, associated_set_id: int = None, set_storage_key: str = None, deg_radii: Quantity = None, auto_save: bool = False): @@ -856,7 +860,7 @@ def __init__(self, radii: Quantity, values: Quantity, centre: Quantity, source_n self._y_axis_name = "Projected Temperature" # This sets the profile to unusable if there is a problem with the data - if self._values_err is not None and np.any((self._values+self._values_err) > Quantity(30, 'keV')): + if self._values_err is not None and np.any((self._values + self._values_err) > Quantity(30, 'keV')): self._usable = False elif self._values_err is None and np.any(self._values > Quantity(30, 'keV')): self._usable = False @@ -890,6 +894,7 @@ class APECNormalisation1D(BaseProfile1D): :param bool auto_save: Whether the profile should automatically save itself to disk at any point. The default is False, but all profiles generated through XGA processes acting on XGA sources will auto-save. """ + def __init__(self, radii: Quantity, values: Quantity, centre: Quantity, source_name: str, obs_id: str, inst: str, radii_err: Quantity = None, values_err: Quantity = None, associated_set_id: int = None, set_storage_key: str = None, deg_radii: Quantity = None, auto_save: bool = False): @@ -1006,8 +1011,8 @@ def gas_density_profile(self, redshift: float, cosmo: Quantity, abund_table: str # Angular diameter distance is calculated using the cosmology which was associated with the cluster # at definition conv_factor = (4 * np.pi * (ang_dist * (1 + redshift)) ** 2) / (e_to_p_ratio * 10 ** -14) - num_gas_scale = (1+e_to_p_ratio) - conv_mass = MEAN_MOL_WEIGHT*m_p + num_gas_scale = (1 + e_to_p_ratio) + conv_mass = MEAN_MOL_WEIGHT * m_p # Generating random normalisation profile realisations from DATA norm_real = self.generate_data_realisations(num_real, truncate_zero=True) @@ -1032,7 +1037,7 @@ def gas_density_profile(self, redshift: float, cosmo: Quantity, abund_table: str med_dens = np.nanpercentile(gas_dens_reals, 50, axis=0) # Calculates the standard deviation of each data point, this is how we estimate the density errors - dens_sigma = np.nanstd(gas_dens_reals, axis=0)*sigma + dens_sigma = np.nanstd(gas_dens_reals, axis=0) * sigma # Set up the actual profile object and return it dens_prof = GasDensity3D(self.radii, med_dens, self.centre, self.src_name, self.obs_id, self.instrument, @@ -1067,7 +1072,7 @@ def emission_measure_profile(self, redshift: float, cosmo: Cosmology, abund_tabl em_meas_reals = norm_real * conv_factor # Calculates the standard deviation of each data point, this is how we estimate the density errors - em_meas_sigma = np.std(em_meas_reals, axis=0)*sigma + em_meas_sigma = np.std(em_meas_reals, axis=0) * sigma # Set up the actual profile object and return it em_meas_prof = EmissionMeasure1D(self.radii, em_meas, self.centre, self.src_name, self.obs_id, self.instrument, @@ -1098,6 +1103,7 @@ class EmissionMeasure1D(BaseProfile1D): :param bool auto_save: Whether the profile should automatically save itself to disk at any point. The default is False, but all profiles generated through XGA processes acting on XGA sources will auto-save. """ + def __init__(self, radii: Quantity, values: Quantity, centre: Quantity, source_name: str, obs_id: str, inst: str, radii_err: Quantity = None, values_err: Quantity = None, associated_set_id: int = None, set_storage_key: str = None, deg_radii: Quantity = None, auto_save: bool = False): @@ -1143,6 +1149,7 @@ class ProjectedGasMetallicity1D(BaseProfile1D): :param bool auto_save: Whether the profile should automatically save itself to disk at any point. The default is False, but all profiles generated through XGA processes acting on XGA sources will auto-save. """ + def __init__(self, radii: Quantity, values: Quantity, centre: Quantity, source_name: str, obs_id: str, inst: str, radii_err: Quantity = None, values_err: Quantity = None, associated_set_id: int = None, set_storage_key: str = None, deg_radii: Quantity = None, auto_save: bool = False): @@ -1208,8 +1215,9 @@ class GasTemperature3D(BaseProfile1D): units of degrees, or if no set_storage_key is passed. It should be a quantity containing the radii values converted to degrees, and allows this object to construct a predictable storage key. """ + def __init__(self, radii: Quantity, values: Quantity, centre: Quantity, source_name: str, obs_id: str, inst: str, - radii_err: Quantity = None, values_err: Quantity = None, associated_set_id: int = None, + radii_err: Quantity = None, values_err: Quantity = None, associated_set_id: int = None, set_storage_key: str = None, deg_radii: Quantity = None, auto_save: bool = False): """ The init of a subclass of BaseProfile1D which will hold a radial 3D temperature profile. @@ -1252,8 +1260,9 @@ class BaryonFraction(BaseProfile1D): units of degrees, or if no set_storage_key is passed. It should be a quantity containing the radii values converted to degrees, and allows this object to construct a predictable storage key. """ + def __init__(self, radii: Quantity, values: Quantity, centre: Quantity, source_name: str, obs_id: str, inst: str, - radii_err: Quantity = None, values_err: Quantity = None, associated_set_id: int = None, + radii_err: Quantity = None, values_err: Quantity = None, associated_set_id: int = None, set_storage_key: str = None, deg_radii: Quantity = None, auto_save: bool = False): """ The init of a subclass of BaseProfile1D which will hold a radial baryon fraction profile. @@ -1303,6 +1312,7 @@ class HydrostaticMass(BaseProfile1D): :param bool auto_save: Whether the profile should automatically save itself to disk at any point. The default is False, but all profiles generated through XGA processes acting on XGA sources will auto-save. """ + def __init__(self, temperature_profile: GasTemperature3D, temperature_model: Union[str, BaseModel1D], density_profile: GasDensity3D, density_model: Union[str, BaseModel1D], radii: Quantity, radii_err: Quantity, deg_radii: Quantity, fit_method: str = "mcmc", num_walkers: int = 20, @@ -1513,9 +1523,9 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, # a dx to assume, so I will set one equal to radius/1e+6 (or the max radius if non-scalar), should be # small enough. if radius.isscalar: - dx = radius/1e+6 + dx = radius / 1e+6 else: - dx = radius.max()/1e+6 + dx = radius.max() / 1e+6 # Declaring this allows us to randomly draw from Gaussians, if the user has given us radius error information rng = np.random.default_rng() @@ -1547,14 +1557,14 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, dens = self._dens_model.get_realisations(calc_rad) dens_der = self._dens_model.derivative(calc_rad, dx, True) else: - dens = self._dens_model.get_realisations(calc_rad) / (MEAN_MOL_WEIGHT*m_p) - dens_der = self._dens_model.derivative(calc_rad, dx, True) / (MEAN_MOL_WEIGHT*m_p) + dens = self._dens_model.get_realisations(calc_rad) / (MEAN_MOL_WEIGHT * m_p) + dens_der = self._dens_model.derivative(calc_rad, dx, True) / (MEAN_MOL_WEIGHT * m_p) # We do the same for the temperature vals, again need to check the units if self._temp_model.y_unit.is_equivalent("keV"): - temp = (self._temp_model.get_realisations(calc_rad)/k_B).to('K') - temp_der = self._temp_model.derivative(calc_rad, dx, True)/k_B - temp_der = temp_der.to(Unit('K')/self._temp_model.x_unit) + temp = (self._temp_model.get_realisations(calc_rad) / k_B).to('K') + temp_der = self._temp_model.derivative(calc_rad, dx, True) / k_B + temp_der = temp_der.to(Unit('K') / self._temp_model.x_unit) else: temp = self._temp_model.get_realisations(calc_rad).to('K') temp_der = self._temp_model.derivative(calc_rad, dx, True).to('K') @@ -1562,7 +1572,7 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, # Please note that this is just the vanilla hydrostatic mass equation, but not written in the "standard # form". Here there are no logs in the derivatives, because it's easier to take advantage of astropy's # quantities that way. - mass_dist = ((-1 * k_B * np.power(radius[..., None], 2)) / (dens * (MEAN_MOL_WEIGHT*m_p) * G)) * \ + mass_dist = ((-1 * k_B * np.power(radius[..., None], 2)) / (dens * (MEAN_MOL_WEIGHT * m_p) * G)) * \ ((dens * temp_der) + (temp * dens_der)) # Just converts the mass/masses to the unit we normally use for them @@ -1670,8 +1680,8 @@ def view_mass_dist(self, radius: Quantity, conf_level: float = 68.2, figsize=(8, # And this just plots the 'result' on the distribution as a series of vertical lines plt.axvline(hy_mass[0].value, color='red', label=res_label) - plt.axvline(hy_mass[0].value-hy_mass[1].value, color='red', linestyle='dashed') - plt.axvline(hy_mass[0].value+hy_mass[2].value, color='red', linestyle='dashed') + plt.axvline(hy_mass[0].value - hy_mass[1].value, color='red', linestyle='dashed') + plt.axvline(hy_mass[0].value + hy_mass[2].value, color='red', linestyle='dashed') plt.legend(loc='best', prop={'size': 12}) plt.tight_layout() plt.show() @@ -1747,12 +1757,12 @@ def view_baryon_fraction_dist(self, radius: Quantity, conf_level: float = 68.2, plt.title("Baryon Fraction Distribution at {}".format(radius.to_string())) vals_label = str(bar_frac[0].round(2).value) + "^{+" + str(bar_frac[2].round(2).value) + "}" + \ - "_{-" + str(bar_frac[1].round(2).value) + "}" + "_{-" + str(bar_frac[1].round(2).value) + "}" res_label = r"$\rm{f_{gas}} = " + vals_label + "$" plt.axvline(bar_frac[0].value, color='red', label=res_label) - plt.axvline(bar_frac[0].value-bar_frac[1].value, color='red', linestyle='dashed') - plt.axvline(bar_frac[0].value+bar_frac[2].value, color='red', linestyle='dashed') + plt.axvline(bar_frac[0].value - bar_frac[1].value, color='red', linestyle='dashed') + plt.axvline(bar_frac[0].value + bar_frac[2].value, color='red', linestyle='dashed') plt.legend(loc='best', prop={'size': 12}) plt.xlim(0) plt.tight_layout() @@ -1819,6 +1829,7 @@ def overdensity_radius(self, delta: int, redshift: float, cosmo, init_lo_rad: Qu :return: The calculated overdensity radius. :rtype: Quantity """ + def turning_point(brackets: Quantity, step_size: Quantity) -> Quantity: """ This is the meat of the overdensity_radius method. It goes looking for radii that bracket the @@ -1900,7 +1911,7 @@ def turning_point(brackets: Quantity, step_size: Quantity) -> Quantity: else: tight_bracket = wide_bracket - return ((tight_bracket[0]+tight_bracket[1])/2).to(out_unit) + return ((tight_bracket[0] + tight_bracket[1]) / 2).to(out_unit) def _diag_view_prep(self, src) -> Tuple[int, RateMap, SurfaceBrightness1D]: """ @@ -1997,16 +2008,17 @@ def _gen_diag_view(self, fig: Figure, src, num_plots: int, rt: RateMap, sb: Surf # These simply plot the mass, temperature, and density profiles with legends turned off, residuals turned # off, and no title - ax_arr[0+offset] = self.get_view(fig, ax_arr[0+offset], show_legend=False, custom_title='', - show_residual_ax=False)[0] - ax_arr[1+offset] = self.temperature_profile.get_view(fig, ax_arr[1+offset], show_legend=False, custom_title='', - show_residual_ax=False)[0] - ax_arr[2+offset] = self.density_profile.get_view(fig, ax_arr[2+offset], show_legend=False, custom_title='', - show_residual_ax=False)[0] + ax_arr[0 + offset] = self.get_view(fig, ax_arr[0 + offset], show_legend=False, custom_title='', + show_residual_ax=False)[0] + ax_arr[1 + offset] = \ + self.temperature_profile.get_view(fig, ax_arr[1 + offset], show_legend=False, custom_title='', + show_residual_ax=False)[0] + ax_arr[2 + offset] = self.density_profile.get_view(fig, ax_arr[2 + offset], show_legend=False, custom_title='', + show_residual_ax=False)[0] # Then if there is a surface brightness profile thats added too if sb is not None: - ax_arr[3+offset] = sb.get_view(fig, ax_arr[3+offset], show_legend=False, custom_title='', - show_residual_ax=False)[0] + ax_arr[3 + offset] = sb.get_view(fig, ax_arr[3 + offset], show_legend=False, custom_title='', + show_residual_ax=False)[0] return ax_arr @@ -2029,7 +2041,7 @@ def diagnostic_view(self, src=None, figsize: tuple = None): # Calculate a sensible figsize if the user didn't pass one if figsize is None: - figsize = (7.2*num_plots, 7) + figsize = (7.2 * num_plots, 7) # Set up the figure fig = plt.figure(figsize=figsize) @@ -2061,7 +2073,7 @@ def save_diagnostic_view(self, save_path: str, src=None, figsize: tuple = None): # Calculate a sensible figsize if the user didn't pass one if figsize is None: - figsize = (7.2*num_plots, 7) + figsize = (7.2 * num_plots, 7) # Set up the figure fig = plt.figure(figsize=figsize) @@ -2131,10 +2143,6 @@ def rad_check(self, rad: Quantity): "you will be extrapolating based on the model fits.", stacklevel=2) - - - - class NewHydrostaticMass(BaseProfile1D): """ A profile product which uses input temperature and density profiles to calculate a cumulative hydrostatic mass @@ -2383,8 +2391,18 @@ def __init__(self, temperature_profile: Union[GasTemperature3D, ProjectedGasTemp # also retrieve the model object. The if statements are separate because we may allow for the fitting of # one model and not another, using a combination of model and datapoints to calculate hydrostatic mass if temperature_model is not None: - temperature_model = temperature_profile.fit(temperature_model, fit_method, num_samples, temp_steps, - num_walkers, progress, show_warn) + # If the passed model has already been fit then yay! however, we make sure the number of samples is the + # same as what was passed to this class, as otherwise we're going to have some shape mismatches. If they + # aren't the same then the fit will have to be re-run + in_mod_names = temperature_model.name in [m for m in temperature_profile._good_model_fits[fit_method]] + + if in_mod_names and len(temperature_profile.get_model_fit(temperature_model.name, + fit_method).par_dists[0]) != num_samples: + temperature_model = temperature_profile.fit(temperature_model, fit_method, num_samples, temp_steps, + num_walkers, progress, show_warn, force_refit=True) + elif not in_mod_names: + temperature_model = temperature_profile.fit(temperature_model, fit_method, num_samples, temp_steps, + num_walkers, progress, show_warn, force_refit=False) key_temp_mod_part = "tm{t}".format(t=temperature_model.name) # Have to check whether the fits were actually successful, as the fit method will return a model instance # either way @@ -2397,8 +2415,18 @@ def __init__(self, temperature_profile: Union[GasTemperature3D, ProjectedGasTemp key_temp_mod_part = "tmdata" if density_model is not None: - density_model = density_profile.fit(density_model, fit_method, num_samples, dens_steps, num_walkers, - progress, show_warn) + # If the passed model has already been fit then yay! however, we make sure the number of samples is the + # same as what was passed to this class, as otherwise we're going to have some shape mismatches. If they + # aren't the same then the fit will have to be re-run + in_mod_names = density_model.name in [m for m in density_profile._good_model_fits[fit_method]] + if in_mod_names and len(density_profile.get_model_fit(density_model.name, + fit_method).par_dists[0]) != num_samples: + density_model = density_profile.fit(density_model, fit_method, num_samples, temp_steps, + num_walkers, progress, show_warn, force_refit=True) + elif not in_mod_names: + density_model = density_profile.fit(density_model, fit_method, num_samples, temp_steps, + num_walkers, progress, show_warn, force_refit=False) + key_dens_mod_part = "dm{d}".format(d=density_model.name) # Have to check whether the fits were actually successful, as the fit method will return a model instance # either way @@ -2532,7 +2560,7 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, # If we have to do any numerical differentiation, which we will if we're not using smooth models that have # analytical solutions to their first order derivative, then we need a 'dx' value. We'll choose a very # small one, dividing the outermost radius of this profile be 1e+6 - dx = self.radii.max()/1e+6 + dx = self.radii.max() / 1e+6 # Here we prepare the radius uncertainties for use (if they've been passed) - the goal here is to end up # with a set of radius samples (either just the one, or M if there are M radii passed) that can be used for @@ -2565,7 +2593,7 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, # This is ugly and inelegant, but want to make sure that the passed radius is an array (even just with # length one) if one_rad: - radius = radius.reshape(1,) + radius = radius.reshape(1, ) # Here, if we haven't already identified a previously calculated hydrostatic mass for the radius, we start to # prepare the data we need (i.e. temperature and density). This is complicated slightly by the different @@ -2606,7 +2634,7 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, np.gradient(dens_data_real, self.density_profile.radii, axis=1).T, axis=0, assume_sorted=True, fill_value='extrapolate', bounds_error=False) dens_der = Quantity(dens_der_interp(radius).T, - self.density_profile.values_unit/self.density_profile.radii_unit).T + self.density_profile.values_unit / self.density_profile.radii_unit).T # This particular combination means that we are doing a data-point based profile, but without interpolation, # and that the density profile has more bins than the temperature (going to be true in most cases). So we @@ -2690,7 +2718,7 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, # profile where the 'custom' is to do it in keV if not already_run and temp.unit.is_equivalent('keV'): temp = (temp / k_B).to('K') - temp_der = (temp_der / k_B).to(Unit('K')/self._temp_prof.radii_unit) + temp_der = (temp_der / k_B).to(Unit('K') / self._temp_prof.radii_unit) # And now we do the actual mass calculation if not already_run: @@ -2699,7 +2727,7 @@ def mass(self, radius: Quantity, conf_level: float = 68.2, # form". Here there are no logs in the derivatives, because it's easier to take advantage of astropy's # quantities that way. mass_dist = (((-1 * k_B * np.power(radius[..., None], 2)) / (dens * (MEAN_MOL_WEIGHT * m_p) * G)) - * ((dens * temp_der) + (temp * dens_der))) + * ((dens * temp_der) + (temp * dens_der))) # Returning to the expected shape of array for single radii passed in if one_rad: @@ -2810,8 +2838,8 @@ def view_mass_dist(self, radius: Quantity, conf_level: float = 68.2, figsize: Tu # And this just plots the 'result' on the distribution as a series of vertical lines plt.axvline(hy_mass[0].value, color='red', label=res_label) - plt.axvline(hy_mass[0].value-hy_mass[1].value, color='red', linestyle='dashed') - plt.axvline(hy_mass[0].value+hy_mass[2].value, color='red', linestyle='dashed') + plt.axvline(hy_mass[0].value - hy_mass[1].value, color='red', linestyle='dashed') + plt.axvline(hy_mass[0].value + hy_mass[2].value, color='red', linestyle='dashed') plt.legend(loc='best', prop={'size': 12}) plt.tight_layout() plt.show() @@ -2898,12 +2926,12 @@ def view_baryon_fraction_dist(self, radius: Quantity, conf_level: float = 68.2, plt.title("Baryon Fraction Distribution at {}".format(radius.to_string())) vals_label = str(bar_frac[0].round(2).value) + "^{+" + str(bar_frac[2].round(2).value) + "}" + \ - "_{-" + str(bar_frac[1].round(2).value) + "}" + "_{-" + str(bar_frac[1].round(2).value) + "}" res_label = r"$\rm{f_{gas}} = " + vals_label + "$" plt.axvline(bar_frac[0].value, color='red', label=res_label) - plt.axvline(bar_frac[0].value-bar_frac[1].value, color='red', linestyle='dashed') - plt.axvline(bar_frac[0].value+bar_frac[2].value, color='red', linestyle='dashed') + plt.axvline(bar_frac[0].value - bar_frac[1].value, color='red', linestyle='dashed') + plt.axvline(bar_frac[0].value + bar_frac[2].value, color='red', linestyle='dashed') plt.legend(loc='best', prop={'size': 12}) plt.xlim(0) plt.tight_layout() @@ -2986,6 +3014,7 @@ def overdensity_radius(self, delta: int, redshift: float, cosmo, init_lo_rad: Qu :return: The calculated overdensity radius. :rtype: Quantity """ + def turning_point(brackets: Quantity, step_size: Quantity) -> Quantity: """ This is the meat of the overdensity_radius method. It goes looking for radii that bracket the @@ -3067,7 +3096,7 @@ def turning_point(brackets: Quantity, step_size: Quantity) -> Quantity: else: tight_bracket = wide_bracket - return ((tight_bracket[0]+tight_bracket[1])/2).to(out_unit) + return ((tight_bracket[0] + tight_bracket[1]) / 2).to(out_unit) def _diag_view_prep(self, src) -> Tuple[int, RateMap, SurfaceBrightness1D]: """ @@ -3164,16 +3193,17 @@ def _gen_diag_view(self, fig: Figure, src, num_plots: int, rt: RateMap, sb: Surf # These simply plot the mass, temperature, and density profiles with legends turned off, residuals turned # off, and no title - ax_arr[0+offset] = self.get_view(fig, ax_arr[0+offset], show_legend=False, custom_title='', - show_residual_ax=False)[0] - ax_arr[1+offset] = self.temperature_profile.get_view(fig, ax_arr[1+offset], show_legend=False, custom_title='', - show_residual_ax=False)[0] - ax_arr[2+offset] = self.density_profile.get_view(fig, ax_arr[2+offset], show_legend=False, custom_title='', - show_residual_ax=False)[0] + ax_arr[0 + offset] = self.get_view(fig, ax_arr[0 + offset], show_legend=False, custom_title='', + show_residual_ax=False)[0] + ax_arr[1 + offset] = \ + self.temperature_profile.get_view(fig, ax_arr[1 + offset], show_legend=False, custom_title='', + show_residual_ax=False)[0] + ax_arr[2 + offset] = self.density_profile.get_view(fig, ax_arr[2 + offset], show_legend=False, custom_title='', + show_residual_ax=False)[0] # Then if there is a surface brightness profile thats added too if sb is not None: - ax_arr[3+offset] = sb.get_view(fig, ax_arr[3+offset], show_legend=False, custom_title='', - show_residual_ax=False)[0] + ax_arr[3 + offset] = sb.get_view(fig, ax_arr[3 + offset], show_legend=False, custom_title='', + show_residual_ax=False)[0] return ax_arr @@ -3196,7 +3226,7 @@ def diagnostic_view(self, src=None, figsize: Tuple[float, float] = None): # Calculate a sensible figsize if the user didn't pass one if figsize is None: - figsize = (7.2*num_plots, 7) + figsize = (7.2 * num_plots, 7) # Set up the figure fig = plt.figure(figsize=figsize) @@ -3228,7 +3258,7 @@ def save_diagnostic_view(self, save_path: str, src=None, figsize: Tuple[float, f # Calculate a sensible figsize if the user didn't pass one if figsize is None: - figsize = (7.2*num_plots, 7) + figsize = (7.2 * num_plots, 7) # Set up the figure fig = plt.figure(figsize=figsize) @@ -3310,9 +3340,6 @@ def rad_check(self, rad: Quantity): "you will be extrapolating based on the model fits.", stacklevel=2) - - - class SpecificEntropy(BaseProfile1D): """ A profile product which uses input temperature and density profiles to calculate a specific entropy profile of @@ -3751,7 +3778,7 @@ def entropy(self, radius: Quantity, conf_level: float = 68.2) -> Union[Quantity, # And now we do the actual entropy calculation if not already_run: - ent_dist = (temp / dens**(2/3)).T + ent_dist = (temp / dens ** (2 / 3)).T # Storing the result if it is for a single radius if radius.isscalar: self._entropies[radius] = ent_dist @@ -3810,8 +3837,8 @@ def view_entropy_dist(self, radius: Quantity, conf_level: float = 68.2, figsize= # And this just plots the 'result' on the distribution as a series of vertical lines plt.axvline(ent[0].value, color='red', label=res_label) - plt.axvline(ent[0].value-ent[1].value, color='red', linestyle='dashed') - plt.axvline(ent[0].value+ent[2].value, color='red', linestyle='dashed') + plt.axvline(ent[0].value - ent[1].value, color='red', linestyle='dashed') + plt.axvline(ent[0].value + ent[2].value, color='red', linestyle='dashed') plt.legend(loc='best', prop={'size': 12}) plt.tight_layout() plt.show() @@ -3897,6 +3924,7 @@ class Generic1D(BaseProfile1D): :param bool auto_save: Whether the profile should automatically save itself to disk at any point. The default is False, but all profiles generated through XGA processes acting on XGA sources will auto-save. """ + def __init__(self, radii: Quantity, values: Quantity, centre: Quantity, source_name: str, obs_id: str, inst: str, y_axis_label: str, prof_type: str, radii_err: Quantity = None, values_err: Quantity = None, associated_set_id: int = None, set_storage_key: str = None, deg_radii: Quantity = None, @@ -3929,11 +3957,3 @@ def __init__(self, radii: Quantity, values: Quantity, centre: Quantity, source_n set_storage_key, deg_radii, auto_save=auto_save) self._prof_type = prof_type self._y_axis_name = y_axis_label - - - - - - - - From 6211a0186f3c6fa140091ebd8944be4ff0476102 Mon Sep 17 00:00:00 2001 From: David Turner Date: Thu, 21 Nov 2024 22:10:38 -0500 Subject: [PATCH 83/85] The model refitting in new hydro mass profile didn't work because the models can either be strings or model instances. Indirectly for issue #1260 --- xga/products/profile.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/xga/products/profile.py b/xga/products/profile.py index bd0aca16..22c8d384 100644 --- a/xga/products/profile.py +++ b/xga/products/profile.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 21/11/2024, 21:59. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 21/11/2024, 22:10. Copyright (c) The Contributors from copy import copy from typing import Tuple, Union, List @@ -2391,10 +2391,11 @@ def __init__(self, temperature_profile: Union[GasTemperature3D, ProjectedGasTemp # also retrieve the model object. The if statements are separate because we may allow for the fitting of # one model and not another, using a combination of model and datapoints to calculate hydrostatic mass if temperature_model is not None: + t_mn = temperature_model.name if isinstance(temperature_model, BaseModel1D) else temperature_model # If the passed model has already been fit then yay! however, we make sure the number of samples is the # same as what was passed to this class, as otherwise we're going to have some shape mismatches. If they # aren't the same then the fit will have to be re-run - in_mod_names = temperature_model.name in [m for m in temperature_profile._good_model_fits[fit_method]] + in_mod_names = t_mn in [m for m in temperature_profile._good_model_fits[fit_method]] if in_mod_names and len(temperature_profile.get_model_fit(temperature_model.name, fit_method).par_dists[0]) != num_samples: @@ -2415,16 +2416,17 @@ def __init__(self, temperature_profile: Union[GasTemperature3D, ProjectedGasTemp key_temp_mod_part = "tmdata" if density_model is not None: + d_mn = density_model.name if isinstance(density_model, BaseModel1D) else density_model # If the passed model has already been fit then yay! however, we make sure the number of samples is the # same as what was passed to this class, as otherwise we're going to have some shape mismatches. If they # aren't the same then the fit will have to be re-run - in_mod_names = density_model.name in [m for m in density_profile._good_model_fits[fit_method]] + in_mod_names = d_mn in [m for m in density_profile._good_model_fits[fit_method]] if in_mod_names and len(density_profile.get_model_fit(density_model.name, fit_method).par_dists[0]) != num_samples: - density_model = density_profile.fit(density_model, fit_method, num_samples, temp_steps, + density_model = density_profile.fit(density_model, fit_method, num_samples, dens_steps, num_walkers, progress, show_warn, force_refit=True) elif not in_mod_names: - density_model = density_profile.fit(density_model, fit_method, num_samples, temp_steps, + density_model = density_profile.fit(density_model, fit_method, num_samples, dens_steps, num_walkers, progress, show_warn, force_refit=False) key_dens_mod_part = "dm{d}".format(d=density_model.name) From e17aee0a8b7311c79c734cc0b3ceff54e7e08d32 Mon Sep 17 00:00:00 2001 From: David Turner Date: Thu, 21 Nov 2024 22:13:34 -0500 Subject: [PATCH 84/85] The model refitting in new hydro mass profile didn't work because the models can either be strings or model instances. Indirectly for issue #1260 --- xga/products/profile.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/xga/products/profile.py b/xga/products/profile.py index 22c8d384..8f322160 100644 --- a/xga/products/profile.py +++ b/xga/products/profile.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 21/11/2024, 22:10. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 21/11/2024, 22:13. Copyright (c) The Contributors from copy import copy from typing import Tuple, Union, List @@ -2397,8 +2397,7 @@ def __init__(self, temperature_profile: Union[GasTemperature3D, ProjectedGasTemp # aren't the same then the fit will have to be re-run in_mod_names = t_mn in [m for m in temperature_profile._good_model_fits[fit_method]] - if in_mod_names and len(temperature_profile.get_model_fit(temperature_model.name, - fit_method).par_dists[0]) != num_samples: + if in_mod_names and len(temperature_profile.get_model_fit(t_mn, fit_method).par_dists[0]) != num_samples: temperature_model = temperature_profile.fit(temperature_model, fit_method, num_samples, temp_steps, num_walkers, progress, show_warn, force_refit=True) elif not in_mod_names: @@ -2421,8 +2420,7 @@ def __init__(self, temperature_profile: Union[GasTemperature3D, ProjectedGasTemp # same as what was passed to this class, as otherwise we're going to have some shape mismatches. If they # aren't the same then the fit will have to be re-run in_mod_names = d_mn in [m for m in density_profile._good_model_fits[fit_method]] - if in_mod_names and len(density_profile.get_model_fit(density_model.name, - fit_method).par_dists[0]) != num_samples: + if in_mod_names and len(density_profile.get_model_fit(d_mn, fit_method).par_dists[0]) != num_samples: density_model = density_profile.fit(density_model, fit_method, num_samples, dens_steps, num_walkers, progress, show_warn, force_refit=True) elif not in_mod_names: From 4a1a25b1ea3a6620910e45d9509ea0c3e066a8be Mon Sep 17 00:00:00 2001 From: David Turner Date: Thu, 21 Nov 2024 22:18:06 -0500 Subject: [PATCH 85/85] Deleted the old hydrostatic mass profile and renamed the new. Closes issue #1260 --- xga/products/profile.py | 863 +--------------------------------------- 1 file changed, 1 insertion(+), 862 deletions(-) diff --git a/xga/products/profile.py b/xga/products/profile.py index 8f322160..62e50cee 100644 --- a/xga/products/profile.py +++ b/xga/products/profile.py @@ -1,5 +1,5 @@ # This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS). -# Last modified by David J Turner (turne540@msu.edu) 21/11/2024, 22:13. Copyright (c) The Contributors +# Last modified by David J Turner (turne540@msu.edu) 21/11/2024, 22:18. Copyright (c) The Contributors from copy import copy from typing import Tuple, Union, List @@ -1282,868 +1282,7 @@ def __init__(self, radii: Quantity, values: Quantity, centre: Quantity, source_n # This is what the y-axis is labelled as during plotting self._y_axis_name = "Baryon Fraction" - class HydrostaticMass(BaseProfile1D): - """ - A profile product which uses input GasTemperature3D and GasDensity3D profiles to generate a hydrostatic - mass profile, which in turn can be used to measure the hydrostatic mass at a particular radius. In contrast - to other profile objects, this one calculates the y values itself, as such any radii may be passed. - - :param GasTemperature3D temperature_profile: The XGA 3D temperature profile to take temperature - information from. - :param str/BaseModel1D temperature_model: The model to fit to the temperature profile, either a name or an - instance of an XGA temperature model class. - :param GasDensity3D density_profile: The XGA 3D density profile to take density information from. - :param str/BaseModel1D density_model: The model to fit to the density profile, either a name or an - instance of an XGA density model class. - :param Quantity radii: The radii at which to measure the hydrostatic mass for the declaration of the profile. - :param Quantity radii_err: The uncertainties on the radii. - :param Quantity deg_radii: The radii values, but in units of degrees. This is required to set up a storage key - for the profile to be filed in an XGA source. - :param str fit_method: The name of the fit method to use for the fitting of the profiles, default is 'mcmc'. - :param int num_walkers: If the fit method is 'mcmc' then this will set the number of walkers for the emcee - sampler to set up. - :param list/int num_steps: If the fit method is 'mcmc' this will set the number of steps for each sampler to - take. If a single number is passed then that number of steps is used for both profiles, otherwise if a list - is passed the first entry is used for the temperature fit, and the second for the density fit. - :param int num_samples: The number of random samples to be drawn from the posteriors of the fit results. - :param bool show_warn: Should warnings thrown during the fitting processes be shown. - :param bool progress: Should fit progress bars be shown. - :param bool auto_save: Whether the profile should automatically save itself to disk at any point. The default is - False, but all profiles generated through XGA processes acting on XGA sources will auto-save. - """ - - def __init__(self, temperature_profile: GasTemperature3D, temperature_model: Union[str, BaseModel1D], - density_profile: GasDensity3D, density_model: Union[str, BaseModel1D], radii: Quantity, - radii_err: Quantity, deg_radii: Quantity, fit_method: str = "mcmc", num_walkers: int = 20, - num_steps: [int, List[int]] = 20000, num_samples: int = 10000, show_warn: bool = True, - progress: bool = True, auto_save: bool = False): - """ - The init method for the HydrostaticMass class, uses temperature and density profiles, along with models, to - set up the hydrostatic mass profile. - - :param GasTemperature3D temperature_profile: The XGA 3D temperature profile to take temperature - information from. - :param str/BaseModel1D temperature_model: The model to fit to the temperature profile, either a name or an - instance of an XGA temperature model class. - :param GasDensity3D density_profile: The XGA 3D density profile to take density information from. - :param str/BaseModel1D density_model: The model to fit to the density profile, either a name or an - instance of an XGA density model class. - :param Quantity radii: The radii at which to measure the hydrostatic mass for the declaration of the profile. - :param Quantity radii_err: The uncertainties on the radii. - :param Quantity deg_radii: The radii values, but in units of degrees. This is required to set up a storage key - for the profile to be filed in an XGA source. - :param str fit_method: The name of the fit method to use for the fitting of the profiles, default is 'mcmc'. - :param int num_walkers: If the fit method is 'mcmc' then this will set the number of walkers for the emcee - sampler to set up. - :param list/int num_steps: If the fit method is 'mcmc' this will set the number of steps for each sampler to - take. If a single number is passed then that number of steps is used for both profiles, otherwise if a list - is passed the first entry is used for the temperature fit, and the second for the density fit. - :param int num_samples: The number of random samples to be drawn from the posteriors of the fit results. - :param bool show_warn: Should warnings thrown during the fitting processes be shown. - :param bool progress: Should fit progress bars be shown. - :param bool auto_save: Whether the profile should automatically save itself to disk at any point. The default is - False, but all profiles generated through XGA processes acting on XGA sources will auto-save. - """ - # We check whether the temperature profile passed is actually the type of profile we need - if type(temperature_profile) != GasTemperature3D: - raise TypeError("Only a GasTemperature3D instance may be passed for temperature_profile, check " - "you haven't accidentally passed a ProjectedGasTemperature1D.") - - # We repeat this process with the density profile and model - if type(density_profile) != GasDensity3D: - raise TypeError("Only a GasDensity3D instance may be passed for density_profile, check you haven't " - "accidentally passed a GasDensity1D.") - - # We also need to check that someone hasn't done something dumb like pass profiles from two different - # clusters, so we'll compare source names. - if temperature_profile.src_name != density_profile.src_name: - raise ValueError("You have passed temperature and density profiles from two different " - "sources, any resulting hydrostatic mass measurements would not be valid, so this is not " - "allowed.") - # And check they were generated with the same central coordinate, otherwise they may not be valid. I - # considered only raising a warning, but I need a consistent central coordinate to pass to the super init - elif np.any(temperature_profile.centre != density_profile.centre): - raise ValueError("The temperature and density profiles do not have the same central coordinate.") - # Same reasoning with the ObsID and instrument - elif temperature_profile.obs_id != density_profile.obs_id: - warn("The temperature and density profiles do not have the same associated ObsID.", stacklevel=2) - elif temperature_profile.instrument != density_profile.instrument: - warn("The temperature and density profiles do not have the same associated instrument.", stacklevel=2) - - # We see if either of the profiles have an associated spectrum - if temperature_profile.set_ident is None and density_profile.set_ident is None: - set_id = None - set_store = None - elif temperature_profile.set_ident is None and density_profile.set_ident is not None: - set_id = density_profile.set_ident - set_store = density_profile.associated_set_storage_key - elif temperature_profile.set_ident is not None and density_profile.set_ident is None: - set_id = temperature_profile.set_ident - set_store = temperature_profile.associated_set_storage_key - elif temperature_profile.set_ident is not None and density_profile.set_ident is not None: - if temperature_profile.set_ident != density_profile.set_ident: - warn("The temperature and density profile you passed were generated from different sets of annular" - " spectra, the mass profiles associated set ident will be set to None.", stacklevel=2) - set_id = None - set_store = None - else: - set_id = temperature_profile.set_ident - set_store = temperature_profile.associated_set_storage_key - - self._temp_prof = temperature_profile - self._dens_prof = density_profile - - if not radii.unit.is_equivalent("kpc"): - raise UnitConversionError("Radii unit cannot be converted to kpc") - else: - radii = radii.to('kpc') - radii_err = radii_err.to('kpc') - # This will be overwritten by the super() init call, but it allows rad_check to work - self._radii = radii - - # We won't REQUIRE that the profiles have data point generated at the same radii, as we're gonna - # measure masses from the models, but I do need to check that the passed radii are within the radii of the - # and warn the user if they aren't - self.rad_check(radii) - - if isinstance(num_steps, int): - temp_steps = num_steps - dens_steps = num_steps - elif isinstance(num_steps, list) and len(num_steps) == 2: - temp_steps = num_steps[0] - dens_steps = num_steps[1] - else: - raise ValueError("If a list is passed for num_steps then it must have two entries, the first for the " - "temperature profile fit and the second for the density profile fit") - - # Make sure the model fits have been run, and retrieve the model objects - temperature_model = temperature_profile.fit(temperature_model, fit_method, num_samples, temp_steps, num_walkers, - progress, show_warn) - density_model = density_profile.fit(density_model, fit_method, num_samples, dens_steps, num_walkers, progress, - show_warn) - - # Have to check whether the fits were actually successful, as the fit method will return a model instance - # either way - if not temperature_model.success: - raise XGAFitError("The fit to the temperature was unsuccessful, cannot define hydrostatic mass profile.") - if not density_model.success: - raise XGAFitError("The fit to the density was unsuccessful, cannot define hydrostatic mass profile.") - - self._temp_model = temperature_model - self._dens_model = density_model - - mass, mass_dist = self.mass(radii, conf_level=68) - mass_vals = mass[0, :] - mass_errs = np.mean(mass[1:, :], axis=0) - - super().__init__(radii, mass_vals, self._temp_prof.centre, self._temp_prof.src_name, self._temp_prof.obs_id, - self._temp_prof.instrument, radii_err, mass_errs, set_id, set_store, deg_radii, - auto_save=auto_save) - - # Need a custom storage key for this mass profile, incorporating all the information we have about what - # went into it, density profile, temperature profile, radii, density and temperature models. - dens_part = "dprof_{}".format(self._dens_prof.storage_key) - temp_part = "tprof_{}".format(self._temp_prof.storage_key) - cur_part = self.storage_key - new_part = "tm{t}_dm{d}".format(t=self._temp_model.name, d=self._dens_model.name) - whole_new = "{n}_{c}_{t}_{d}".format(n=new_part, c=cur_part, t=temp_part, d=dens_part) - self._storage_key = whole_new - - # Setting the type - self._prof_type = "hydrostatic_mass" - - # This is what the y-axis is labelled as during plotting - self._y_axis_name = r"M$_{\rm{hydro}}$" - - # Setting up a dictionary to store hydro mass results in. - self._masses = {} - - def mass(self, radius: Quantity, conf_level: float = 68.2, - radius_err: Quantity = None) -> Union[Quantity, Quantity]: - """ - A method which will measure a hydrostatic mass and hydrostatic mass uncertainty within the given - radius/radii. No corrections are applied to the values calculated by this method, it is just the vanilla - hydrostatic mass. - - If the models for temperature and density have analytical solutions to their derivative wrt to radius then - those will be used to calculate the gradients at radius, but if not then a numerical method will be used for - which dx will be set to radius/1e+6. - - :param Quantity radius: An astropy quantity containing the radius/radii that you wish to calculate the - mass within. - :param float conf_level: The confidence level for the mass uncertainties, the default is 68.2% (~1σ). - :param Quantity radius_err: A standard deviation on radius, which will be taken into account during the - calculation of hydrostatic mass. - :return: An astropy quantity containing the mass/masses, lower and upper uncertainties, and another containing - the mass realisation distribution. - :rtype: Union[Quantity, Quantity] - """ - upper = 50 + (conf_level / 2) - lower = 50 - (conf_level / 2) - - # Prints a warning of the mass is outside the range of the data - self.rad_check(radius) - - # We need check that, if the user has passed uncertainty information on radii, it is how we expect it to be. - # First off, are there the right number of entries? - if not radius.isscalar and radius_err is not None and (radius_err.isscalar or len(radius) != len(radius_err)): - raise ValueError("If a set of radii are passed, and radius uncertainty information is provided, the " - "'radius_err' argument must contain the same number of entries as the 'radius' argument.") - # Same deal here, if only one radius is passed, only one error may be passed - elif radius.isscalar and radius_err is not None and not radius_err.isscalar: - raise ValueError("When a radius uncertainty ('radius_err') is passed for a single radius value, " - "'radius_err' must be scalar.") - # Now we check that the units of the radius and radius error quantities are compatible - elif radius_err is not None and not radius_err.unit.is_equivalent(radius.unit): - raise UnitConversionError("The radius_err quantity must be in units that are equivalent to units " - "of {}.".format(radius.unit.to_string())) - - # Now we make absolutely sure that the radius error(s) are in the correct units - if radius_err is not None: - radius_err = radius_err.to(self.radii_unit) - - # Here we construct the storage key for the radius passed, and the uncertainty if there is one - if radius.isscalar and radius_err is None: - stor_key = str(radius.value) + " " + str(radius.unit) - elif radius.isscalar and radius_err is not None: - stor_key = str(radius.value) + '_' + str(radius_err.value) + " " + str(radius.unit) - # In this case, as the radius is not scalar, the masses won't be stored so we don't need a storage key - else: - stor_key = None - - # Check to see whether the calculation has to be run again - if radius.isscalar and stor_key in self._masses: - already_run = True - mass_dist = self._masses[stor_key] - else: - already_run = False - - # If the models don't have analytical solutions to their derivative then the derivative method will need - # a dx to assume, so I will set one equal to radius/1e+6 (or the max radius if non-scalar), should be - # small enough. - if radius.isscalar: - dx = radius / 1e+6 - else: - dx = radius.max() / 1e+6 - - # Declaring this allows us to randomly draw from Gaussians, if the user has given us radius error information - rng = np.random.default_rng() - # In this case a single radius value, and a radius uncertainty has been passed - if radius.isscalar and radius_err is not None: - # We just want one a single distribution of radius here, but make sure that it is in a (1, N) shaped array - # as some downstream tasks in model classes, such as get_realisations and derivative, want radius - # DISTRIBUTIONS to be 2dim arrays, and multiple radius VALUES (e.g. [1, 2, 3, 4]) to be 1dim arrays - calc_rad = Quantity(rng.normal(radius.value, radius_err.value, (1, len(self._dens_model.par_dists[0]))), - radius_err.unit) - # In this case multiple radius values have been passed, each with an uncertainty - elif not radius.isscalar and radius_err is not None: - # So here we're setting up M radius distributions, where M is the number of input radii. So this radius - # array ends up being shape (M, N), where M is the number of radii, and M is the number of samples in - # the model posterior distributions - calc_rad = Quantity(rng.normal(radius.value, radius_err.value, - (len(self._dens_model.par_dists[0]), len(radius))), radius_err.unit).T - - # This is the simplest case, just a radius (or a set of radii) with no uncertainty information has been passed - else: - calc_rad = radius - - # If we need to do the calculation (i.e. no existing result is available, and the model fits worked) then - # we had best get to it! - if not already_run and self._dens_model.success and self._temp_model.success: - # This grabs gas density values from the density model, need to check whether the model is in units - # of mass or number density - if self._dens_model.y_unit.is_equivalent('1/cm^3'): - dens = self._dens_model.get_realisations(calc_rad) - dens_der = self._dens_model.derivative(calc_rad, dx, True) - else: - dens = self._dens_model.get_realisations(calc_rad) / (MEAN_MOL_WEIGHT * m_p) - dens_der = self._dens_model.derivative(calc_rad, dx, True) / (MEAN_MOL_WEIGHT * m_p) - - # We do the same for the temperature vals, again need to check the units - if self._temp_model.y_unit.is_equivalent("keV"): - temp = (self._temp_model.get_realisations(calc_rad) / k_B).to('K') - temp_der = self._temp_model.derivative(calc_rad, dx, True) / k_B - temp_der = temp_der.to(Unit('K') / self._temp_model.x_unit) - else: - temp = self._temp_model.get_realisations(calc_rad).to('K') - temp_der = self._temp_model.derivative(calc_rad, dx, True).to('K') - - # Please note that this is just the vanilla hydrostatic mass equation, but not written in the "standard - # form". Here there are no logs in the derivatives, because it's easier to take advantage of astropy's - # quantities that way. - mass_dist = ((-1 * k_B * np.power(radius[..., None], 2)) / (dens * (MEAN_MOL_WEIGHT * m_p) * G)) * \ - ((dens * temp_der) + (temp * dens_der)) - - # Just converts the mass/masses to the unit we normally use for them - mass_dist = mass_dist.to('Msun').T - - if radius.isscalar: - self._masses[stor_key] = mass_dist - - elif not self._temp_model.success or not self._dens_model.success: - raise XGAFitError("One or both of the fits to the temperature model and density profiles were " - "not successful") - - mass_med = np.percentile(mass_dist, 50, axis=0) - mass_lower = mass_med - np.percentile(mass_dist, lower, axis=0) - mass_upper = np.percentile(mass_dist, upper, axis=0) - mass_med - - mass_res = Quantity(np.array([mass_med.value, mass_lower.value, mass_upper.value]), mass_dist.unit) - - # We check to see if any of the upper limits (i.e. measured value plus +ve error) are below zero, and if so - # then we throw an exception up - if np.any((mass_res[0] + mass_res[1]) < 0): - raise ValueError("A mass upper limit (i.e. measured value plus +ve error) of less than zero has been " - "measured, which is not physical.") - - return mass_res, mass_dist - - def annular_mass(self, outer_radius: Quantity, inner_radius: Quantity, conf_level: float = 68.2): - """ - Calculate the hydrostatic mass contained within a specific 3D annulus, bounded by the outer and inner radius - supplied to this method. Annular mass is calculated by measuring the mass within the inner and outer - radii, and then subtracting the inner from the outer. Also supports calculating multiple annular masses - when inner_radius and outer_radius are non-scalar. - - WARNING - THIS METHOD INVOLVES SUBTRACTING TWO MASS DISTRIBUTIONS, WHICH CAN'T NECESSARILY BE APPROXIMATED - AS GAUSSIAN DISTRIBUTIONS, AS SUCH RESULTS FROM THIS METHOD SHOULD BE TREATED WITH SOME SUSPICION. - - :param Quantity outer_radius: Astropy containing outer radius (or radii) for the annulus (annuli) within - which you wish to measure the mass. If calculating multiple annular masses, the length of outer_radius - must be the same as inner_radius. - :param Quantity inner_radius: Astropy containing inner radius (or radii) for the annulus (annuli) within - which you wish to measure the mass. If calculating multiple annular masses, the length of inner_radius - must be the same as outer_radius. - :param float conf_level: The confidence level for the mass uncertainties, the default is 68.2% (~1σ). - :return: An astropy quantity containing a mass distribution(s). Quantity will become two-dimensional - when multiple sets of inner and outer radii are passed by the user. - :rtype: Quantity - """ - # Perform some checks to make sure that the user has passed inner and outer radii quantities that are valid - # and won't break any of the calculations that will be happening in this method - if outer_radius.isscalar != inner_radius.isscalar: - raise ValueError("The outer_radius and inner_radius Quantities must both be scalar, or both " - "be non-scalar.") - elif (not inner_radius.isscalar and inner_radius.ndim != 1) or \ - (not outer_radius.isscalar and outer_radius.ndim != 1): - raise ValueError('Non-scalar radius Quantities must have only one dimension') - elif not outer_radius.isscalar and not inner_radius.isscalar and outer_radius.shape != inner_radius.shape: - raise ValueError('The outer_radius and inner_radius Quantities must be the same shape.') - - # This just measures the masses within two radii, the outer and the inner supplied by the user. The mass() - # method will automatically deal with the input of multiple entries for each radius - outer_mass, outer_mass_dist = self.mass(outer_radius, conf_level) - inner_mass, inner_mass_dist = self.mass(inner_radius, conf_level) - - # This PROBABLY NOT AT ALL valid because they're just posterior distributions of mass - return outer_mass_dist - inner_mass_dist - - def view_mass_dist(self, radius: Quantity, conf_level: float = 68.2, figsize=(8, 8), bins: Union[str, int] = 'auto', - colour: str = "lightslategrey"): - """ - A method which will generate a histogram of the mass distribution that resulted from the mass calculation - at the supplied radius. If the mass for the passed radius has already been measured it, and the mass - distribution, will be retrieved from the storage of this product rather than re-calculated. - - :param Quantity radius: An astropy quantity containing the radius/radii that you wish to calculate the - mass within. - :param float conf_level: The confidence level for the mass uncertainties, the default is 68.2% (~1σ). - :param int/str bins: The argument to be passed to plt.hist, either a number of bins or a binning - algorithm name. - :param str colour: The desired colour of the histogram. - :param tuple figsize: The desired size of the histogram figure. - """ - if not radius.isscalar: - raise ValueError("Unfortunately this method can only display a distribution for one radius, so " - "arrays of radii are not supported.") - - # Grabbing out the mass distribution, as well as the single result that describes the mass distribution. - hy_mass, hy_dist = self.mass(radius, conf_level) - # Setting up the figure - plt.figure(figsize=figsize) - ax = plt.gca() - # Includes nicer ticks - ax.tick_params(axis='both', direction='in', which='both', top=True, right=True) - # And removing the yaxis tick labels as its just a number of values per bin - ax.yaxis.set_ticklabels([]) - - # Plot the histogram and set up labels - plt.hist(hy_dist.value, bins=bins, color=colour, alpha=0.7, density=False) - plt.xlabel(self._y_axis_name + r" M$_{\odot}$") - plt.title("Mass Distribution at {}".format(radius.to_string())) - - lab_hy_mass = hy_mass.to("10^14Msun") - vals_label = str(lab_hy_mass[0].round(2).value) + "^{+" + str(lab_hy_mass[2].round(2).value) + "}" + \ - "_{-" + str(lab_hy_mass[1].round(2).value) + "}" - res_label = r"$\rm{M_{hydro}} = " + vals_label + r"10^{14}M_{\odot}$" - - # And this just plots the 'result' on the distribution as a series of vertical lines - plt.axvline(hy_mass[0].value, color='red', label=res_label) - plt.axvline(hy_mass[0].value - hy_mass[1].value, color='red', linestyle='dashed') - plt.axvline(hy_mass[0].value + hy_mass[2].value, color='red', linestyle='dashed') - plt.legend(loc='best', prop={'size': 12}) - plt.tight_layout() - plt.show() - - def baryon_fraction(self, radius: Quantity, conf_level: float = 68.2) -> Tuple[Quantity, Quantity]: - """ - A method to use the hydrostatic mass information of this profile, and the gas density information of the - input gas density profile, to calculate a baryon fraction within the given radius. - - :param Quantity radius: An astropy quantity containing the radius/radii that you wish to calculate the - baryon fraction within. - :param float conf_level: The confidence level for the mass uncertainties, the default is 68.2% (~1σ). - :return: An astropy quantity containing the baryon fraction, -ve error, and +ve error, and another quantity - containing the baryon fraction distribution. - :rtype: Tuple[Quantity, Quantity] - """ - upper = 50 + (conf_level / 2) - lower = 50 - (conf_level / 2) - - if not radius.isscalar: - raise ValueError("Unfortunately this method can only calculate the baryon fraction within one " - "radius, multiple radii are not supported.") - - # Grab out the hydrostatic mass distribution, and the gas mass distribution - hy_mass, hy_mass_dist = self.mass(radius, conf_level) - gas_mass, gas_mass_dist = self._dens_prof.gas_mass(self._dens_model.name, radius, conf_level=conf_level, - fit_method=self._dens_model.fit_method) - - # If the distributions don't have the same number of entries (though as far I can recall they always should), - # then we just make sure we have two equal length distributions to divide - if len(hy_mass_dist) < len(gas_mass_dist): - bar_frac_dist = gas_mass_dist[:len(hy_mass_dist)] / hy_mass_dist - elif len(hy_mass_dist) > len(gas_mass_dist): - bar_frac_dist = gas_mass_dist / hy_mass_dist[:len(gas_mass_dist)] - else: - bar_frac_dist = gas_mass_dist / hy_mass_dist - - bfrac_med = np.percentile(bar_frac_dist, 50, axis=0) - bfrac_lower = bfrac_med - np.percentile(bar_frac_dist, lower, axis=0) - bfrac_upper = np.percentile(bar_frac_dist, upper, axis=0) - bfrac_med - bar_frac_res = Quantity([bfrac_med.value, bfrac_lower.value, bfrac_upper.value]) - - return bar_frac_res, bar_frac_dist - - def view_baryon_fraction_dist(self, radius: Quantity, conf_level: float = 68.2, figsize=(8, 8), - bins: Union[str, int] = 'auto', colour: str = "lightslategrey"): - """ - A method which will generate a histogram of the baryon fraction distribution that resulted from the mass - calculation at the supplied radius. If the baryon fraction for the passed radius has already been - measured it, and the baryon fraction distribution, will be retrieved from the storage of this product - rather than re-calculated. - - :param Quantity radius: An astropy quantity containing the radius/radii that you wish to calculate the - baryon fraction within. - :param float conf_level: The confidence level for the mass uncertainties, the default is 68.2% (~1σ). - :param int/str bins: The argument to be passed to plt.hist, either a number of bins or a binning - algorithm name. - :param tuple figsize: The desired size of the histogram figure. - :param str colour: The desired colour of the histogram. - """ - if not radius.isscalar: - raise ValueError("Unfortunately this method can only display a distribution for one radius, so " - "arrays of radii are not supported.") - - bar_frac, bar_frac_dist = self.baryon_fraction(radius, conf_level) - plt.figure(figsize=figsize) - ax = plt.gca() - ax.tick_params(axis='both', direction='in', which='both', top=True, right=True) - ax.yaxis.set_ticklabels([]) - - plt.hist(bar_frac_dist.value, bins=bins, color=colour, alpha=0.7) - plt.xlabel("Baryon Fraction") - plt.title("Baryon Fraction Distribution at {}".format(radius.to_string())) - - vals_label = str(bar_frac[0].round(2).value) + "^{+" + str(bar_frac[2].round(2).value) + "}" + \ - "_{-" + str(bar_frac[1].round(2).value) + "}" - res_label = r"$\rm{f_{gas}} = " + vals_label + "$" - - plt.axvline(bar_frac[0].value, color='red', label=res_label) - plt.axvline(bar_frac[0].value - bar_frac[1].value, color='red', linestyle='dashed') - plt.axvline(bar_frac[0].value + bar_frac[2].value, color='red', linestyle='dashed') - plt.legend(loc='best', prop={'size': 12}) - plt.xlim(0) - plt.tight_layout() - plt.show() - - def baryon_fraction_profile(self) -> BaryonFraction: - """ - A method which uses the baryon_fraction method to construct a baryon fraction profile at the radii of - this HydrostaticMass profile. The uncertainties on the baryon fraction are calculated at the 1σ level. - - :return: An XGA BaryonFraction object. - :rtype: BaryonFraction - """ - frac = [] - frac_err = [] - # Step through the radii of this profile - for rad in self.radii: - # Grabs the baryon fraction for the current radius - b_frac = self.baryon_fraction(rad)[0] - - # Only need the actual result, not the distribution - frac.append(b_frac[0]) - # Calculates a mean uncertainty - frac_err.append(b_frac[1:].mean()) - - # Makes them unit-less quantities, as baryon fraction is mass/mass - frac = Quantity(frac, '') - frac_err = Quantity(frac_err, '') - - return BaryonFraction(self.radii, frac, self.centre, self.src_name, self.obs_id, self.instrument, - self.radii_err, frac_err, self.set_ident, self.associated_set_storage_key, - self.deg_radii, auto_save=self.auto_save) - - def overdensity_radius(self, delta: int, redshift: float, cosmo, init_lo_rad: Quantity = Quantity(100, 'kpc'), - init_hi_rad: Quantity = Quantity(3500, 'kpc'), init_step: Quantity = Quantity(100, 'kpc'), - out_unit: Union[Unit, str] = Unit('kpc')) -> Quantity: - """ - This method uses the 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 method takes a numerical approach to the location of the requested radius. Though we have calculated - analytical hydrostatic mass models for common choices of temperature and density profile models, there are - no analytical solutions for R. - - When an overdensity radius is being calculated, we initially measure masses for a range of radii between - init_lo_rad - init_hi_rad in steps of init_step. From this we find the two radii that bracket the radius where - average density - Delta*critical density = 0. Between those two radii we perform the same test with another - range of radii (in steps of 1 kpc this time), finding the radius that corresponds to the minimum - density difference value. - - :param int delta: The overdensity factor for which a radius is to be calculated. - :param float redshift: The redshift of the cluster. - :param cosmo: The cosmology in which to calculate the overdensity. Should be an astropy cosmology instance. - :param Quantity init_lo_rad: The lower radius bound for the first radii array generated to find the wide - brackets around the requested overdensity radius. Default value is 100 kpc. - :param Quantity init_hi_rad: The upper radius bound for the first radii array generated to find the wide - brackets around the requested overdensity radius. Default value is 3500 kpc. - :param Quantity init_step: The step size for the first radii array generated to find the wide brackets - around the requested overdensity radius. Default value is 100 kpc, recommend that you don't set it - smaller than 10 kpc. - :param Unit/str out_unit: The unit that this method should output the radius with. - :return: The calculated overdensity radius. - :rtype: Quantity - """ - - def turning_point(brackets: Quantity, step_size: Quantity) -> Quantity: - """ - This is the meat of the overdensity_radius method. It goes looking for radii that bracket the - requested overdensity radius. This works by calculating an array of masses, calculating densities - from them and the radius array, then calculating the difference between Delta*critical density at - source redshift. Where the difference array flips from being positive to negative is where the - bracketing radii are. - - :param Quantity brackets: The brackets within which to generate our array of radii. - :param Quantity step_size: The step size for the array of radii - :return: The bracketing radii for the requested overdensity for this search. - :rtype: Quantity - """ - # Just makes sure that the step size is definitely in the same unit as the bracket - # variable, as I take the value of step_size later - step_size = step_size.to(brackets.unit) - - # This sets up a range of radii within which to calculate masses, which in turn are used to find the - # closest value to the Delta*critical density we're looking for - rads = Quantity(np.arange(*brackets.value, step_size.value), 'kpc') - # The masses contained within the test radii, the transpose is just there because the array output - # by that function is weirdly ordered - there is an issue open that will remind to eventually change that - rad_masses = self.mass(rads)[0].T - # Calculating the density from those masses - uses the radii that the masses were measured within - rad_dens = rad_masses[:, 0] / (4 * np.pi * (rads ** 3) / 3) - # Finds the difference between the density array calculated above and the requested - # overdensity (i.e. Delta * the critical density of the Universe at the source redshift). - rad_dens_diffs = rad_dens - (delta * z_crit_dens) - - if np.all(rad_dens_diffs.value > 0) or np.all(rad_dens_diffs.value < 0): - raise ValueError("The passed lower ({l}) and upper ({u}) radii don't appear to bracket the " - "requested overdensity (Delta={d}) radius.".format(l=brackets[0], u=brackets[1], - d=delta)) - - # This finds the index of the radius where the turnover between the density difference being - # positive and negative happens. The radius of that index, and the index before it, bracket - # the requested overdensity. - turnover = np.where(rad_dens_diffs.value < 0, rad_dens_diffs.value, -np.inf).argmax() - brackets = rads[[turnover - 1, turnover]] - - return brackets - - # First perform some sanity checks to make sure that the user hasn't passed anything silly - # Check that the overdensity is a positive, non-zero (because that wouldn't make sense) integer. - if not type(delta) == int or delta <= 0: - raise ValueError("The overdensity must be a positive, non-zero, integer.") - - # The user is allowed to pass either a unit instance or a string, we make sure the out_unit is consistently - # a unit instance for the benefit of the rest of this method. - if isinstance(out_unit, str): - out_unit = Unit(out_unit) - elif not isinstance(out_unit, Unit): - raise ValueError("The out_unit argument must be either an astropy Unit instance, or a string " - "representing an astropy unit.") - - # We know that if we have arrived here then the out_unit variable is a Unit instance, so we just check - # that it's a distance unit that makes sense. I haven't allowed degrees, arcmins etc. because it would - # entail a little extra work, and I don't care enough right now. - if not out_unit.is_equivalent('kpc'): - raise UnitConversionError("The out_unit argument must be supplied with a unit that is convertible " - "to kpc. Angular units such as deg are not currently supported.") - - # Obviously redshift can't be negative, and I won't allow zero redshift because it doesn't - # make sense for clusters and completely changes how distance calculations are done. - if redshift <= 0: - raise ValueError("Redshift cannot be less than or equal to zero.") - - # This is the critical density of the Universe at the cluster redshift - this is what we compare the - # cluster density too to figure out the requested overdensity radius. - z_crit_dens = cosmo.critical_density(redshift) - - wide_bracket = turning_point(Quantity([init_lo_rad, init_hi_rad]), init_step) - if init_step != Quantity(1, 'kpc'): - # In this case I buffer the wide bracket (subtract 5 kpc from the lower bracket and add 5 kpc to the upper - # bracket) - this is a fix to help avoid errors when the turning point is equal to the upper or lower - # bracket - buffered_wide_bracket = wide_bracket + Quantity([-5, 5], 'kpc') - tight_bracket = turning_point(buffered_wide_bracket, Quantity(1, 'kpc')) - else: - tight_bracket = wide_bracket - - return ((tight_bracket[0] + tight_bracket[1]) / 2).to(out_unit) - - def _diag_view_prep(self, src) -> Tuple[int, RateMap, SurfaceBrightness1D]: - """ - This internal function just serves to grab the relevant photometric products (if available) and check to - see how many plots will be in the diagnostic view. The maximum is five; mass profile, temperature profile, - density profile, surface brightness profile, and ratemap. - - :param GalaxyCluster src: The source object for which this hydrostatic mass profile was created - :return: The number of plots, a RateMap (if src was pass, otherwise None), and a SB profile (if the - density profile was created with the SB method, otherwise None). - :rtype: Tuple[int, RateMap, SurfaceBrightness1D] - """ - - # This checks to make sure that the source is a galaxy cluster, I do it this way (with strings) to avoid - # annoying circular import errors. The source MUST be a galaxy cluster because you can only calculate - # hydrostatic mass profiles for galaxy clusters. - if src is not None and type(src).__name__ != 'GalaxyCluster': - raise TypeError("The src argument must be a GalaxyCluster object.") - - # This just checks to make sure that the name of the passed source is the same as the stored source name - # of this profile. Maybe in the future this won't be necessary because a reference to the source - # will be stored IN the profile. - if src is not None and src.name != self.src_name: - raise ValueError("The passed source has a different name to the source that was used to generate" - " this HydrostaticMass profile.") - - # If the hydrostatic mass profile was created using combined data then I grab a combined image - if self.obs_id == 'combined' and src is not None: - rt = src.get_combined_ratemaps(src.peak_lo_en, src.peak_hi_en) - # Otherwise we grab the specific relevant image - elif self.obs_id != 'combined' and src is not None: - rt = src.get_ratemaps(self.obs_id, self.instrument, src.peak_lo_en, src.peak_hi_en) - # If there is no source passed, then we don't get a ratemap - else: - rt = None - - # Checks to see whether the generation profile of the density profile is a surface brightness - # profile. The other option is that it's an apec normalisation profile if generated from the spectra method - if type(self.density_profile.generation_profile) == SurfaceBrightness1D: - sb = self.density_profile.generation_profile - # Otherwise there is no SB profile - else: - sb = None - - # Maximum number of plots is five, this just figures out how many there are going to be based on what the - # ratemap and surface brightness profile values are - num_plots = 5 - sum([rt is None, sb is None]) - - return num_plots, rt, sb - - def _gen_diag_view(self, fig: Figure, src, num_plots: int, rt: RateMap, sb: SurfaceBrightness1D): - """ - This populates the diagnostic plot figure, grabbing axes from various classes of profile product. - - :param Figure fig: The figure instance being populated. - :param GalaxyCluster src: The galaxy cluster source that this hydrostatic mass profile was created for. - :param int num_plots: The number of plots in this diagnostic view. - :param RateMap rt: A RateMap to add to this diagnostic view. - :param SurfaceBrightness1D sb: A surface brightness profile to add to this diagnostic view. - :return: The axes array of this diagnostic view. - :rtype: np.ndarray([Axes]) - """ - from ..imagetools.misc import physical_rad_to_pix - - # The preparation method has already figured out how many plots there will be, so we create those subplots - ax_arr = fig.subplots(nrows=1, ncols=num_plots) - - # If a RateMap has been passed then we need to get the view, calculate some things, and then add it to our - # diagnostic plot - if rt is not None: - # As the RateMap is the first plot, and is not guaranteed to be present, I use the offset parameter - # later in this function to shift the other plots across by 1 if it is present. - offset = 1 - # If the source was setup to use a peak coordinate, then we want to include that in the ratemap display - if src.use_peak: - ch = Quantity([src.peak, src.ra_dec]) - # I also grab the annulus boundaries from the temperature profile used to create this - # HydrostaticMass profile, then convert to pixels. That does depend on there being a source, but - # we know that we wouldn't have a RateMap at this point if the user hadn't passed a source - pix_rads = physical_rad_to_pix(rt, self.temperature_profile.annulus_bounds, src.peak, src.redshift, - src.cosmo) - - else: - # No peak means we just use the original user-passed RA-Dec - ch = src.ra_dec - pix_rads = physical_rad_to_pix(rt, self.temperature_profile.annulus_bounds, src.ra_dec, src.redshift, - src.cosmo) - - # This gets the nicely setup view from the RateMap object and adds it to our array of matplotlib axes - ax_arr[0] = rt.get_view(ax_arr[0], ch, radial_bins_pix=pix_rads.value) - else: - # In this case there is no RateMap to add, so I don't need to shift the other plots across - offset = 0 - - # These simply plot the mass, temperature, and density profiles with legends turned off, residuals turned - # off, and no title - ax_arr[0 + offset] = self.get_view(fig, ax_arr[0 + offset], show_legend=False, custom_title='', - show_residual_ax=False)[0] - ax_arr[1 + offset] = \ - self.temperature_profile.get_view(fig, ax_arr[1 + offset], show_legend=False, custom_title='', - show_residual_ax=False)[0] - ax_arr[2 + offset] = self.density_profile.get_view(fig, ax_arr[2 + offset], show_legend=False, custom_title='', - show_residual_ax=False)[0] - # Then if there is a surface brightness profile thats added too - if sb is not None: - ax_arr[3 + offset] = sb.get_view(fig, ax_arr[3 + offset], show_legend=False, custom_title='', - show_residual_ax=False)[0] - - return ax_arr - - def diagnostic_view(self, src=None, figsize: tuple = None): - """ - This method produces a figure with the most important products that went into the creation of this - HydrostaticMass profile, for the purposes of quickly checking that everything looks sensible. The - maximum number of plots included is five; mass profile, temperature profile, density profile, - surface brightness profile, and ratemap. The RateMap will only be included if the source that this profile - was generated from is passed. - - :param GalaxyCluster src: The GalaxyCluster source that this HydrostaticMass profile was generated from. - :param tuple figsize: A tuple that sets the size of the diagnostic plot, default is None in which case - it is set automatically. - """ - - # Run the preparatory method to get the number of plots, RateMap, and SB profile - also performs - # some common sense checks if a source has been passed. - num_plots, rt, sb = self._diag_view_prep(src) - - # Calculate a sensible figsize if the user didn't pass one - if figsize is None: - figsize = (7.2 * num_plots, 7) - - # Set up the figure - fig = plt.figure(figsize=figsize) - # Set up and populate the axes with plots - ax_arr = self._gen_diag_view(fig, src, num_plots, rt, sb) - - # And show the figure - plt.tight_layout() - plt.show() - - plt.close('all') - - def save_diagnostic_view(self, save_path: str, src=None, figsize: tuple = None): - """ - This method saves a figure (without displaying) with the most important products that went into the creation - of this HydrostaticMass profile, for the purposes of quickly checking that everything looks sensible. The - maximum number of plots included is five; mass profile, temperature profile, density profile, surface - brightness profile, and ratemap. The RateMap will only be included if the source that this profile - was generated from is passed. - - :param str save_path: The path and filename where the diagnostic figure should be saved. - :param GalaxyCluster src: The GalaxyCluster source that this HydrostaticMass profile was generated from. - :param tuple figsize: A tuple that sets the size of the diagnostic plot, default is None in which case - it is set automatically. - """ - # Run the preparatory method to get the number of plots, RateMap, and SB profile - also performs - # some common sense checks if a source has been passed. - num_plots, rt, sb = self._diag_view_prep(src) - - # Calculate a sensible figsize if the user didn't pass one - if figsize is None: - figsize = (7.2 * num_plots, 7) - - # Set up the figure - fig = plt.figure(figsize=figsize) - # Set up and populate the axes with plots - ax_arr = self._gen_diag_view(fig, src, num_plots, rt, sb) - - # And show the figure - plt.tight_layout() - plt.savefig(save_path) - - plt.close('all') - - @property - def temperature_profile(self) -> GasTemperature3D: - """ - A method to provide access to the 3D temperature profile used to generate this hydrostatic mass profile. - - :return: The input temperature profile. - :rtype: GasTemperature3D - """ - return self._temp_prof - - @property - def density_profile(self) -> GasDensity3D: - """ - A method to provide access to the 3D density profile used to generate this hydrostatic mass profile. - - :return: The input density profile. - :rtype: GasDensity3D - """ - return self._dens_prof - - @property - def temperature_model(self) -> BaseModel1D: - """ - A method to provide access to the model that was fit to the temperature profile. - - :return: The fit temperature model. - :rtype: BaseModel1D - """ - return self._temp_model - - @property - def density_model(self) -> BaseModel1D: - """ - A method to provide access to the model that was fit to the density profile. - - :return: The fit density profile. - :rtype: BaseModel1D - """ - return self._dens_model - - def rad_check(self, rad: Quantity): - """ - Very simple method that prints a warning if the radius is outside the range of data covered by the - density or temperature profiles. - - :param Quantity rad: The radius to check. - """ - if not rad.unit.is_equivalent(self.radii_unit): - raise UnitConversionError("You can only check radii in units convertible to the radius units of " - "the profile ({})".format(self.radii_unit.to_string())) - - if (self._temp_prof.annulus_bounds is not None and (rad > self._temp_prof.annulus_bounds[-1]).any()) \ - or (self._dens_prof.annulus_bounds is not None and (rad > self._dens_prof.annulus_bounds[-1]).any()): - warn("Some radii are outside the data range covered by the temperature or density profiles, as such " - "you will be extrapolating based on the model fits.", stacklevel=2) - - -class NewHydrostaticMass(BaseProfile1D): """ A profile product which uses input temperature and density profiles to calculate a cumulative hydrostatic mass profile - used in galaxy cluster analyses (https://ui.adsabs.harvard.edu/abs/2024arXiv240307982T/abstract